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:
boos
2026-05-17 17:04:54 +08:00
parent 07f427877b
commit fe1a7c2115
+130 -2
View File
@@ -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))
}