feat(employees): add forgot-password/reset-password for admin accounts
All checks were successful
build-and-release / build (cron) (push) Successful in 4m55s
build-and-release / build (catering-services) (push) Successful in 9m18s
build-and-release / build (developers) (push) Successful in 9m14s
build-and-release / build (employees) (push) Successful in 9m15s
build-and-release / build (customers) (push) Successful in 9m35s
build-and-release / build (companies) (push) Successful in 9m49s
build-and-release / build (gateway) (push) Successful in 3m2s
build-and-release / build (fitness-trainers) (push) Successful in 8m20s
build-and-release / build (jobs) (push) Successful in 4m22s
build-and-release / build (graphic-designers) (push) Successful in 8m41s
build-and-release / build (job-seekers) (push) Successful in 9m14s
build-and-release / build (leads) (push) Successful in 9m10s
build-and-release / build (makeup-artists) (push) Successful in 8m51s
build-and-release / build (payments) (push) Successful in 8m51s
build-and-release / build (photographers) (push) Successful in 8m39s
build-and-release / build (ugc-content-creators) (push) Successful in 6m57s
build-and-release / build (social-media-managers) (push) Successful in 8m25s
build-and-release / build (tutors) (push) Successful in 8m28s
build-and-release / build (video-editors) (push) Successful in 8m43s
build-and-release / build (users) (push) Successful in 11m8s

Employees (internal admin/staff) had no self-service password reset —
only /login, /logout, /session existed. Adds /api/admin/auth/forgot-password
and /api/admin/auth/reset-password, mirroring the existing users-table flow
but against EmployeeRepository and a distinct Redis key namespace
(reset:employee:*) so a code for one identity store can never be consumed
against the other.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Ashwin Kumar Sivakumar 2026-07-14 23:28:39 +05:30
parent 308daf4959
commit 01f3810c8f
2 changed files with 99 additions and 1 deletions

View file

@ -1,5 +1,5 @@
use auth::{
crypto::{verify_password},
crypto::{hash_password, verify_password},
jwt::generate_tokens,
};
use axum::{
@ -19,6 +19,8 @@ pub fn router() -> Router<AppState> {
.route("/login", post(login))
.route("/logout", post(logout))
.route("/session", get(session))
.route("/forgot-password", post(forgot_password))
.route("/reset-password", post(reset_password))
}
#[derive(Deserialize)]
@ -131,3 +133,77 @@ async fn session(
"role_code": auth.claims.active_role,
})))
}
#[derive(Deserialize)]
pub struct ForgotPasswordPayload {
pub email: String,
}
#[derive(Deserialize)]
pub struct ResetPasswordPayload {
pub code: String,
pub new_password: String,
}
/// POST /api/admin/auth/forgot-password
/// Always responds 200 regardless of whether the email exists, to avoid
/// leaking which addresses have employee accounts.
async fn forgot_password(
State(state): State<AppState>,
Json(payload): Json<ForgotPasswordPayload>,
) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {
let silent_ok = (StatusCode::OK, Json(serde_json::json!({ "message": "Reset code sent if email exists" })));
let email = payload.email.to_lowercase();
let mut redis = state.redis.clone();
if !cache::rate_limit::check(&mut redis, "employee_forgot_password", &email, 3, 900).await.unwrap_or(true) {
return Ok(silent_ok);
}
let employee = match EmployeeRepository::get_by_email(&state.pool, &email).await {
Ok(Some(e)) => e,
_ => return Ok(silent_ok),
};
let code = format!("{:06}", rand::random::<u32>() % 1_000_000);
tracing::info!(otp = %code, email = %employee.email, "OTP generated for employee password reset");
cache::token::store_employee_reset(&mut redis, &code, &employee.id.to_string())
.await
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string(), "CACHE_ERROR"))?;
let name = format!("{} {}", employee.first_name, employee.last_name);
let _ = state.mail.send_password_reset_email(&employee.email, &name, &code).await;
Ok(silent_ok)
}
/// POST /api/admin/auth/reset-password
async fn reset_password(
State(state): State<AppState>,
Json(payload): Json<ResetPasswordPayload>,
) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {
let mut redis = state.redis.clone();
let employee_id_str = cache::token::consume_employee_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 employee_id = employee_id_str
.parse::<uuid::Uuid>()
.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"))?;
EmployeeRepository::change_password(&state.pool, employee_id, &password_hash)
.await
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string(), "DB_ERROR"))?;
Ok((StatusCode::OK, Json(serde_json::json!({ "message": "Password reset successfully" }))))
}

View file

@ -65,3 +65,25 @@ pub async fn consume_reset(
let key = format!("reset:{token}");
redis.get_del(key).await
}
// ── Employee (internal admin) password-reset tokens ──────────────────────────
// Distinct key namespace from the user-facing reset tokens above, so a code
// generated for one identity store can never be consumed against the other.
pub async fn store_employee_reset(
redis: &mut RedisPool,
token: &str,
employee_id: &str,
) -> Result<(), redis::RedisError> {
let key = format!("reset:employee:{token}");
redis.set_ex(key, employee_id, RESET_TTL).await
}
/// Atomically fetch and delete the employee reset token (single-use).
pub async fn consume_employee_reset(
redis: &mut RedisPool,
token: &str,
) -> Result<Option<String>, redis::RedisError> {
let key = format!("reset:employee:{token}");
redis.get_del(key).await
}