Private
Public Access
1
0

feat: Phase 4 - password validation, force password reset flow, account lockout, QR code for MFA
Some checks failed
CI Pipeline / Rust Format Check (push) Failing after 6s
CI Pipeline / Clippy Lints (push) Successful in 46s
CI Pipeline / Rust Unit Tests (push) Successful in 1m2s
CI Pipeline / Security Audit (push) Successful in 5s
CI Pipeline / Frontend Lint & Type Check (push) Failing after 10s
CI Pipeline / Build .deb & Release (push) Has been skipped

This commit is contained in:
2026-05-07 17:53:16 +00:00
parent b5b975e7e5
commit cc1214a963
13 changed files with 889 additions and 68 deletions

View File

@ -20,5 +20,6 @@ pub mod session;
// Commonly re-exported types
pub use jwt::{AccessClaims, JwtError};
pub use password::{hash_password, verify_password, PasswordError};
pub use password::validate_password_strength;
pub use rbac::{AuthConfig, AuthUser, UserRole};
pub use session::{LoginRequest, LoginResponse, SessionError, SessionUser};

View File

@ -67,6 +67,34 @@ pub fn verify_password(password: &str, hash: &str) -> Result<bool, PasswordError
}
}
/// Validate password strength against minimum requirements.
///
/// Requirements:
/// - Minimum 8 characters
/// - At least one uppercase letter
/// - At least one lowercase letter
/// - At least one digit
/// - At least one special character (!@#$%^&*()_+-=[]{}|;:,.<>?)
pub fn validate_password_strength(password: &str) -> Result<(), String> {
if password.len() < 8 {
return Err("Password must be at least 8 characters".to_string());
}
if !password.chars().any(|c| c.is_ascii_uppercase()) {
return Err("Password must contain at least one uppercase letter".to_string());
}
if !password.chars().any(|c| c.is_ascii_lowercase()) {
return Err("Password must contain at least one lowercase letter".to_string());
}
if !password.chars().any(|c| c.is_ascii_digit()) {
return Err("Password must contain at least one digit".to_string());
}
let special_chars = "!@#$%^&*()_+-=[]{}|;:,.<>?";
if !password.chars().any(|c| special_chars.contains(c)) {
return Err("Password must contain at least one special character (!@#$%^&*()_+-=[]{}|;:,.<>?)".to_string());
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;

View File

@ -30,6 +30,8 @@ pub enum SessionError {
MfaRequired,
#[error("Invalid MFA code")]
InvalidMfaCode,
#[error("Account locked due to too many failed attempts")]
AccountLocked,
#[error("JWT error: {0}")]
Jwt(#[from] JwtError),
#[error("Refresh token error: {0}")]
@ -78,6 +80,8 @@ struct DbUser {
mfa_enabled: bool,
is_active: bool,
force_password_reset: bool,
failed_login_attempts: i32,
locked_until: Option<chrono::DateTime<Utc>>,
}
/// Login request payload.
@ -110,7 +114,8 @@ pub async fn login(
let user: Option<DbUser> = sqlx::query_as(
r#"
SELECT id, username, display_name, role, auth_provider,
password_hash, totp_secret, mfa_enabled, is_active, force_password_reset
password_hash, totp_secret, mfa_enabled, is_active, force_password_reset,
failed_login_attempts, locked_until
FROM users
WHERE username = $1 AND auth_provider = 'local'
"#,
@ -129,12 +134,43 @@ pub async fn login(
},
};
// 2a. Check if account is locked due to too many failed attempts
if let Some(locked_until) = user.locked_until {
if locked_until > Utc::now() {
tracing::warn!(username = %req.username, "Login blocked: account locked until {}", locked_until);
return Err(SessionError::AccountLocked);
}
// Lockout period has expired — reset counters
sqlx::query("UPDATE users SET failed_login_attempts = 0, locked_until = NULL WHERE id = $1")
.bind(user.id)
.execute(pool)
.await?;
}
// 2. Verify password
let hash = user.password_hash.as_deref().unwrap_or("");
let valid = password::verify_password(&req.password, hash).unwrap_or(false);
if !valid {
tracing::warn!(username = %req.username, "Login failed: invalid password");
// Increment failed login attempts
let new_attempts = user.failed_login_attempts + 1;
if new_attempts >= 5 {
let lock_until = Utc::now() + chrono::Duration::minutes(30);
sqlx::query("UPDATE users SET failed_login_attempts = $1, locked_until = $2 WHERE id = $3")
.bind(new_attempts)
.bind(lock_until)
.bind(user.id)
.execute(pool)
.await?;
tracing::warn!(username = %req.username, "Account locked after {} failed attempts", new_attempts);
} else {
sqlx::query("UPDATE users SET failed_login_attempts = $1 WHERE id = $2")
.bind(new_attempts)
.bind(user.id)
.execute(pool)
.await?;
}
tracing::warn!(username = %req.username, "Login failed: invalid password (attempt {})", new_attempts);
return Err(SessionError::InvalidCredentials);
}
@ -175,7 +211,7 @@ pub async fn login(
let raw_refresh = refresh::issue(pool, user.id, user_agent, ip_address).await?;
// 6. Update last_login_at
sqlx::query("UPDATE users SET last_login_at = $1 WHERE id = $2")
sqlx::query("UPDATE users SET last_login_at = $1, failed_login_attempts = 0, locked_until = NULL WHERE id = $2")
.bind(Utc::now())
.bind(user.id)
.execute(pool)
@ -216,7 +252,8 @@ pub async fn refresh_session(
let user: DbUser = sqlx::query_as(
r#"
SELECT id, username, display_name, role, auth_provider,
password_hash, totp_secret, mfa_enabled, is_active, force_password_reset
password_hash, totp_secret, mfa_enabled, is_active, force_password_reset,
failed_login_attempts, locked_until
FROM users WHERE id = $1
"#,
)

View File

@ -21,11 +21,13 @@ use pm_auth::{
mfa_totp,
rbac::AuthUser,
session::{self, LoginRequest, LoginResponse},
verify_password,
verify_password, hash_password, validate_password_strength,
};
use serde::Deserialize;
use serde_json::{json, Value};
use uuid::Uuid;
use crate::AppState;
// ============================================================
@ -37,6 +39,7 @@ pub fn public_router() -> Router<AppState> {
.route("/login", post(login_handler))
.route("/refresh", post(refresh_handler))
.route("/logout", post(logout_handler))
.route("/force-change-password", post(force_change_password_handler))
}
// ============================================================
@ -113,6 +116,11 @@ async fn login_handler(
"password_reset_required",
"Password reset is required before login",
),
SessionError::AccountLocked => (
StatusCode::LOCKED,
"account_locked",
"Account is locked due to too many failed login attempts",
),
_ => {
tracing::error!(error = %e, "Login error");
(
@ -214,6 +222,93 @@ async fn logout_handler(
// GET /api/v1/auth/mfa/setup (JWT required — via middleware)
// ============================================================
// ============================================================
// POST /api/v1/auth/force-change-password (PUBLIC — no JWT)
// ============================================================
#[derive(Debug, Deserialize)]
struct ForceChangePasswordRequest {
username: String,
current_password: String,
new_password: String,
}
async fn force_change_password_handler(
State(state): State<AppState>,
Json(req): Json<ForceChangePasswordRequest>,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
// Validate new password strength
if let Err(msg) = validate_password_strength(&req.new_password) {
return Err((
StatusCode::BAD_REQUEST,
Json(json!({ "error": { "code": "weak_password", "message": msg } })),
));
}
// Look up user by username
let row: Option<(Uuid, Option<String>, bool)> = sqlx::query_as(
"SELECT id, password_hash, force_password_reset FROM users WHERE username = $1 AND auth_provider = 'local'",
)
.bind(&req.username)
.fetch_optional(&state.db)
.await
.map_err(|e| {
tracing::error!(error = %e, "Failed to fetch user");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": { "code": "internal_error", "message": "Database error" } })),
)
})?;
let (user_id, hash_opt, _force_reset) = match row {
Some(r) => r,
None => {
return Err((
StatusCode::UNAUTHORIZED,
Json(json!({ "error": { "code": "invalid_credentials", "message": "Invalid username or password" } })),
));
}
};
// Verify current password
let hash_str = hash_opt.as_deref().unwrap_or("");
let valid = verify_password(&req.current_password, hash_str).unwrap_or(false);
if !valid {
return Err((
StatusCode::UNAUTHORIZED,
Json(json!({ "error": { "code": "invalid_credentials", "message": "Invalid username or password" } })),
));
}
// Hash and update password, clear force_password_reset
let new_hash = hash_password(&req.new_password).map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": { "code": "internal_error", "message": e.to_string() } })),
)
})?;
sqlx::query(
"UPDATE users SET password_hash = $1, force_password_reset = FALSE, failed_login_attempts = 0, locked_until = NULL, updated_at = NOW() WHERE id = $2",
)
.bind(&new_hash)
.bind(user_id)
.execute(&state.db)
.await
.map_err(|e| {
tracing::error!(error = %e, "Failed to update password");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": { "code": "internal_error", "message": "Failed to update password" } })),
)
})?;
tracing::info!(user_id = %user_id, username = %req.username, "Password changed via force-change-password");
Ok(Json(json!({ "message": "Password changed successfully" })))
}
async fn mfa_setup_handler(
auth_user: AuthUser,
) -> Result<Json<mfa_totp::TotpSetup>, (StatusCode, Json<Value>)> {

View File

@ -16,6 +16,7 @@ use axum::{
Router,
};
use pm_auth::{hash_password, rbac::AuthUser, session::force_logout, verify_password};
use pm_auth::validate_password_strength;
use pm_core::{
audit::{log_event, AuditAction},
models::{AdminResetPasswordRequest, ChangePasswordRequest, CreateUserRequest, UpdateUserRequest, User},
@ -77,6 +78,14 @@ async fn create_user(
));
}
// Validate password strength
if let Err(msg) = validate_password_strength(&req.password) {
return Err((
StatusCode::BAD_REQUEST,
Json(json!({ "error": { "code": "weak_password", "message": msg } })),
));
}
let hash = hash_password(&req.password).map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
@ -371,6 +380,14 @@ async fn change_own_password(
));
}
// Validate new password strength
if let Err(msg) = validate_password_strength(&req.new_password) {
return Err((
StatusCode::BAD_REQUEST,
Json(json!({ "error": { "code": "weak_password", "message": msg } })),
));
}
let new_hash = hash_password(&req.new_password).map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
@ -445,6 +462,14 @@ async fn admin_reset_password(
));
}
// Validate new password strength
if let Err(msg) = validate_password_strength(&req.new_password) {
return Err((
StatusCode::BAD_REQUEST,
Json(json!({ "error": { "code": "weak_password", "message": msg } })),
));
}
let new_hash = hash_password(&req.new_password).map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,

View File

@ -12,8 +12,10 @@
"@emotion/styled": "^11.14.0",
"@mui/icons-material": "^7.0.0",
"@mui/material": "^7.0.0",
"@types/qrcode": "^1.5.6",
"axios": "^1.9.0",
"jszip": "^3.10.1",
"qrcode": "^1.5.4",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.5.3",
@ -1772,6 +1774,14 @@
"integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
"dev": true
},
"node_modules/@types/node": {
"version": "25.6.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.1.tgz",
"integrity": "sha512-coJCN8O1q4AGyyqCAUSP06P+SrMTu18BkEj3NVAK07q6QUneD2wzj3CLv9+yP+BMeZQlMvneXqqvDe3w+xcq7g==",
"dependencies": {
"undici-types": "~7.19.0"
}
},
"node_modules/@types/parse-json": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz",
@ -1782,6 +1792,14 @@
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw=="
},
"node_modules/@types/qrcode": {
"version": "1.5.6",
"resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz",
"integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/react": {
"version": "19.2.14",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
@ -2086,11 +2104,18 @@
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"engines": {
"node": ">=8"
}
},
"node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dev": true,
"dependencies": {
"color-convert": "^2.0.1"
},
@ -2222,6 +2247,14 @@
"node": ">=6"
}
},
"node_modules/camelcase": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
"engines": {
"node": ">=6"
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001790",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001790.tgz",
@ -2258,6 +2291,16 @@
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/cliui": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.0",
"wrap-ansi": "^6.2.0"
}
},
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
@ -2270,7 +2313,6 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dev": true,
"dependencies": {
"color-name": "~1.1.4"
},
@ -2281,8 +2323,7 @@
"node_modules/color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="
},
"node_modules/combined-stream": {
"version": "1.0.8",
@ -2381,6 +2422,14 @@
}
}
},
"node_modules/decamelize": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/deep-is": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
@ -2395,6 +2444,11 @@
"node": ">=0.4.0"
}
},
"node_modules/dijkstrajs": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA=="
},
"node_modules/dom-helpers": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
@ -2423,6 +2477,11 @@
"integrity": "sha512-4MxfbmNDm+KPh066EZy+eUnkcDPcZ35wNmOWzFuh/ijvHsve6kbLTLURy88uCNK5FbpN+yk2nQY6BYh1GEt+wg==",
"dev": true
},
"node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="
},
"node_modules/error-ex": {
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz",
@ -2904,6 +2963,14 @@
"node": ">=6.9.0"
}
},
"node_modules/get-caller-file": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"engines": {
"node": "6.* || 8.* || >= 10.*"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
@ -3103,6 +3170,14 @@
"node": ">=0.10.0"
}
},
"node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"engines": {
"node": ">=8"
}
},
"node_modules/is-glob": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
@ -3408,6 +3483,14 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-try": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
"engines": {
"node": ">=6"
}
},
"node_modules/pako": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
@ -3445,7 +3528,6 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
"dev": true,
"engines": {
"node": ">=8"
}
@ -3489,6 +3571,14 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/pngjs": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/postcss": {
"version": "8.5.10",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz",
@ -3563,6 +3653,22 @@
"node": ">=6"
}
},
"node_modules/qrcode": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
"dependencies": {
"dijkstrajs": "^1.0.1",
"pngjs": "^5.0.0",
"yargs": "^15.3.1"
},
"bin": {
"qrcode": "bin/qrcode"
},
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/react": {
"version": "19.2.5",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz",
@ -3661,6 +3767,19 @@
"util-deprecate": "~1.0.1"
}
},
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/require-main-filename": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg=="
},
"node_modules/resolve": {
"version": "1.22.12",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
@ -3755,6 +3874,11 @@
"node": ">=10"
}
},
"node_modules/set-blocking": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw=="
},
"node_modules/set-cookie-parser": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
@ -3811,6 +3935,30 @@
"safe-buffer": "~5.1.0"
}
},
"node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/strip-json-comments": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
@ -3904,6 +4052,11 @@
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "7.19.2",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz",
"integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="
},
"node_modules/update-browserslist-db": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
@ -4037,6 +4190,11 @@
"node": ">= 8"
}
},
"node_modules/which-module": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ=="
},
"node_modules/word-wrap": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
@ -4046,6 +4204,24 @@
"node": ">=0.10.0"
}
},
"node_modules/wrap-ansi": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/y18n": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ=="
},
"node_modules/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
@ -4069,6 +4245,87 @@
"url": "https://github.com/sponsors/eemeli"
}
},
"node_modules/yargs": {
"version": "15.4.1",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
"dependencies": {
"cliui": "^6.0.0",
"decamelize": "^1.2.0",
"find-up": "^4.1.0",
"get-caller-file": "^2.0.1",
"require-directory": "^2.1.1",
"require-main-filename": "^2.0.0",
"set-blocking": "^2.0.0",
"string-width": "^4.2.0",
"which-module": "^2.0.0",
"y18n": "^4.0.0",
"yargs-parser": "^18.1.2"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yargs-parser": {
"version": "18.1.3",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
"dependencies": {
"camelcase": "^5.0.0",
"decamelize": "^1.2.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/yargs/node_modules/find-up": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
"dependencies": {
"locate-path": "^5.0.0",
"path-exists": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yargs/node_modules/locate-path": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
"dependencies": {
"p-locate": "^4.1.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yargs/node_modules/p-limit": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
"dependencies": {
"p-try": "^2.0.0"
},
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/yargs/node_modules/p-locate": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
"dependencies": {
"p-limit": "^2.2.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yocto-queue": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",

View File

@ -15,8 +15,10 @@
"@emotion/styled": "^11.14.0",
"@mui/icons-material": "^7.0.0",
"@mui/material": "^7.0.0",
"@types/qrcode": "^1.5.6",
"axios": "^1.9.0",
"jszip": "^3.10.1",
"qrcode": "^1.5.4",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.5.3",

View File

@ -101,6 +101,9 @@ export const authApi = {
logout: (refreshToken: string) =>
apiClient.post('/auth/logout', { refresh_token: refreshToken }),
forceChangePassword: (username: string, currentPassword: string, newPassword: string) =>
apiClient.post('/auth/force-change-password', { username, current_password: currentPassword, new_password: newPassword }),
getMfaSetup: () =>
apiClient.get('/auth/mfa/setup'),

View File

@ -3,8 +3,12 @@ import { useNavigate } from 'react-router-dom'
import {
Box, Button, Container, TextField, Typography,
Alert, CircularProgress, Paper, InputAdornment, IconButton,
List, ListItem, ListItemIcon, ListItemText,
} from '@mui/material'
import { Visibility, VisibilityOff } from '@mui/icons-material'
import {
Visibility, VisibilityOff,
Check as CheckIcon, Close as CloseIcon,
} from '@mui/icons-material'
import { authApi } from '../api/client'
import { useAuthStore } from '../store/authStore'
import type { User } from '../types'
@ -32,6 +36,16 @@ function getErrorMessage(err: unknown): string {
return 'MFA_REQUIRED' // sentinel — caller checks this
}
// Password reset required
if (code === 'password_reset_required') {
return 'PASSWORD_RESET_REQUIRED'
}
// Account locked
if (code === 'account_locked') {
return 'ACCOUNT_LOCKED'
}
// Account disabled
if (code === 'account_disabled') {
return 'This account has been disabled. Contact your administrator.'
@ -56,6 +70,21 @@ function getErrorMessage(err: unknown): string {
return 'Login failed. Please try again.'
}
/** Password strength checker */
function checkPasswordStrength(password: string) {
return {
length: password.length >= 8,
uppercase: /[A-Z]/.test(password),
lowercase: /[a-z]/.test(password),
digit: /[0-9]/.test(password),
special: /[!@#$%^&*()_+\-=\[\]{}|;:,.<>?]/.test(password),
}
}
function isPasswordValid(checks: ReturnType<typeof checkPasswordStrength>) {
return checks.length && checks.uppercase && checks.lowercase && checks.digit && checks.special
}
export default function LoginPage() {
const navigate = useNavigate()
const { setTokens, setUser } = useAuthStore()
@ -65,9 +94,20 @@ export default function LoginPage() {
const [totpCode, setTotpCode] = useState('')
const [showPassword, setShowPassword] = useState(false)
const [needsMfa, setNeedsMfa] = useState(false)
const [forcePasswordReset, setForcePasswordReset] = useState(false)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
// Force change password state
const [newPassword, setNewPassword] = useState('')
const [confirmNewPassword, setConfirmNewPassword] = useState('')
const [showNewPassword, setShowNewPassword] = useState(false)
const [passwordChanged, setPasswordChanged] = useState(false)
const pwChecks = checkPasswordStrength(newPassword)
const pwValid = isPasswordValid(pwChecks)
const pwMismatch = !!(confirmNewPassword && newPassword !== confirmNewPassword)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setLoading(true)
@ -84,6 +124,11 @@ export default function LoginPage() {
if (message === 'MFA_REQUIRED') {
setNeedsMfa(true)
setError('Please enter your MFA code.')
} else if (message === 'PASSWORD_RESET_REQUIRED') {
setForcePasswordReset(true)
setError('You must change your password before logging in.')
} else if (message === 'ACCOUNT_LOCKED') {
setError('Account locked due to too many failed login attempts. Please try again in 30 minutes.')
} else {
setError(message)
}
@ -92,6 +137,44 @@ export default function LoginPage() {
}
}
const handleForceChangePassword = async (e: React.FormEvent) => {
e.preventDefault()
if (!pwValid || pwMismatch) return
setLoading(true)
setError(null)
try {
await authApi.forceChangePassword(username, password, newPassword)
setPasswordChanged(true)
setForcePasswordReset(false)
setNewPassword('')
setConfirmNewPassword('')
setPassword('')
} catch (err: unknown) {
const axiosErr = err as { response?: { data?: { error?: { code?: string; message?: string } } } }
const code = axiosErr.response?.data?.error?.code
const msg = axiosErr.response?.data?.error?.message
if (code === 'weak_password') {
setError(msg || 'Password does not meet strength requirements.')
} else if (code === 'invalid_credentials') {
setError('Invalid username or password.')
} else {
setError(msg || 'Failed to change password. Please try again.')
}
} finally {
setLoading(false)
}
}
const handleBackToLogin = () => {
setForcePasswordReset(false)
setPasswordChanged(false)
setError(null)
setPassword('')
setNewPassword('')
setConfirmNewPassword('')
}
return (
<Container maxWidth="xs" sx={{ mt: 12 }}>
<Paper elevation={4} sx={{ p: 4 }}>
@ -101,7 +184,7 @@ export default function LoginPage() {
{error && (
<Alert
severity={needsMfa ? 'info' : 'error'}
severity={forcePasswordReset ? 'warning' : 'error'}
sx={{ mb: 2 }}
onClose={() => setError(null)}
>
@ -109,6 +192,102 @@ export default function LoginPage() {
</Alert>
)}
{passwordChanged ? (
<Box>
<Alert severity="success" sx={{ mb: 2 }}>
Password changed successfully! Please log in with your new password.
</Alert>
<Button
fullWidth variant="contained" size="large"
onClick={handleBackToLogin}
>
Back to Login
</Button>
</Box>
) : forcePasswordReset ? (
<Box component="form" onSubmit={handleForceChangePassword} noValidate>
<Typography variant="h6" fontWeight={600} mb={2}>
Change Your Password
</Typography>
<Typography variant="body2" color="text.secondary" mb={2}>
Your password has expired and must be changed before you can log in.
</Typography>
<TextField
fullWidth margin="normal" label="Username"
value={username} InputProps={{ readOnly: true }}
/>
<TextField
fullWidth margin="normal" label="Current Password" type="password"
value={password} InputProps={{ readOnly: true }}
/>
<TextField
fullWidth margin="normal" label="New Password"
type={showNewPassword ? 'text' : 'password'}
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
disabled={loading} required
InputProps={{
endAdornment: (
<InputAdornment position="end">
<IconButton onClick={() => setShowNewPassword(!showNewPassword)} edge="end">
{showNewPassword ? <VisibilityOff /> : <Visibility />}
</IconButton>
</InputAdornment>
),
}}
/>
{newPassword && (
<Box sx={{ mt: 1, mb: 1 }}>
<List dense disablePadding>
<ListItem disableGutters sx={{ py: 0 }}>
<ListItemIcon sx={{ minWidth: 28 }}>
{pwChecks.length ? <CheckIcon color="success" fontSize="small" /> : <CloseIcon color="error" fontSize="small" />}
</ListItemIcon>
<ListItemText primary="At least 8 characters" primaryTypographyProps={{ variant: 'caption' }} />
</ListItem>
<ListItem disableGutters sx={{ py: 0 }}>
<ListItemIcon sx={{ minWidth: 28 }}>
{pwChecks.uppercase ? <CheckIcon color="success" fontSize="small" /> : <CloseIcon color="error" fontSize="small" />}
</ListItemIcon>
<ListItemText primary="At least one uppercase letter" primaryTypographyProps={{ variant: 'caption' }} />
</ListItem>
<ListItem disableGutters sx={{ py: 0 }}>
<ListItemIcon sx={{ minWidth: 28 }}>
{pwChecks.lowercase ? <CheckIcon color="success" fontSize="small" /> : <CloseIcon color="error" fontSize="small" />}
</ListItemIcon>
<ListItemText primary="At least one lowercase letter" primaryTypographyProps={{ variant: 'caption' }} />
</ListItem>
<ListItem disableGutters sx={{ py: 0 }}>
<ListItemIcon sx={{ minWidth: 28 }}>
{pwChecks.digit ? <CheckIcon color="success" fontSize="small" /> : <CloseIcon color="error" fontSize="small" />}
</ListItemIcon>
<ListItemText primary="At least one digit" primaryTypographyProps={{ variant: 'caption' }} />
</ListItem>
<ListItem disableGutters sx={{ py: 0 }}>
<ListItemIcon sx={{ minWidth: 28 }}>
{pwChecks.special ? <CheckIcon color="success" fontSize="small" /> : <CloseIcon color="error" fontSize="small" />}
</ListItemIcon>
<ListItemText primary="At least one special character" primaryTypographyProps={{ variant: 'caption' }} />
</ListItem>
</List>
</Box>
)}
<TextField
fullWidth margin="normal" label="Confirm New Password" type="password"
value={confirmNewPassword}
onChange={(e) => setConfirmNewPassword(e.target.value)}
disabled={loading} required
error={pwMismatch}
helperText={pwMismatch ? 'Passwords do not match' : ''}
/>
<Button
type="submit" fullWidth variant="contained" size="large"
sx={{ mt: 3 }} disabled={loading || !pwValid || pwMismatch}
>
{loading ? <CircularProgress size={24} /> : 'Change Password'}
</Button>
</Box>
) : (
<Box component="form" onSubmit={handleSubmit} noValidate>
<TextField
fullWidth margin="normal" label="Username" autoComplete="username"
@ -145,6 +324,7 @@ export default function LoginPage() {
{loading ? <CircularProgress size={24} /> : 'Sign In'}
</Button>
</Box>
)}
</Paper>
</Container>
)

View File

@ -2,7 +2,10 @@ import React, { useEffect, useState } from 'react'
import {
Box, Button, Container, TextField, Typography,
Alert, CircularProgress, Paper, Stepper, Step, StepLabel,
IconButton, Tooltip, Snackbar,
} from '@mui/material'
import { ContentCopy as CopyIcon } from '@mui/icons-material'
import QRCode from 'qrcode'
import { authApi } from '../api/client'
const STEPS = ['Get your QR code', 'Verify code', 'Done']
@ -13,13 +16,35 @@ export default function MfaSetupPage() {
const [code, setCode] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [qrDataUrl, setQrDataUrl] = useState<string | null>(null)
const [copied, setCopied] = useState(false)
useEffect(() => {
authApi.getMfaSetup()
.then((res) => setSetup(res.data))
.then((res) => {
setSetup(res.data)
// Generate QR code from otpauth URI
if (res.data.otp_uri) {
QRCode.toDataURL(res.data.otp_uri, {
width: 256,
margin: 2,
color: { dark: '#000000', light: '#ffffff' },
})
.then((url) => setQrDataUrl(url))
.catch(() => setError('Failed to generate QR code.'))
}
})
.catch(() => setError('Failed to load MFA setup.'))
}, [])
const handleCopySecret = () => {
if (setup?.secret_base32) {
navigator.clipboard.writeText(setup.secret_base32)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
}
const handleVerify = async (e: React.FormEvent) => {
e.preventDefault()
if (!setup) return
@ -48,14 +73,46 @@ export default function MfaSetupPage() {
{step === 0 && setup && (
<Box>
<Typography mb={2}>
Scan this URI in your authenticator app or enter the secret manually:
Scan this QR code in your authenticator app:
</Typography>
<Typography variant="body2" sx={{ fontFamily: 'monospace', wordBreak: 'break-all', mb: 2, p: 1, bgcolor: 'grey.100', borderRadius: 1 }}>
{setup.otp_uri}
{qrDataUrl ? (
<Box sx={{ display: 'flex', justifyContent: 'center', mb: 2 }}>
<img
src={qrDataUrl}
alt="MFA QR Code"
width={256}
height={256}
style={{ imageRendering: 'pixelated' }}
/>
</Box>
) : (
<Box sx={{ display: 'flex', justifyContent: 'center', mb: 2 }}>
<CircularProgress />
</Box>
)}
<Typography variant="caption" color="text.secondary" display="block" mb={1}>
If you can't scan the QR code, enter the secret manually:
</Typography>
<Typography variant="caption" color="text.secondary" display="block" mb={3}>
Manual entry secret: <strong>{setup.secret_base32}</strong>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
<Typography
variant="body2"
sx={{
fontFamily: 'monospace',
wordBreak: 'break-all',
p: 1,
bgcolor: 'grey.100',
borderRadius: 1,
flexGrow: 1,
}}
>
{setup.secret_base32}
</Typography>
<Tooltip title={copied ? 'Copied!' : 'Copy Secret'}>
<IconButton onClick={handleCopySecret} color={copied ? 'success' : 'default'}>
<CopyIcon />
</IconButton>
</Tooltip>
</Box>
<Button variant="contained" onClick={() => setStep(1)}>Continue</Button>
</Box>
)}
@ -81,6 +138,15 @@ export default function MfaSetupPage() {
</Alert>
)}
</Paper>
<Snackbar
open={copied}
autoHideDuration={2000}
onClose={() => setCopied(false)}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
>
<Alert severity="success" variant="filled">Secret copied to clipboard</Alert>
</Snackbar>
</Container>
)
}

View File

@ -4,6 +4,7 @@ import {
Box, Button, Card, CardContent, Chip, Container, Dialog,
DialogActions, DialogContent, DialogTitle, Snackbar,
Alert, TextField, Typography, InputAdornment, IconButton,
List, ListItem, ListItemIcon, ListItemText,
} from '@mui/material'
import {
Person as PersonIcon,
@ -11,11 +12,27 @@ import {
Visibility, VisibilityOff,
VpnKey as MfaIcon,
Save as SaveIcon,
Check as CheckIcon, Close as CloseIcon,
} from '@mui/icons-material'
import { useAuthStore } from '../store/authStore'
import { usersApi } from '../api/client'
import type { User } from '../types'
/** Password strength checker */
function checkPasswordStrength(password: string) {
return {
length: password.length >= 8,
uppercase: /[A-Z]/.test(password),
lowercase: /[a-z]/.test(password),
digit: /[0-9]/.test(password),
special: /[!@#$%^&*()_+\-=\[\]{}|;:,.<>?]/.test(password),
}
}
function isPasswordValid(checks: ReturnType<typeof checkPasswordStrength>) {
return checks.length && checks.uppercase && checks.lowercase && checks.digit && checks.special
}
export default function ProfilePage() {
const navigate = useNavigate()
const { user, setUser } = useAuthStore()
@ -49,6 +66,10 @@ export default function ProfilePage() {
const showSnack = (severity: 'success' | 'error', message: string) =>
setSnack({ open: true, severity, message })
const pwChecks = checkPasswordStrength(newPw)
const pwValid = isPasswordValid(pwChecks)
const pwMismatch = !!(newPw && confirmPw && newPw !== confirmPw)
// ── Load current user on mount ──────────────────────────────────────────
useEffect(() => {
;(async () => {
@ -87,6 +108,10 @@ export default function ProfilePage() {
showSnack('error', 'New passwords do not match')
return
}
if (!pwValid) {
showSnack('error', 'Password does not meet strength requirements')
return
}
setChangingPw(true)
try {
await usersApi.changePassword({ current_password: currentPw, new_password: newPw })
@ -127,8 +152,6 @@ export default function ProfilePage() {
)
}
const pwMismatch = !!(newPw && confirmPw && newPw !== confirmPw)
return (
<Container maxWidth="md" sx={{ mt: 3 }}>
<Typography variant="h5" fontWeight={700} sx={{ mb: 3 }}>My Profile</Typography>
@ -217,6 +240,42 @@ export default function ProfilePage() {
),
}}
/>
{newPw && (
<Box sx={{ mt: -1, mb: 0 }}>
<List dense disablePadding>
<ListItem disableGutters sx={{ py: 0 }}>
<ListItemIcon sx={{ minWidth: 28 }}>
{pwChecks.length ? <CheckIcon color="success" fontSize="small" /> : <CloseIcon color="error" fontSize="small" />}
</ListItemIcon>
<ListItemText primary="At least 8 characters" primaryTypographyProps={{ variant: 'caption' }} />
</ListItem>
<ListItem disableGutters sx={{ py: 0 }}>
<ListItemIcon sx={{ minWidth: 28 }}>
{pwChecks.uppercase ? <CheckIcon color="success" fontSize="small" /> : <CloseIcon color="error" fontSize="small" />}
</ListItemIcon>
<ListItemText primary="At least one uppercase letter" primaryTypographyProps={{ variant: 'caption' }} />
</ListItem>
<ListItem disableGutters sx={{ py: 0 }}>
<ListItemIcon sx={{ minWidth: 28 }}>
{pwChecks.lowercase ? <CheckIcon color="success" fontSize="small" /> : <CloseIcon color="error" fontSize="small" />}
</ListItemIcon>
<ListItemText primary="At least one lowercase letter" primaryTypographyProps={{ variant: 'caption' }} />
</ListItem>
<ListItem disableGutters sx={{ py: 0 }}>
<ListItemIcon sx={{ minWidth: 28 }}>
{pwChecks.digit ? <CheckIcon color="success" fontSize="small" /> : <CloseIcon color="error" fontSize="small" />}
</ListItemIcon>
<ListItemText primary="At least one digit" primaryTypographyProps={{ variant: 'caption' }} />
</ListItem>
<ListItem disableGutters sx={{ py: 0 }}>
<ListItemIcon sx={{ minWidth: 28 }}>
{pwChecks.special ? <CheckIcon color="success" fontSize="small" /> : <CloseIcon color="error" fontSize="small" />}
</ListItemIcon>
<ListItemText primary="At least one special character" primaryTypographyProps={{ variant: 'caption' }} />
</ListItem>
</List>
</Box>
)}
<TextField
label="Confirm New Password"
type={showConfirmPw ? 'text' : 'password'}
@ -239,7 +298,7 @@ export default function ProfilePage() {
<Button
variant="contained"
onClick={handleChangePassword}
disabled={changingPw || !currentPw || !newPw || !confirmPw || !!pwMismatch}
disabled={changingPw || !currentPw || !newPw || !confirmPw || !!pwMismatch || !pwValid}
>
{changingPw ? 'Changing…' : 'Change Password'}
</Button>

View File

@ -5,15 +5,74 @@ import {
MenuItem, Paper, Select, Switch, Table, TableBody, TableCell,
TableContainer, TableHead, TableRow, TextField, Toolbar, Tooltip, Typography,
Snackbar, Alert, InputAdornment, FormControl, InputLabel,
List, ListItem, ListItemIcon, ListItemText,
} from '@mui/material'
import {
Add as AddIcon, Lock as LockIcon, Edit as EditIcon,
VpnKey as VpnKeyIcon, Delete as DeleteIcon, Search as SearchIcon,
Check as CheckIcon, Close as CloseIcon,
} from '@mui/icons-material'
import { usersApi } from '../api/client'
import { useAuthStore } from '../store/authStore'
import type { User, UpdateUserRequest, AdminResetPasswordRequest } from '../types'
/** Password strength checker */
function checkPasswordStrength(password: string) {
return {
length: password.length >= 8,
uppercase: /[A-Z]/.test(password),
lowercase: /[a-z]/.test(password),
digit: /[0-9]/.test(password),
special: /[!@#$%^&*()_+\-=\[\]{}|;:,.<>?]/.test(password),
}
}
function isPasswordValid(checks: ReturnType<typeof checkPasswordStrength>) {
return checks.length && checks.uppercase && checks.lowercase && checks.digit && checks.special
}
/** Reusable password strength checklist component */
function PasswordStrengthIndicator({ password }: { password: string }) {
if (!password) return null
const checks = checkPasswordStrength(password)
return (
<Box sx={{ mt: 0.5, mb: 1 }}>
<List dense disablePadding>
<ListItem disableGutters sx={{ py: 0 }}>
<ListItemIcon sx={{ minWidth: 28 }}>
{checks.length ? <CheckIcon color="success" fontSize="small" /> : <CloseIcon color="error" fontSize="small" />}
</ListItemIcon>
<ListItemText primary="At least 8 characters" primaryTypographyProps={{ variant: 'caption' }} />
</ListItem>
<ListItem disableGutters sx={{ py: 0 }}>
<ListItemIcon sx={{ minWidth: 28 }}>
{checks.uppercase ? <CheckIcon color="success" fontSize="small" /> : <CloseIcon color="error" fontSize="small" />}
</ListItemIcon>
<ListItemText primary="At least one uppercase letter" primaryTypographyProps={{ variant: 'caption' }} />
</ListItem>
<ListItem disableGutters sx={{ py: 0 }}>
<ListItemIcon sx={{ minWidth: 28 }}>
{checks.lowercase ? <CheckIcon color="success" fontSize="small" /> : <CloseIcon color="error" fontSize="small" />}
</ListItemIcon>
<ListItemText primary="At least one lowercase letter" primaryTypographyProps={{ variant: 'caption' }} />
</ListItem>
<ListItem disableGutters sx={{ py: 0 }}>
<ListItemIcon sx={{ minWidth: 28 }}>
{checks.digit ? <CheckIcon color="success" fontSize="small" /> : <CloseIcon color="error" fontSize="small" />}
</ListItemIcon>
<ListItemText primary="At least one digit" primaryTypographyProps={{ variant: 'caption' }} />
</ListItem>
<ListItem disableGutters sx={{ py: 0 }}>
<ListItemIcon sx={{ minWidth: 28 }}>
{checks.special ? <CheckIcon color="success" fontSize="small" /> : <CloseIcon color="error" fontSize="small" />}
</ListItemIcon>
<ListItemText primary="At least one special character" primaryTypographyProps={{ variant: 'caption' }} />
</ListItem>
</List>
</Box>
)
}
export default function UsersPage() {
const currentUser = useAuthStore(s => s.user)
const isAdmin = currentUser?.role === 'admin'
@ -56,6 +115,10 @@ export default function UsersPage() {
const [deleteOpen, setDeleteOpen] = useState(false)
const [deleteUser, setDeleteUser] = useState<User | null>(null)
const addPwValid = isPasswordValid(checkPasswordStrength(addForm.password))
const resetPwValid = isPasswordValid(checkPasswordStrength(resetForm.new_password))
const resetPwMismatch = !!(resetForm.confirm_password && resetForm.new_password !== resetForm.confirm_password)
const load = async () => {
setLoading(true)
try {
@ -90,6 +153,10 @@ export default function UsersPage() {
// ── Handlers ────────────────────────────────────────────────────────────────
const handleCreate = async () => {
if (!addPwValid) {
showSnack('error', 'Password does not meet strength requirements')
return
}
try {
await usersApi.create(addForm)
setAddOpen(false)
@ -142,12 +209,12 @@ export default function UsersPage() {
const handleResetSave = async () => {
if (!resetUser) return
if (resetForm.new_password !== resetForm.confirm_password) {
if (resetPwMismatch) {
showSnack('error', 'Passwords do not match')
return
}
if (resetForm.new_password.length < 8) {
showSnack('error', 'Password must be at least 8 characters')
if (!resetPwValid) {
showSnack('error', 'Password does not meet strength requirements')
return
}
try {
@ -326,6 +393,7 @@ export default function UsersPage() {
value={addForm.password}
onChange={e => setAddForm({ ...addForm, password: e.target.value })}
margin="normal" required />
<PasswordStrengthIndicator password={addForm.password} />
<FormControl fullWidth sx={{ mt: 2 }}>
<InputLabel>Role</InputLabel>
<Select value={addForm.role} label="Role"
@ -338,7 +406,7 @@ export default function UsersPage() {
<DialogActions>
<Button onClick={() => setAddOpen(false)}>Cancel</Button>
<Button variant="contained" onClick={handleCreate}
disabled={!addForm.username || !addForm.email || !addForm.password}>
disabled={!addForm.username || !addForm.email || !addForm.password || !addPwValid}>
Create
</Button>
</DialogActions>
@ -427,17 +495,14 @@ export default function UsersPage() {
value={resetForm.new_password}
onChange={e => setResetForm({ ...resetForm, new_password: e.target.value })}
margin="normal" required
error={resetForm.new_password.length > 0 && resetForm.new_password.length < 8}
helperText={resetForm.new_password.length > 0 && resetForm.new_password.length < 8
? 'Minimum 8 characters' : ''}
/>
<PasswordStrengthIndicator password={resetForm.new_password} />
<TextField fullWidth label="Confirm Password" type="password"
value={resetForm.confirm_password}
onChange={e => setResetForm({ ...resetForm, confirm_password: e.target.value })}
margin="normal" required
error={resetForm.confirm_password.length > 0 && resetForm.new_password !== resetForm.confirm_password}
helperText={resetForm.confirm_password.length > 0 && resetForm.new_password !== resetForm.confirm_password
? 'Passwords do not match' : ''}
error={resetPwMismatch}
helperText={resetPwMismatch ? 'Passwords do not match' : ''}
/>
<FormControlLabel
control={
@ -453,8 +518,8 @@ export default function UsersPage() {
<Button variant="contained" color="warning" onClick={handleResetSave}
disabled={
!resetForm.new_password ||
resetForm.new_password.length < 8 ||
resetForm.new_password !== resetForm.confirm_password
!resetPwValid ||
resetPwMismatch
}>
Reset Password
</Button>

View File

@ -0,0 +1,3 @@
-- Account lockout: track failed login attempts and lockout timestamps
ALTER TABLE users ADD COLUMN failed_login_attempts INTEGER NOT NULL DEFAULT 0;
ALTER TABLE users ADD COLUMN locked_until TIMESTAMPTZ;