feat: User profile routes migrated to Rust
- PATCH /api/users/me — update own username
- POST /api/users/me/password — change password (verify old first)
- GET /api/users/{id} — get user profile (self or admin)
- JWT auth for all endpoints
- 18/18 tests pass
This commit is contained in:
@@ -8,9 +8,9 @@ use crate::{
|
|||||||
AppState,
|
AppState,
|
||||||
};
|
};
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::State,
|
extract::{Path, State},
|
||||||
http::HeaderMap,
|
http::HeaderMap,
|
||||||
routing::{get, post},
|
routing::{get, patch, post},
|
||||||
Json, Router,
|
Json, Router,
|
||||||
};
|
};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
@@ -53,6 +53,9 @@ pub fn routes() -> Router<AppState> {
|
|||||||
.route("/api/auth/register", post(register))
|
.route("/api/auth/register", post(register))
|
||||||
.route("/api/auth/login", post(login))
|
.route("/api/auth/login", post(login))
|
||||||
.route("/api/auth/me", get(me))
|
.route("/api/auth/me", get(me))
|
||||||
|
.route("/api/users/me", patch(update_me))
|
||||||
|
.route("/api/users/me/password", post(change_password))
|
||||||
|
.route("/api/users/{id}", get(get_user))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Handlers ─────────────────────────────────────────────────────────
|
// ── Handlers ─────────────────────────────────────────────────────────
|
||||||
@@ -191,3 +194,128 @@ async fn me(
|
|||||||
}
|
}
|
||||||
})))
|
})))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// PATCH /api/users/me — update current user profile
|
||||||
|
async fn update_me(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Json(body): Json<serde_json::Value>,
|
||||||
|
) -> AppResult<Json<Value>> {
|
||||||
|
let (user_id, _role) = extract_jwt(&state, &headers)?;
|
||||||
|
|
||||||
|
let username = body.get("username").and_then(|v| v.as_str());
|
||||||
|
if let Some(name) = username {
|
||||||
|
if !name.trim().is_empty() {
|
||||||
|
sqlx::query("UPDATE users SET username = ? WHERE id = ?")
|
||||||
|
.bind(name.trim())
|
||||||
|
.bind(user_id)
|
||||||
|
.execute(&*state.db)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let user: User = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = ?")
|
||||||
|
.bind(user_id)
|
||||||
|
.fetch_one(&*state.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(Json(json!({
|
||||||
|
"user": {
|
||||||
|
"id": user.id,
|
||||||
|
"username": user.username,
|
||||||
|
"email": user.email,
|
||||||
|
"role": user.role,
|
||||||
|
"status": user.status,
|
||||||
|
}
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST /api/users/me/password — change current user password
|
||||||
|
async fn change_password(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Json(body): Json<serde_json::Value>,
|
||||||
|
) -> AppResult<Json<Value>> {
|
||||||
|
let (user_id, _role) = extract_jwt(&state, &headers)?;
|
||||||
|
|
||||||
|
let old_pw = body.get("oldPassword").and_then(|v| v.as_str())
|
||||||
|
.ok_or_else(|| AppError::BadRequest("oldPassword is required".into()))?;
|
||||||
|
let new_pw = body.get("newPassword").and_then(|v| v.as_str())
|
||||||
|
.ok_or_else(|| AppError::BadRequest("newPassword is required".into()))?;
|
||||||
|
|
||||||
|
if new_pw.len() < 6 {
|
||||||
|
return Err(AppError::BadRequest("new password must be at least 6 characters".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let user: User = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = ?")
|
||||||
|
.bind(user_id)
|
||||||
|
.fetch_optional(&*state.db)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| AppError::NotFound("User not found".into()))?;
|
||||||
|
|
||||||
|
let valid = crate::auth::password::verify_password(old_pw, &user.password_hash)
|
||||||
|
.map_err(|e| AppError::Internal(anyhow::anyhow!("Password verification failed: {e}")))?;
|
||||||
|
|
||||||
|
if !valid {
|
||||||
|
return Err(AppError::Unauthorized("Current password is incorrect".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let new_hash = crate::auth::password::hash_password(new_pw)
|
||||||
|
.map_err(|e| AppError::Internal(anyhow::anyhow!("Failed to hash password: {e}")))?;
|
||||||
|
|
||||||
|
sqlx::query("UPDATE users SET password_hash = ? WHERE id = ?")
|
||||||
|
.bind(&new_hash)
|
||||||
|
.bind(user_id)
|
||||||
|
.execute(&*state.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(Json(json!({ "success": true })))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /api/users/:id — get user profile (self or admin)
|
||||||
|
async fn get_user(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Path(id): Path<i64>,
|
||||||
|
) -> AppResult<Json<Value>> {
|
||||||
|
let (user_id, role) = extract_jwt(&state, &headers)?;
|
||||||
|
|
||||||
|
if user_id != id && role != "admin" {
|
||||||
|
return Err(AppError::Forbidden("Forbidden".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let user: User = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = ?")
|
||||||
|
.bind(id)
|
||||||
|
.fetch_optional(&*state.db)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| AppError::NotFound("User not found".into()))?;
|
||||||
|
|
||||||
|
Ok(Json(json!({
|
||||||
|
"user": {
|
||||||
|
"id": user.id,
|
||||||
|
"username": user.username,
|
||||||
|
"email": user.email,
|
||||||
|
"role": user.role,
|
||||||
|
"status": user.status,
|
||||||
|
"created_at": user.created_at,
|
||||||
|
}
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Inline JWT extract helper for this module ──────────────────────────
|
||||||
|
|
||||||
|
fn extract_jwt(state: &AppState, headers: &HeaderMap) -> AppResult<(i64, String)> {
|
||||||
|
let auth = headers
|
||||||
|
.get("Authorization")
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.and_then(|v| v.strip_prefix("Bearer "))
|
||||||
|
.ok_or_else(|| AppError::Unauthorized("Missing Authorization header".into()))?;
|
||||||
|
|
||||||
|
let claims = crate::auth::jwt::validate_jwt(&state.config.auth.jwt_secret, auth)
|
||||||
|
.map_err(|_| AppError::Unauthorized("Invalid or expired token".into()))?;
|
||||||
|
|
||||||
|
let uid: i64 = claims.sub.parse()
|
||||||
|
.map_err(|_| AppError::Unauthorized("Invalid user ID in token".into()))?;
|
||||||
|
|
||||||
|
Ok((uid, claims.role))
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user