use auth::{ crypto::{hash_password, verify_password}, jwt::generate_tokens, }; use axum::{ extract::State, http::{header::SET_COOKIE, StatusCode}, response::IntoResponse, routing::{get, post}, Json, Router, }; use db::models::user::{CreateUserPayload, UserRepository}; use serde::{Deserialize, Serialize}; use contracts::auth_middleware::AuthUser; use uuid::Uuid; use crate::AppState; pub fn router() -> Router { Router::new() .route("/check-email", post(check_email)) .route("/register", post(register)) .route("/login", post(login)) .route("/logout", post(logout)) .route("/refresh", post(refresh)) .route("/session", get(session)) .route("/switch-role", post(switch_role)) .route("/verify-email", post(verify_email)) .route("/verify-otp", post(verify_email)) .route("/resend-otp", post(resend_otp)) .route("/forgot-password", post(forgot_password)) .route("/reset-password", post(reset_password)) .route("/change-password", post(change_password)) } // ── DTOs ────────────────────────────────────────────────────────────────────── #[derive(Deserialize)] #[allow(dead_code)] pub struct RegisterPayload { #[serde(default)] pub first_name: Option, #[serde(default)] pub last_name: Option, #[serde(default)] pub name: Option, pub email: String, pub phone: Option, pub password: String, pub intent: Option, #[serde(alias = "role_key", alias = "roleKey")] pub profession: Option, #[serde(default)] pub test_mode: Option, } #[derive(Deserialize)] pub struct LoginPayload { pub email: String, pub password: String, } #[derive(Deserialize)] pub struct CheckEmailPayload { pub email: String, } #[derive(Deserialize)] pub struct VerifyEmailPayload { pub otp: String, } #[derive(Deserialize)] pub struct ResendOtpPayload { pub email: String, } #[derive(Deserialize)] pub struct ForgotPasswordPayload { pub email: String, } #[derive(Deserialize)] pub struct ResetPasswordPayload { pub code: String, pub new_password: String, } #[derive(Deserialize)] pub struct ChangePasswordPayload { pub current_password: String, pub new_password: String, } #[derive(Deserialize)] pub struct SwitchRolePayload { pub role_key: String, } #[derive(Serialize)] pub struct RegisterResponse { pub user_id: String, pub email: String, pub phone: Option, pub name: String, pub status: String, pub email_verified: bool, pub created_at: String, pub otp: Option, } #[derive(Serialize)] pub struct SessionUser { pub id: String, pub email: String, pub name: String, pub email_verified: bool, pub roles: Vec, pub active_role: Option, } #[derive(Serialize)] pub struct ErrorResponse { pub error: String, pub code: String, #[serde(rename = "statusCode")] pub status_code: u16, } fn err(status: StatusCode, msg: &str, code: &str) -> (StatusCode, Json) { (status, Json(ErrorResponse { error: msg.to_string(), code: code.to_string(), status_code: status.as_u16(), })) } fn normalize_role_key(raw: &str) -> String { raw.trim().to_uppercase().replace(['-', ' '], "_") } fn resolve_signup_role_candidates(intent: Option<&str>, profession: Option<&str>) -> Vec { let normalized_intent = intent.map(normalize_role_key).unwrap_or_default(); let normalized_profession = profession.map(normalize_role_key).filter(|v| !v.is_empty()); if normalized_intent.is_empty() { return vec![]; } if normalized_intent.contains("COMPANY") { return vec!["COMPANY".to_string()]; } if normalized_intent.contains("CUSTOMER") { return vec!["CUSTOMER".to_string()]; } if normalized_intent.contains("JOB_SEEKER") || normalized_intent.contains("JOBSEEKER") { return vec!["JOB_SEEKER".to_string()]; } if normalized_intent.contains("PROFESSIONAL") { if let Some(p) = normalized_profession { return vec![p, "PHOTOGRAPHER".to_string(), "JOB_SEEKER".to_string()]; } return vec!["PHOTOGRAPHER".to_string(), "JOB_SEEKER".to_string()]; } vec![] } fn is_dummy_account_email(email: &str) -> bool { email.ends_with("@demo.com") || email == "paymentgateway@demo.com" || email.contains("+dummy@") || email.starts_with("dummy+") } fn role_display_name_from_code(code: &str) -> String { code .split('_') .filter(|part| !part.is_empty()) .map(|part| { let lower = part.to_lowercase(); let mut chars = lower.chars(); match chars.next() { Some(first) => format!("{}{}", first.to_uppercase(), chars.collect::()), None => String::new(), } }) .collect::>() .join(" ") } async fn ensure_role_exists(pool: &sqlx::PgPool, role_code: &str) -> Option { let normalized = normalize_role_key(role_code); if normalized.is_empty() { return None; } if let Ok(found) = sqlx::query_scalar::<_, Uuid>("SELECT id FROM roles WHERE key = $1") .bind(&normalized) .fetch_optional(pool) .await { if found.is_some() { return found; } } let display_name = role_display_name_from_code(&normalized); let role_id = sqlx::query_scalar::<_, Uuid>( r#" INSERT INTO roles (key, name, audience, is_active) VALUES ($1, $2, 'EXTERNAL', true) ON CONFLICT (key) DO UPDATE SET is_active = true RETURNING id "#, ) .bind(&normalized) .bind(display_name) .fetch_one(pool) .await .ok()?; Some(role_id) } // ── Handlers ────────────────────────────────────────────────────────────────── /// POST /api/auth/check-email async fn check_email( State(state): State, Json(payload): Json, ) -> impl IntoResponse { let email = payload.email.trim().to_lowercase(); if email.is_empty() { return ( StatusCode::BAD_REQUEST, Json(serde_json::json!({ "exists": false, "error": "Email is required" })), ); } let user = UserRepository::get_by_email(&state.pool, &email).await.ok(); let exists = user.is_some(); let roles = if let Some(ref found_user) = user { UserRepository::get_user_role_keys(&state.pool, found_user.id) .await .unwrap_or_default() } else { Vec::new() }; let active_role = roles.first().cloned(); ( StatusCode::OK, Json(serde_json::json!({ "exists": exists, "active_role": active_role, "roles": roles, })), ) } /// POST /api/auth/register async fn register( State(state): State, Json(payload): Json, ) -> Result)> { let email = payload.email.to_lowercase(); let test_mode = payload.test_mode.unwrap_or(false); let mut redis = state.redis.clone(); // Rate limit: max 10 registrations per hour per email if !cache::rate_limit::check_register(&mut redis, &email).await.unwrap_or(true) { return Err(err(StatusCode::TOO_MANY_REQUESTS, "Too many registration attempts. Try again later.", "RATE_LIMITED")); } if payload.password.len() < 8 { return Err(err(StatusCode::UNPROCESSABLE_ENTITY, "Password must be at least 8 characters", "VALIDATION_ERROR")); } let password_hash = hash_password(&payload.password) .map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string(), "INTERNAL_ERROR"))?; let first_name = payload.first_name.unwrap_or_default().trim().to_string(); let last_name = payload.last_name.unwrap_or_default().trim().to_string(); let user = UserRepository::create(&state.pool, CreateUserPayload { first_name: Some(first_name), last_name: Some(last_name), email: email.clone(), password_hash, }) .await .map_err(|e| { let msg = e.to_string(); if msg.contains("users_email_key") || (msg.contains("email") && msg.contains("unique")) { err(StatusCode::CONFLICT, "Email already registered", "EMAIL_EXISTS") } else if msg.contains("users_phone_key") || (msg.contains("phone") && msg.contains("unique")) { err(StatusCode::CONFLICT, "Phone already registered", "PHONE_EXISTS") } else { err(StatusCode::INTERNAL_SERVER_ERROR, &msg, "DB_ERROR") } })?; // Check if this is a demo account (payment gateway integration) let is_demo_account = is_dummy_account_email(&email); // Assign signup role immediately (intent-driven). Email verification is still required for login. let role_candidates = resolve_signup_role_candidates( payload.intent.as_deref(), payload.profession.as_deref(), ); for role_key in role_candidates { let role_id = ensure_role_exists(&state.pool, &role_key).await; if let Some(role_id) = role_id { // For demo accounts, auto-approve the role immediately let status = if is_demo_account { "APPROVED" } else { "PENDING" }; let _ = sqlx::query( r#" UPDATE user_role_assignments SET status = $3 WHERE user_id = $1 AND role_id = $2 "#, ) .bind(user.id) .bind(role_id) .bind(status) .execute(&state.pool) .await; let _ = sqlx::query( r#" INSERT INTO user_role_assignments (user_id, role_id, status) SELECT $1, $2, $3 WHERE NOT EXISTS ( SELECT 1 FROM user_role_assignments WHERE user_id = $1 AND role_id = $2 ) "#, ) .bind(user.id) .bind(role_id) .bind(status) .execute(&state.pool) .await; break; } } // For demo accounts: auto-verify email and skip OTP if is_demo_account { tracing::info!(email = %email, "Demo account auto-verified"); let _ = sqlx::query( "UPDATE users SET email_verified = true, status = 'ACTIVE' WHERE id = $1" ) .bind(user.id) .execute(&state.pool) .await; // Return success with demo flag let user_name = format!("{} {}", user.first_name.unwrap_or_default(), user.last_name.unwrap_or_default()); return Ok((StatusCode::CREATED, Json(RegisterResponse { user_id: user.id.to_string(), email: user.email, phone: None, name: user_name, status: "ACTIVE".to_string(), email_verified: true, created_at: user.created_at.to_rfc3339(), otp: Some("DEMO".to_string()), // Return dummy OTP for demo }))); } // Store OTP in Redis (15-min TTL, keyed by code → user_id) let otp = format!("{:06}", rand::random::() % 1_000_000); tracing::info!(otp = %otp, email = %email, "OTP generated for registration"); cache::otp::set(&mut redis, &otp, &user.id.to_string()) .await .map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string(), "CACHE_ERROR"))?; cache::otp::record_resend(&mut redis, &user.id.to_string()).await.ok(); let user_name = format!("{} {}", user.first_name.unwrap_or_default(), user.last_name.unwrap_or_default()); if let Err(e) = state.mail.send_verification_email(&user.email, &user_name, &otp).await { tracing::error!( error = %e, email = %user.email, endpoint = "/api/auth/register", "Failed to send verification email - OTP still stored in Redis" ); // OTP is already in Redis — do not fail registration if email sending fails } Ok((StatusCode::CREATED, Json(RegisterResponse { user_id: user.id.to_string(), email: user.email, phone: None, name: user_name, status: user.status, email_verified: user.email_verified, created_at: user.created_at.to_rfc3339(), otp: if test_mode { Some(otp) } else { None }, }))) } /// POST /api/auth/login async fn login( State(state): State, Json(payload): Json, ) -> Result)> { let email = payload.email.to_lowercase(); let mut redis = state.redis.clone(); // Rate limit: max 10 login attempts per 15 min per email if !cache::rate_limit::check_login(&mut redis, &email).await.unwrap_or(true) { return Err(err(StatusCode::TOO_MANY_REQUESTS, "Too many login attempts. Try again in 15 minutes.", "RATE_LIMITED")); } let user = UserRepository::get_by_email(&state.pool, &email) .await .map_err(|_| err(StatusCode::UNAUTHORIZED, "Invalid credentials", "INVALID_CREDENTIALS"))?; if user.status == "SUSPENDED" { return Err(err(StatusCode::FORBIDDEN, "Account suspended", "ACCOUNT_SUSPENDED")); } // Allow demo accounts to login without email verification let is_demo_account = is_dummy_account_email(&email); if !user.email_verified && !is_demo_account { return Err(err(StatusCode::UNAUTHORIZED, "Email not verified. Check your inbox.", "EMAIL_NOT_VERIFIED")); } let is_valid = verify_password(&payload.password, &user.password_hash) .map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string(), "INTERNAL_ERROR"))?; if !is_valid { return Err(err(StatusCode::UNAUTHORIZED, "Invalid credentials", "INVALID_CREDENTIALS")); } let user_roles = UserRepository::get_user_role_keys(&state.pool, user.id) .await .unwrap_or_default(); let jwt_secret = std::env::var("JWT_SECRET").expect("JWT_SECRET must be set"); let tokens = generate_tokens( user.id.to_string(), user.email.clone(), user_roles.clone(), user_roles.first().cloned(), &jwt_secret, ) .map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string(), "TOKEN_ERROR"))?; // Refresh token → Redis (30-day TTL) cache::token::store_refresh(&mut redis, &tokens.refresh_token, &user.id.to_string()) .await .map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string(), "CACHE_ERROR"))?; let cookie = format!( "nxtgauge_refresh_token={}; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=2592000", tokens.refresh_token ); let active_role = user_roles.first().cloned(); let user_name = format!("{} {}", user.first_name.unwrap_or_default(), user.last_name.unwrap_or_default()); Ok((StatusCode::OK, [(SET_COOKIE, cookie)], Json(serde_json::json!({ "access_token": tokens.access_token, "token_type": "Bearer", "expires_in": 900, "user": { "id": user.id.to_string(), "email": user.email, "name": user_name, "email_verified": user.email_verified, "active_role": active_role, "roles": user_roles, } })))) } /// POST /api/auth/logout async fn logout( State(state): State, req: axum::http::Request, ) -> impl IntoResponse { let cookie_header = req .headers() .get(axum::http::header::COOKIE) .and_then(|v| v.to_str().ok()) .unwrap_or(""); if let Some(token) = cookie_header .split(';') .map(str::trim) .find_map(|p| p.strip_prefix("nxtgauge_refresh_token=")) { let mut redis = state.redis.clone(); let _ = cache::token::revoke_refresh(&mut redis, token).await; } let clear = "nxtgauge_refresh_token=; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=0"; (StatusCode::OK, [(SET_COOKIE, clear)], Json(serde_json::json!({ "message": "Logged out" }))) } /// POST /api/auth/refresh async fn refresh( State(state): State, req: axum::http::Request, ) -> Result)> { let cookie_header = req .headers() .get(axum::http::header::COOKIE) .and_then(|v| v.to_str().ok()) .unwrap_or(""); let token = cookie_header .split(';') .map(str::trim) .find_map(|p| p.strip_prefix("nxtgauge_refresh_token=")) .ok_or_else(|| err(StatusCode::UNAUTHORIZED, "Refresh token missing", "REFRESH_TOKEN_INVALID"))?; let mut redis = state.redis.clone(); let user_id_str = cache::token::get_refresh(&mut redis, token) .await .map_err(|_| err(StatusCode::UNAUTHORIZED, "Refresh token invalid", "REFRESH_TOKEN_INVALID"))? .ok_or_else(|| err(StatusCode::UNAUTHORIZED, "Refresh token expired", "REFRESH_TOKEN_INVALID"))?; let user_id = user_id_str .parse::() .map_err(|_| err(StatusCode::UNAUTHORIZED, "Refresh token corrupt", "REFRESH_TOKEN_INVALID"))?; let user = UserRepository::get_by_id(&state.pool, user_id) .await .map_err(|_| err(StatusCode::UNAUTHORIZED, "User not found", "INVALID_CREDENTIALS"))?; // Rotate: revoke old, issue new let _ = cache::token::revoke_refresh(&mut redis, token).await; let user_roles = UserRepository::get_user_role_keys(&state.pool, user.id) .await .unwrap_or_default(); let jwt_secret = std::env::var("JWT_SECRET").expect("JWT_SECRET must be set"); let tokens = generate_tokens( user.id.to_string(), user.email.clone(), user_roles.clone(), user_roles.first().cloned(), &jwt_secret, ) .map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string(), "TOKEN_ERROR"))?; cache::token::store_refresh(&mut redis, &tokens.refresh_token, &user.id.to_string()) .await .map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string(), "CACHE_ERROR"))?; let new_cookie = format!( "nxtgauge_refresh_token={}; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=2592000", tokens.refresh_token ); Ok((StatusCode::OK, [(SET_COOKIE, new_cookie)], Json(serde_json::json!({ "access_token": tokens.access_token, "expires_in": 900 })))) } /// GET /api/auth/session async fn session( auth: AuthUser, State(state): State, ) -> Result)> { let user = UserRepository::get_by_id(&state.pool, auth.user_id) .await .map_err(|_| err(StatusCode::UNAUTHORIZED, "User not found", "USER_NOT_FOUND"))?; let user_roles = UserRepository::get_user_role_keys(&state.pool, user.id) .await .unwrap_or_default(); let user_name = format!("{} {}", user.first_name.unwrap_or_default(), user.last_name.unwrap_or_default()); Ok(Json(SessionUser { id: user.id.to_string(), email: user.email, name: user_name, email_verified: user.email_verified, active_role: user_roles.first().cloned(), roles: user_roles, })) } /// POST /api/auth/verify-email { "otp": "123456" } async fn verify_email( State(state): State, Json(payload): Json, ) -> Result)> { let mut redis = state.redis.clone(); // Atomically consume OTP from Redis (GETDEL — single use, auto-expiry) let user_id_str = cache::otp::consume(&mut redis, &payload.otp) .await .map_err(|_| err(StatusCode::INTERNAL_SERVER_ERROR, "Cache error", "CACHE_ERROR"))? .ok_or_else(|| err(StatusCode::UNAUTHORIZED, "Invalid or expired verification code", "INVALID_CODE"))?; let user_id = user_id_str .parse::() .map_err(|_| err(StatusCode::UNAUTHORIZED, "Invalid verification code", "INVALID_CODE"))?; UserRepository::set_email_verified(&state.pool, user_id) .await .map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string(), "DB_ERROR"))?; // Get user details for welcome email if let Ok(user) = UserRepository::get_by_id(&state.pool, user_id).await { let user_name = format!("{} {}", user.first_name.unwrap_or_default(), user.last_name.unwrap_or_default()); if let Err(e) = state.mail.send_welcome_email(&user.email, &user_name).await { tracing::error!( error = %e, email = %user.email, endpoint = "/api/auth/verify-email", "Failed to send welcome email" ); } } Ok((StatusCode::OK, Json(serde_json::json!({ "message": "Email verified successfully" })))) } /// POST /api/auth/resend-otp async fn resend_otp( State(state): State, Json(payload): Json, ) -> Result)> { let silent_ok = (StatusCode::OK, Json(serde_json::json!({ "message": "If the email is registered, a new code has been sent" }))); let user = match UserRepository::get_by_email(&state.pool, &payload.email.to_lowercase()).await { Ok(u) => u, Err(_) => return Ok(silent_ok), }; if user.email_verified { return Ok(silent_ok); } let mut redis = state.redis.clone(); // Rate limit: max 3 resends per hour per user if !cache::otp::resend_allowed(&mut redis, &user.id.to_string()).await.unwrap_or(true) { return Err(err(StatusCode::TOO_MANY_REQUESTS, "Too many OTP requests. Try again in an hour.", "RATE_LIMITED")); } let otp = format!("{:06}", rand::random::() % 1_000_000); tracing::info!(otp = %otp, email = %user.email, "OTP generated for resend"); cache::otp::set(&mut redis, &otp, &user.id.to_string()) .await .map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string(), "CACHE_ERROR"))?; cache::otp::record_resend(&mut redis, &user.id.to_string()).await.ok(); let user_name = format!("{} {}", user.first_name.unwrap_or_default(), user.last_name.unwrap_or_default()); if let Err(e) = state.mail.send_verification_email(&user.email, &user_name, &otp).await { tracing::error!( error = %e, email = %user.email, endpoint = "/api/auth/resend-otp", "Failed to resend verification email" ); return Err(err( StatusCode::INTERNAL_SERVER_ERROR, "Failed to resend verification email", "SMTP_ERROR", )); } Ok(silent_ok) } /// POST /api/auth/forgot-password async fn forgot_password( State(state): State, Json(payload): Json, ) -> Result)> { let silent_ok = (StatusCode::OK, Json(serde_json::json!({ "message": "Reset code sent if email exists" }))); let user = match UserRepository::get_by_email(&state.pool, &payload.email.to_lowercase()).await { Ok(u) => u, Err(_) => return Ok(silent_ok), }; let code = format!("{:06}", rand::random::() % 1_000_000); tracing::info!(otp = %code, email = %user.email, "OTP generated for password reset"); let mut redis = state.redis.clone(); cache::token::store_reset(&mut redis, &code, &user.id.to_string()) .await .map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string(), "CACHE_ERROR"))?; let user_name = format!("{} {}", user.first_name.unwrap_or_default(), user.last_name.unwrap_or_default()); let _ = state.mail.send_password_reset_email(&user.email, &user_name, &code).await; Ok(silent_ok) } /// POST /api/auth/reset-password async fn reset_password( State(state): State, Json(payload): Json, ) -> Result)> { let mut redis = state.redis.clone(); // Consume reset code from Redis (single-use GETDEL) let user_id_str = cache::token::consume_reset(&mut redis, &payload.code) .await .map_err(|_| err(StatusCode::INTERNAL_SERVER_ERROR, "Cache error", "CACHE_ERROR"))? .ok_or_else(|| err(StatusCode::UNAUTHORIZED, "Invalid or expired reset code", "INVALID_CODE"))?; let user_id = user_id_str .parse::() .map_err(|_| err(StatusCode::UNAUTHORIZED, "Invalid reset code", "INVALID_CODE"))?; if payload.new_password.len() < 8 { return Err(err(StatusCode::UNPROCESSABLE_ENTITY, "Password must be at least 8 characters", "VALIDATION_ERROR")); } let password_hash = hash_password(&payload.new_password) .map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string(), "INTERNAL_ERROR"))?; UserRepository::update_password(&state.pool, user_id, &password_hash) .await .map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string(), "DB_ERROR"))?; if let Ok(user) = UserRepository::get_by_id(&state.pool, user_id).await { let user_name = format!("{} {}", user.first_name.unwrap_or_default(), user.last_name.unwrap_or_default()); let _ = state.mail.send_password_changed_email(&user.email, &user_name).await; } Ok((StatusCode::OK, Json(serde_json::json!({ "message": "Password reset successfully" })))) } /// POST /api/auth/change-password async fn change_password( auth: AuthUser, State(state): State, Json(payload): Json, ) -> Result)> { let user = UserRepository::get_by_id(&state.pool, auth.user_id) .await .map_err(|_| err(StatusCode::UNAUTHORIZED, "User not found", "USER_NOT_FOUND"))?; if !verify_password(&payload.current_password, &user.password_hash) .map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string(), "AUTH_ERROR"))? { return Err(err(StatusCode::UNAUTHORIZED, "Incorrect current password", "INVALID_PASSWORD")); } if payload.new_password.len() < 8 { return Err(err(StatusCode::UNPROCESSABLE_ENTITY, "Password must be at least 8 characters", "VALIDATION_ERROR")); } let password_hash = hash_password(&payload.new_password) .map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string(), "INTERNAL_ERROR"))?; UserRepository::update_password(&state.pool, user.id, &password_hash) .await .map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string(), "DB_ERROR"))?; let user_name = format!("{} {}", user.first_name.unwrap_or_default(), user.last_name.unwrap_or_default()); let _ = state.mail.send_password_changed_email(&user.email, &user_name).await; Ok((StatusCode::OK, Json(serde_json::json!({ "message": "Password changed successfully" })))) } /// POST /api/auth/switch-role async fn switch_role( auth: AuthUser, State(state): State, Json(payload): Json, ) -> Result)> { let user_roles = UserRepository::get_user_role_keys(&state.pool, auth.user_id) .await .unwrap_or_default(); let requested = payload.role_key.to_uppercase(); if !user_roles.contains(&requested) { return Err(err(StatusCode::FORBIDDEN, "You do not have this role", "ROLE_NOT_FOUND")); } let jwt_secret = std::env::var("JWT_SECRET").expect("JWT_SECRET must be set"); let tokens = generate_tokens( auth.user_id.to_string(), auth.email.clone(), user_roles, Some(requested), &jwt_secret, ) .map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string(), "TOKEN_ERROR"))?; Ok((StatusCode::OK, Json(serde_json::json!({ "access_token": tokens.access_token, "expires_in": 900 })))) } // ── V1 API Router (for backward compatibility) ───────────────────────── pub fn v1_router() -> Router { Router::new() .route("/sign-up", post(v1_sign_up)) .route("/verify-otp", post(v1_verify_otp)) .route("/resend-otp", post(resend_otp)) } #[derive(Deserialize)] struct V1VerifyOtpPayload { #[serde(alias = "code")] otp: String, } /// POST /api/v1/users/sign-up async fn v1_sign_up( State(state): State, Json(payload): Json, ) -> Result)> { register(State(state), Json(payload)).await } /// POST /api/v1/users/verify-otp async fn v1_verify_otp( State(state): State, Json(payload): Json, ) -> Result)> { verify_email(State(state), Json(VerifyEmailPayload { otp: payload.otp })).await }