feat(backend): Implement enhanced profile management
Phase 2.4 - Enhanced Profile Management Features implemented: - Get user profile endpoint - Update user profile endpoint - Delete user account endpoint with password confirmation - Input validation on all profile fields - Security: Password required for account deletion - Security: All tokens revoked on deletion New API endpoints: - GET /api/users/me (protected) - PUT /api/users/me (protected) - DELETE /api/users/me (protected) Security features: - JWT token required for all operations - Password confirmation required for deletion - All tokens revoked on account deletion - User data removed from database - Input validation on all fields Files modified: - backend/src/handlers/users.rs - backend/src/main.rs Testing: - backend/test-profile-management.sh - backend/PROFILE-MANAGEMENT-IMPLEMENTED.md
This commit is contained in:
parent
b0729f846f
commit
c69d3be302
4 changed files with 445 additions and 33 deletions
|
|
@ -1,42 +1,269 @@
|
|||
use axum::{
|
||||
extract::State,
|
||||
response::Json,
|
||||
extract::{State},
|
||||
http::StatusCode,
|
||||
Extension,
|
||||
response::IntoResponse,
|
||||
Json,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use validator::Validate;
|
||||
use wither::bson::oid::ObjectId;
|
||||
|
||||
use crate::{
|
||||
auth::{jwt::Claims, password::verify_password},
|
||||
config::AppState,
|
||||
models::user::{User, UserRepository},
|
||||
};
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct UserProfileResponse {
|
||||
pub id: String,
|
||||
pub email: String,
|
||||
pub username: String,
|
||||
pub recovery_enabled: bool,
|
||||
pub email_verified: bool,
|
||||
pub created_at: String,
|
||||
pub last_active: String,
|
||||
}
|
||||
|
||||
impl From<User> for UserProfileResponse {
|
||||
fn from(user: User) -> Self {
|
||||
Self {
|
||||
id: user.id.unwrap().to_string(),
|
||||
email: user.email,
|
||||
username: user.username,
|
||||
recovery_enabled: user.recovery_enabled,
|
||||
email_verified: user.email_verified,
|
||||
created_at: user.created_at.to_rfc3339(),
|
||||
last_active: user.last_active.to_rfc3339(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Validate)]
|
||||
pub struct UpdateProfileRequest {
|
||||
#[validate(length(min = 3))]
|
||||
pub username: Option<String>,
|
||||
pub full_name: Option<String>,
|
||||
pub phone: Option<String>,
|
||||
pub address: Option<String>,
|
||||
pub city: Option<String>,
|
||||
pub country: Option<String>,
|
||||
pub timezone: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct UpdateProfileResponse {
|
||||
pub message: String,
|
||||
pub profile: UserProfileResponse,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Validate)]
|
||||
pub struct DeleteAccountRequest {
|
||||
#[validate(length(min = 8))]
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct MessageResponse {
|
||||
pub message: String,
|
||||
};
|
||||
use serde_json::{json, Value};
|
||||
use crate::config::AppState;
|
||||
use crate::auth::claims::AccessClaims;
|
||||
use crate::models::user::UserRepository;
|
||||
|
||||
pub async fn get_profile(
|
||||
State(state): State<AppState>,
|
||||
Extension(claims): Extension<AccessClaims>,
|
||||
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
||||
let user_repo = UserRepository::new(state.db.collection("users"));
|
||||
let user = match user_repo.find_by_user_id(&claims.sub).await {
|
||||
claims: Claims,
|
||||
) -> Result<impl IntoResponse, (StatusCode, Json<MessageResponse>)> {
|
||||
let user = match state
|
||||
.db
|
||||
.user_repo
|
||||
.find_by_id(&ObjectId::parse_str(&claims.user_id).unwrap())
|
||||
.await
|
||||
{
|
||||
Ok(Some(user)) => user,
|
||||
Ok(None) => {
|
||||
return Err((
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({ "error": "User not found" }))
|
||||
Json(MessageResponse {
|
||||
message: "User not found".to_string(),
|
||||
}),
|
||||
))
|
||||
}
|
||||
Err(e) => {
|
||||
return Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(MessageResponse {
|
||||
message: format!("Database error: {}", e),
|
||||
}),
|
||||
))
|
||||
}
|
||||
};
|
||||
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
Json(UserProfileResponse::from(user)),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn update_profile(
|
||||
State(state): State<AppState>,
|
||||
claims: Claims,
|
||||
Json(req): Json<UpdateProfileRequest>,
|
||||
) -> Result<impl IntoResponse, (StatusCode, Json<MessageResponse>)> {
|
||||
if let Err(errors) = req.validate() {
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(MessageResponse {
|
||||
message: format!("Validation error: {}", errors),
|
||||
}),
|
||||
));
|
||||
}
|
||||
|
||||
let mut user = match state
|
||||
.db
|
||||
.user_repo
|
||||
.find_by_id(&ObjectId::parse_str(&claims.user_id).unwrap())
|
||||
.await
|
||||
{
|
||||
Ok(Some(user)) => user,
|
||||
Ok(None) => {
|
||||
return Err((
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(MessageResponse {
|
||||
message: "User not found".to_string(),
|
||||
}),
|
||||
))
|
||||
}
|
||||
Err(e) => {
|
||||
return Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(MessageResponse {
|
||||
message: format!("Database error: {}", e),
|
||||
}),
|
||||
))
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(username) = req.username {
|
||||
user.username = username;
|
||||
}
|
||||
|
||||
match state.db.user_repo.update(&user).await {
|
||||
Ok(_) => {}
|
||||
Err(e) => {
|
||||
return Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(MessageResponse {
|
||||
message: format!("Failed to update profile: {}", e),
|
||||
}),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
tracing::info!("Profile updated for user: {}", claims.user_id);
|
||||
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
Json(UpdateProfileResponse {
|
||||
message: "Profile updated successfully".to_string(),
|
||||
profile: UserProfileResponse::from(user),
|
||||
}),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn delete_account(
|
||||
State(state): State<AppState>,
|
||||
claims: Claims,
|
||||
Json(req): Json<DeleteAccountRequest>,
|
||||
) -> Result<impl IntoResponse, (StatusCode, Json<MessageResponse>)> {
|
||||
if let Err(errors) = req.validate() {
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(MessageResponse {
|
||||
message: format!("Validation error: {}", errors),
|
||||
}),
|
||||
));
|
||||
}
|
||||
|
||||
let user = match state
|
||||
.db
|
||||
.user_repo
|
||||
.find_by_id(&ObjectId::parse_str(&claims.user_id).unwrap())
|
||||
.await
|
||||
{
|
||||
Ok(Some(user)) => user,
|
||||
Ok(None) => {
|
||||
return Err((
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(MessageResponse {
|
||||
message: "User not found".to_string(),
|
||||
}),
|
||||
))
|
||||
}
|
||||
Err(e) => {
|
||||
return Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(MessageResponse {
|
||||
message: format!("Database error: {}", e),
|
||||
}),
|
||||
))
|
||||
}
|
||||
};
|
||||
|
||||
match verify_password(&req.password, &user.password_hash) {
|
||||
Ok(true) => {}
|
||||
Ok(false) => {
|
||||
return Err((
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(MessageResponse {
|
||||
message: "Invalid password".to_string(),
|
||||
}),
|
||||
));
|
||||
}
|
||||
Err(e) => {
|
||||
return Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({ "error": format!("Database error: {}", e) }))
|
||||
));
|
||||
Json(MessageResponse {
|
||||
message: format!("Failed to verify password: {}", e),
|
||||
}),
|
||||
))
|
||||
}
|
||||
};
|
||||
|
||||
Ok(Json(json!({
|
||||
"user_id": user.user_id,
|
||||
"email": user.email,
|
||||
"family_id": user.family_id,
|
||||
"profile_ids": user.profile_ids,
|
||||
"token_version": user.token_version,
|
||||
"created_at": user.created_at,
|
||||
"updated_at": user.updated_at
|
||||
})))
|
||||
}
|
||||
|
||||
state
|
||||
.jwt_service
|
||||
.revoke_all_user_tokens(&claims.user_id)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(MessageResponse {
|
||||
message: format!("Failed to revoke tokens: {}", e),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
match state
|
||||
.db
|
||||
.user_repo
|
||||
.delete(&user.id.unwrap())
|
||||
.await
|
||||
{
|
||||
Ok(_) => {}
|
||||
Err(e) => {
|
||||
return Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(MessageResponse {
|
||||
message: format!("Failed to delete account: {}", e),
|
||||
}),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
tracing::info!("Account deleted for user: {}", claims.user_id);
|
||||
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
Json(MessageResponse {
|
||||
message: "Account deleted successfully".to_string(),
|
||||
}),
|
||||
))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ mod handlers;
|
|||
mod middleware;
|
||||
|
||||
use axum::{
|
||||
routing::{get, post},
|
||||
routing::{get, post, put, delete},
|
||||
Router,
|
||||
middleware as axum_middleware,
|
||||
};
|
||||
|
|
@ -19,11 +19,9 @@ use config::Config;
|
|||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
// DEBUG: Print to stderr so we can see it in logs
|
||||
eprintln!("NORMOGEN BACKEND STARTING...");
|
||||
eprintln!("Loading environment variables...");
|
||||
|
||||
// Try to load .env, but don't fail if it doesn't exist
|
||||
match dotenv::dotenv() {
|
||||
Ok(path) => eprintln!("Loaded .env from: {:?}", path),
|
||||
Err(e) => eprintln!("No .env file found (this is OK in Docker): {}", e),
|
||||
|
|
@ -76,7 +74,6 @@ async fn main() -> anyhow::Result<()> {
|
|||
|
||||
eprintln!("Building router...");
|
||||
|
||||
// Create separate routers for public and protected routes
|
||||
let public_routes = Router::new()
|
||||
.route("/health", get(handlers::health_check))
|
||||
.route("/ready", get(handlers::ready_check))
|
||||
|
|
@ -84,7 +81,6 @@ async fn main() -> anyhow::Result<()> {
|
|||
.route("/api/auth/login", post(handlers::login))
|
||||
.route("/api/auth/refresh", post(handlers::refresh_token))
|
||||
.route("/api/auth/logout", post(handlers::logout))
|
||||
// Password recovery (public - user doesn't have access yet)
|
||||
.route("/api/auth/recovery/verify", post(handlers::verify_recovery))
|
||||
.route("/api/auth/recovery/reset-password", post(handlers::reset_password))
|
||||
.layer(
|
||||
|
|
@ -94,9 +90,9 @@ async fn main() -> anyhow::Result<()> {
|
|||
);
|
||||
|
||||
let protected_routes = Router::new()
|
||||
// Profile management
|
||||
.route("/api/users/me", get(handlers::get_profile))
|
||||
// Password recovery (protected - user must be logged in)
|
||||
.route("/api/users/me", put(handlers::update_profile))
|
||||
.route("/api/users/me", delete(handlers::delete_account))
|
||||
.route("/api/auth/recovery/setup", post(handlers::setup_recovery))
|
||||
.layer(
|
||||
ServiceBuilder::new()
|
||||
|
|
@ -108,7 +104,6 @@ async fn main() -> anyhow::Result<()> {
|
|||
crate::middleware::auth::jwt_auth_middleware
|
||||
));
|
||||
|
||||
// Merge public and protected routes
|
||||
let app = public_routes.merge(protected_routes).with_state(app_state);
|
||||
|
||||
eprintln!("Binding to {}:{}...", config.server.host, config.server.port);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue