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 // Commonly re-exported types
pub use jwt::{AccessClaims, JwtError}; pub use jwt::{AccessClaims, JwtError};
pub use password::{hash_password, verify_password, PasswordError}; pub use password::{hash_password, verify_password, PasswordError};
pub use password::validate_password_strength;
pub use rbac::{AuthConfig, AuthUser, UserRole}; pub use rbac::{AuthConfig, AuthUser, UserRole};
pub use session::{LoginRequest, LoginResponse, SessionError, SessionUser}; 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;

View File

@ -30,6 +30,8 @@ pub enum SessionError {
MfaRequired, MfaRequired,
#[error("Invalid MFA code")] #[error("Invalid MFA code")]
InvalidMfaCode, InvalidMfaCode,
#[error("Account locked due to too many failed attempts")]
AccountLocked,
#[error("JWT error: {0}")] #[error("JWT error: {0}")]
Jwt(#[from] JwtError), Jwt(#[from] JwtError),
#[error("Refresh token error: {0}")] #[error("Refresh token error: {0}")]
@ -78,6 +80,8 @@ struct DbUser {
mfa_enabled: bool, mfa_enabled: bool,
is_active: bool, is_active: bool,
force_password_reset: bool, force_password_reset: bool,
failed_login_attempts: i32,
locked_until: Option<chrono::DateTime<Utc>>,
} }
/// Login request payload. /// Login request payload.
@ -110,7 +114,8 @@ pub async fn login(
let user: Option<DbUser> = sqlx::query_as( let user: Option<DbUser> = sqlx::query_as(
r#" r#"
SELECT id, username, display_name, role, auth_provider, 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 FROM users
WHERE username = $1 AND auth_provider = 'local' 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 // 2. Verify password
let hash = user.password_hash.as_deref().unwrap_or(""); let hash = user.password_hash.as_deref().unwrap_or("");
let valid = password::verify_password(&req.password, hash).unwrap_or(false); let valid = password::verify_password(&req.password, hash).unwrap_or(false);
if !valid { 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); 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?; let raw_refresh = refresh::issue(pool, user.id, user_agent, ip_address).await?;
// 6. Update last_login_at // 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(Utc::now())
.bind(user.id) .bind(user.id)
.execute(pool) .execute(pool)
@ -216,7 +252,8 @@ pub async fn refresh_session(
let user: DbUser = sqlx::query_as( let user: DbUser = sqlx::query_as(
r#" r#"
SELECT id, username, display_name, role, auth_provider, 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 FROM users WHERE id = $1
"#, "#,
) )

View File

@ -21,11 +21,13 @@ use pm_auth::{
mfa_totp, mfa_totp,
rbac::AuthUser, rbac::AuthUser,
session::{self, LoginRequest, LoginResponse}, session::{self, LoginRequest, LoginResponse},
verify_password, verify_password, hash_password, validate_password_strength,
}; };
use serde::Deserialize; use serde::Deserialize;
use serde_json::{json, Value}; use serde_json::{json, Value};
use uuid::Uuid;
use crate::AppState; use crate::AppState;
// ============================================================ // ============================================================
@ -37,6 +39,7 @@ pub fn public_router() -> Router<AppState> {
.route("/login", post(login_handler)) .route("/login", post(login_handler))
.route("/refresh", post(refresh_handler)) .route("/refresh", post(refresh_handler))
.route("/logout", post(logout_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_required",
"Password reset is required before login", "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"); tracing::error!(error = %e, "Login error");
( (
@ -214,6 +222,93 @@ async fn logout_handler(
// GET /api/v1/auth/mfa/setup (JWT required — via middleware) // 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( async fn mfa_setup_handler(
auth_user: AuthUser, auth_user: AuthUser,
) -> Result<Json<mfa_totp::TotpSetup>, (StatusCode, Json<Value>)> { ) -> Result<Json<mfa_totp::TotpSetup>, (StatusCode, Json<Value>)> {

View File

@ -16,6 +16,7 @@ use axum::{
Router, Router,
}; };
use pm_auth::{hash_password, rbac::AuthUser, session::force_logout, verify_password}; use pm_auth::{hash_password, rbac::AuthUser, session::force_logout, verify_password};
use pm_auth::validate_password_strength;
use pm_core::{ use pm_core::{
audit::{log_event, AuditAction}, audit::{log_event, AuditAction},
models::{AdminResetPasswordRequest, ChangePasswordRequest, CreateUserRequest, UpdateUserRequest, User}, 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| { let hash = hash_password(&req.password).map_err(|e| {
( (
StatusCode::INTERNAL_SERVER_ERROR, 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| { let new_hash = hash_password(&req.new_password).map_err(|e| {
( (
StatusCode::INTERNAL_SERVER_ERROR, 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| { let new_hash = hash_password(&req.new_password).map_err(|e| {
( (
StatusCode::INTERNAL_SERVER_ERROR, StatusCode::INTERNAL_SERVER_ERROR,

View File

@ -12,8 +12,10 @@
"@emotion/styled": "^11.14.0", "@emotion/styled": "^11.14.0",
"@mui/icons-material": "^7.0.0", "@mui/icons-material": "^7.0.0",
"@mui/material": "^7.0.0", "@mui/material": "^7.0.0",
"@types/qrcode": "^1.5.6",
"axios": "^1.9.0", "axios": "^1.9.0",
"jszip": "^3.10.1", "jszip": "^3.10.1",
"qrcode": "^1.5.4",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-router-dom": "^7.5.3", "react-router-dom": "^7.5.3",
@ -1772,6 +1774,14 @@
"integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
"dev": true "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": { "node_modules/@types/parse-json": {
"version": "4.0.2", "version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz", "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", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==" "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": { "node_modules/@types/react": {
"version": "19.2.14", "version": "19.2.14",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
@ -2086,11 +2104,18 @@
"url": "https://github.com/sponsors/epoberezkin" "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": { "node_modules/ansi-styles": {
"version": "4.3.0", "version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dev": true,
"dependencies": { "dependencies": {
"color-convert": "^2.0.1" "color-convert": "^2.0.1"
}, },
@ -2222,6 +2247,14 @@
"node": ">=6" "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": { "node_modules/caniuse-lite": {
"version": "1.0.30001790", "version": "1.0.30001790",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001790.tgz", "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" "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": { "node_modules/clsx": {
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
@ -2270,7 +2313,6 @@
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dev": true,
"dependencies": { "dependencies": {
"color-name": "~1.1.4" "color-name": "~1.1.4"
}, },
@ -2281,8 +2323,7 @@
"node_modules/color-name": { "node_modules/color-name": {
"version": "1.1.4", "version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="
"dev": true
}, },
"node_modules/combined-stream": { "node_modules/combined-stream": {
"version": "1.0.8", "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": { "node_modules/deep-is": {
"version": "0.1.4", "version": "0.1.4",
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
@ -2395,6 +2444,11 @@
"node": ">=0.4.0" "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": { "node_modules/dom-helpers": {
"version": "5.2.1", "version": "5.2.1",
"resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", "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==", "integrity": "sha512-4MxfbmNDm+KPh066EZy+eUnkcDPcZ35wNmOWzFuh/ijvHsve6kbLTLURy88uCNK5FbpN+yk2nQY6BYh1GEt+wg==",
"dev": true "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": { "node_modules/error-ex": {
"version": "1.3.4", "version": "1.3.4",
"resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz",
@ -2904,6 +2963,14 @@
"node": ">=6.9.0" "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": { "node_modules/get-intrinsic": {
"version": "1.3.0", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
@ -3103,6 +3170,14 @@
"node": ">=0.10.0" "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": { "node_modules/is-glob": {
"version": "4.0.3", "version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
@ -3408,6 +3483,14 @@
"url": "https://github.com/sponsors/sindresorhus" "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": { "node_modules/pako": {
"version": "1.0.11", "version": "1.0.11",
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
@ -3445,7 +3528,6 @@
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
"dev": true,
"engines": { "engines": {
"node": ">=8" "node": ">=8"
} }
@ -3489,6 +3571,14 @@
"url": "https://github.com/sponsors/jonschlinkert" "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": { "node_modules/postcss": {
"version": "8.5.10", "version": "8.5.10",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz",
@ -3563,6 +3653,22 @@
"node": ">=6" "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": { "node_modules/react": {
"version": "19.2.5", "version": "19.2.5",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz", "resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz",
@ -3661,6 +3767,19 @@
"util-deprecate": "~1.0.1" "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": { "node_modules/resolve": {
"version": "1.22.12", "version": "1.22.12",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
@ -3755,6 +3874,11 @@
"node": ">=10" "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": { "node_modules/set-cookie-parser": {
"version": "2.7.2", "version": "2.7.2",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
@ -3811,6 +3935,30 @@
"safe-buffer": "~5.1.0" "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": { "node_modules/strip-json-comments": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
@ -3904,6 +4052,11 @@
"node": ">=14.17" "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": { "node_modules/update-browserslist-db": {
"version": "1.2.3", "version": "1.2.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
@ -4037,6 +4190,11 @@
"node": ">= 8" "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": { "node_modules/word-wrap": {
"version": "1.2.5", "version": "1.2.5",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
@ -4046,6 +4204,24 @@
"node": ">=0.10.0" "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": { "node_modules/yallist": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
@ -4069,6 +4245,87 @@
"url": "https://github.com/sponsors/eemeli" "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": { "node_modules/yocto-queue": {
"version": "0.1.0", "version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",

View File

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

View File

@ -101,6 +101,9 @@ export const authApi = {
logout: (refreshToken: string) => logout: (refreshToken: string) =>
apiClient.post('/auth/logout', { refresh_token: refreshToken }), 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: () => getMfaSetup: () =>
apiClient.get('/auth/mfa/setup'), apiClient.get('/auth/mfa/setup'),

View File

@ -3,8 +3,12 @@ import { useNavigate } from 'react-router-dom'
import { import {
Box, Button, Container, TextField, Typography, Box, Button, Container, TextField, Typography,
Alert, CircularProgress, Paper, InputAdornment, IconButton, Alert, CircularProgress, Paper, InputAdornment, IconButton,
List, ListItem, ListItemIcon, ListItemText,
} from '@mui/material' } 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 { authApi } from '../api/client'
import { useAuthStore } from '../store/authStore' import { useAuthStore } from '../store/authStore'
import type { User } from '../types' import type { User } from '../types'
@ -32,6 +36,16 @@ function getErrorMessage(err: unknown): string {
return 'MFA_REQUIRED' // sentinel — caller checks this 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 // Account disabled
if (code === 'account_disabled') { if (code === 'account_disabled') {
return 'This account has been disabled. Contact your administrator.' return 'This account has been disabled. Contact your administrator.'
@ -56,6 +70,21 @@ function getErrorMessage(err: unknown): string {
return 'Login failed. Please try again.' 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() { export default function LoginPage() {
const navigate = useNavigate() const navigate = useNavigate()
const { setTokens, setUser } = useAuthStore() const { setTokens, setUser } = useAuthStore()
@ -65,9 +94,20 @@ export default function LoginPage() {
const [totpCode, setTotpCode] = useState('') const [totpCode, setTotpCode] = useState('')
const [showPassword, setShowPassword] = useState(false) const [showPassword, setShowPassword] = useState(false)
const [needsMfa, setNeedsMfa] = useState(false) const [needsMfa, setNeedsMfa] = useState(false)
const [forcePasswordReset, setForcePasswordReset] = useState(false)
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null) 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) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault() e.preventDefault()
setLoading(true) setLoading(true)
@ -84,6 +124,11 @@ export default function LoginPage() {
if (message === 'MFA_REQUIRED') { if (message === 'MFA_REQUIRED') {
setNeedsMfa(true) setNeedsMfa(true)
setError('Please enter your MFA code.') 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 { } else {
setError(message) 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 ( return (
<Container maxWidth="xs" sx={{ mt: 12 }}> <Container maxWidth="xs" sx={{ mt: 12 }}>
<Paper elevation={4} sx={{ p: 4 }}> <Paper elevation={4} sx={{ p: 4 }}>
@ -101,7 +184,7 @@ export default function LoginPage() {
{error && ( {error && (
<Alert <Alert
severity={needsMfa ? 'info' : 'error'} severity={forcePasswordReset ? 'warning' : 'error'}
sx={{ mb: 2 }} sx={{ mb: 2 }}
onClose={() => setError(null)} onClose={() => setError(null)}
> >
@ -109,42 +192,139 @@ export default function LoginPage() {
</Alert> </Alert>
)} )}
<Box component="form" onSubmit={handleSubmit} noValidate> {passwordChanged ? (
<TextField <Box>
fullWidth margin="normal" label="Username" autoComplete="username" <Alert severity="success" sx={{ mb: 2 }}>
value={username} onChange={(e) => setUsername(e.target.value)} Password changed successfully! Please log in with your new password.
disabled={loading} required autoFocus </Alert>
/> <Button
<TextField fullWidth variant="contained" size="large"
fullWidth margin="normal" label="Password" type={showPassword ? 'text' : 'password'} onClick={handleBackToLogin}
autoComplete="current-password" value={password} >
onChange={(e) => setPassword(e.target.value)} disabled={loading} required Back to Login
InputProps={{ </Button>
endAdornment: ( </Box>
<InputAdornment position="end"> ) : forcePasswordReset ? (
<IconButton onClick={() => setShowPassword(!showPassword)} edge="end"> <Box component="form" onSubmit={handleForceChangePassword} noValidate>
{showPassword ? <VisibilityOff /> : <Visibility />} <Typography variant="h6" fontWeight={600} mb={2}>
</IconButton> Change Your Password
</InputAdornment> </Typography>
), <Typography variant="body2" color="text.secondary" mb={2}>
}} Your password has expired and must be changed before you can log in.
/> </Typography>
{needsMfa && (
<TextField <TextField
fullWidth margin="normal" label="MFA Code" inputMode="numeric" fullWidth margin="normal" label="Username"
inputProps={{ maxLength: 6, pattern: '[0-9]*' }} value={username} InputProps={{ readOnly: true }}
value={totpCode} onChange={(e) => setTotpCode(e.target.value)}
disabled={loading} required autoFocus
helperText="Enter the 6-digit code from your authenticator app"
/> />
)} <TextField
<Button fullWidth margin="normal" label="Current Password" type="password"
type="submit" fullWidth variant="contained" size="large" value={password} InputProps={{ readOnly: true }}
sx={{ mt: 3 }} disabled={loading} />
> <TextField
{loading ? <CircularProgress size={24} /> : 'Sign In'} fullWidth margin="normal" label="New Password"
</Button> type={showNewPassword ? 'text' : 'password'}
</Box> 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"
value={username} onChange={(e) => setUsername(e.target.value)}
disabled={loading} required autoFocus
/>
<TextField
fullWidth margin="normal" label="Password" type={showPassword ? 'text' : 'password'}
autoComplete="current-password" value={password}
onChange={(e) => setPassword(e.target.value)} disabled={loading} required
InputProps={{
endAdornment: (
<InputAdornment position="end">
<IconButton onClick={() => setShowPassword(!showPassword)} edge="end">
{showPassword ? <VisibilityOff /> : <Visibility />}
</IconButton>
</InputAdornment>
),
}}
/>
{needsMfa && (
<TextField
fullWidth margin="normal" label="MFA Code" inputMode="numeric"
inputProps={{ maxLength: 6, pattern: '[0-9]*' }}
value={totpCode} onChange={(e) => setTotpCode(e.target.value)}
disabled={loading} required autoFocus
helperText="Enter the 6-digit code from your authenticator app"
/>
)}
<Button
type="submit" fullWidth variant="contained" size="large"
sx={{ mt: 3 }} disabled={loading}
>
{loading ? <CircularProgress size={24} /> : 'Sign In'}
</Button>
</Box>
)}
</Paper> </Paper>
</Container> </Container>
) )

View File

@ -2,7 +2,10 @@ import React, { useEffect, useState } from 'react'
import { import {
Box, Button, Container, TextField, Typography, Box, Button, Container, TextField, Typography,
Alert, CircularProgress, Paper, Stepper, Step, StepLabel, Alert, CircularProgress, Paper, Stepper, Step, StepLabel,
IconButton, Tooltip, Snackbar,
} from '@mui/material' } from '@mui/material'
import { ContentCopy as CopyIcon } from '@mui/icons-material'
import QRCode from 'qrcode'
import { authApi } from '../api/client' import { authApi } from '../api/client'
const STEPS = ['Get your QR code', 'Verify code', 'Done'] const STEPS = ['Get your QR code', 'Verify code', 'Done']
@ -13,13 +16,35 @@ export default function MfaSetupPage() {
const [code, setCode] = useState('') const [code, setCode] = useState('')
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [qrDataUrl, setQrDataUrl] = useState<string | null>(null)
const [copied, setCopied] = useState(false)
useEffect(() => { useEffect(() => {
authApi.getMfaSetup() 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.')) .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) => { const handleVerify = async (e: React.FormEvent) => {
e.preventDefault() e.preventDefault()
if (!setup) return if (!setup) return
@ -48,14 +73,46 @@ export default function MfaSetupPage() {
{step === 0 && setup && ( {step === 0 && setup && (
<Box> <Box>
<Typography mb={2}> <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>
<Typography variant="body2" sx={{ fontFamily: 'monospace', wordBreak: 'break-all', mb: 2, p: 1, bgcolor: 'grey.100', borderRadius: 1 }}> {qrDataUrl ? (
{setup.otp_uri} <Box sx={{ display: 'flex', justifyContent: 'center', mb: 2 }}>
</Typography> <img
<Typography variant="caption" color="text.secondary" display="block" mb={3}> src={qrDataUrl}
Manual entry secret: <strong>{setup.secret_base32}</strong> 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>
<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> <Button variant="contained" onClick={() => setStep(1)}>Continue</Button>
</Box> </Box>
)} )}
@ -81,6 +138,15 @@ export default function MfaSetupPage() {
</Alert> </Alert>
)} )}
</Paper> </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> </Container>
) )
} }

View File

@ -4,6 +4,7 @@ import {
Box, Button, Card, CardContent, Chip, Container, Dialog, Box, Button, Card, CardContent, Chip, Container, Dialog,
DialogActions, DialogContent, DialogTitle, Snackbar, DialogActions, DialogContent, DialogTitle, Snackbar,
Alert, TextField, Typography, InputAdornment, IconButton, Alert, TextField, Typography, InputAdornment, IconButton,
List, ListItem, ListItemIcon, ListItemText,
} from '@mui/material' } from '@mui/material'
import { import {
Person as PersonIcon, Person as PersonIcon,
@ -11,11 +12,27 @@ import {
Visibility, VisibilityOff, Visibility, VisibilityOff,
VpnKey as MfaIcon, VpnKey as MfaIcon,
Save as SaveIcon, Save as SaveIcon,
Check as CheckIcon, Close as CloseIcon,
} from '@mui/icons-material' } from '@mui/icons-material'
import { useAuthStore } from '../store/authStore' import { useAuthStore } from '../store/authStore'
import { usersApi } from '../api/client' import { usersApi } from '../api/client'
import type { User } from '../types' 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() { export default function ProfilePage() {
const navigate = useNavigate() const navigate = useNavigate()
const { user, setUser } = useAuthStore() const { user, setUser } = useAuthStore()
@ -49,6 +66,10 @@ export default function ProfilePage() {
const showSnack = (severity: 'success' | 'error', message: string) => const showSnack = (severity: 'success' | 'error', message: string) =>
setSnack({ open: true, severity, message }) setSnack({ open: true, severity, message })
const pwChecks = checkPasswordStrength(newPw)
const pwValid = isPasswordValid(pwChecks)
const pwMismatch = !!(newPw && confirmPw && newPw !== confirmPw)
// ── Load current user on mount ────────────────────────────────────────── // ── Load current user on mount ──────────────────────────────────────────
useEffect(() => { useEffect(() => {
;(async () => { ;(async () => {
@ -87,6 +108,10 @@ export default function ProfilePage() {
showSnack('error', 'New passwords do not match') showSnack('error', 'New passwords do not match')
return return
} }
if (!pwValid) {
showSnack('error', 'Password does not meet strength requirements')
return
}
setChangingPw(true) setChangingPw(true)
try { try {
await usersApi.changePassword({ current_password: currentPw, new_password: newPw }) await usersApi.changePassword({ current_password: currentPw, new_password: newPw })
@ -127,8 +152,6 @@ export default function ProfilePage() {
) )
} }
const pwMismatch = !!(newPw && confirmPw && newPw !== confirmPw)
return ( return (
<Container maxWidth="md" sx={{ mt: 3 }}> <Container maxWidth="md" sx={{ mt: 3 }}>
<Typography variant="h5" fontWeight={700} sx={{ mb: 3 }}>My Profile</Typography> <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 <TextField
label="Confirm New Password" label="Confirm New Password"
type={showConfirmPw ? 'text' : 'password'} type={showConfirmPw ? 'text' : 'password'}
@ -239,7 +298,7 @@ export default function ProfilePage() {
<Button <Button
variant="contained" variant="contained"
onClick={handleChangePassword} onClick={handleChangePassword}
disabled={changingPw || !currentPw || !newPw || !confirmPw || !!pwMismatch} disabled={changingPw || !currentPw || !newPw || !confirmPw || !!pwMismatch || !pwValid}
> >
{changingPw ? 'Changing…' : 'Change Password'} {changingPw ? 'Changing…' : 'Change Password'}
</Button> </Button>

View File

@ -5,15 +5,74 @@ import {
MenuItem, Paper, Select, Switch, Table, TableBody, TableCell, MenuItem, Paper, Select, Switch, Table, TableBody, TableCell,
TableContainer, TableHead, TableRow, TextField, Toolbar, Tooltip, Typography, TableContainer, TableHead, TableRow, TextField, Toolbar, Tooltip, Typography,
Snackbar, Alert, InputAdornment, FormControl, InputLabel, Snackbar, Alert, InputAdornment, FormControl, InputLabel,
List, ListItem, ListItemIcon, ListItemText,
} from '@mui/material' } from '@mui/material'
import { import {
Add as AddIcon, Lock as LockIcon, Edit as EditIcon, Add as AddIcon, Lock as LockIcon, Edit as EditIcon,
VpnKey as VpnKeyIcon, Delete as DeleteIcon, Search as SearchIcon, VpnKey as VpnKeyIcon, Delete as DeleteIcon, Search as SearchIcon,
Check as CheckIcon, Close as CloseIcon,
} from '@mui/icons-material' } from '@mui/icons-material'
import { usersApi } from '../api/client' import { usersApi } from '../api/client'
import { useAuthStore } from '../store/authStore' import { useAuthStore } from '../store/authStore'
import type { User, UpdateUserRequest, AdminResetPasswordRequest } from '../types' 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() { export default function UsersPage() {
const currentUser = useAuthStore(s => s.user) const currentUser = useAuthStore(s => s.user)
const isAdmin = currentUser?.role === 'admin' const isAdmin = currentUser?.role === 'admin'
@ -56,6 +115,10 @@ export default function UsersPage() {
const [deleteOpen, setDeleteOpen] = useState(false) const [deleteOpen, setDeleteOpen] = useState(false)
const [deleteUser, setDeleteUser] = useState<User | null>(null) 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 () => { const load = async () => {
setLoading(true) setLoading(true)
try { try {
@ -90,6 +153,10 @@ export default function UsersPage() {
// ── Handlers ──────────────────────────────────────────────────────────────── // ── Handlers ────────────────────────────────────────────────────────────────
const handleCreate = async () => { const handleCreate = async () => {
if (!addPwValid) {
showSnack('error', 'Password does not meet strength requirements')
return
}
try { try {
await usersApi.create(addForm) await usersApi.create(addForm)
setAddOpen(false) setAddOpen(false)
@ -142,12 +209,12 @@ export default function UsersPage() {
const handleResetSave = async () => { const handleResetSave = async () => {
if (!resetUser) return if (!resetUser) return
if (resetForm.new_password !== resetForm.confirm_password) { if (resetPwMismatch) {
showSnack('error', 'Passwords do not match') showSnack('error', 'Passwords do not match')
return return
} }
if (resetForm.new_password.length < 8) { if (!resetPwValid) {
showSnack('error', 'Password must be at least 8 characters') showSnack('error', 'Password does not meet strength requirements')
return return
} }
try { try {
@ -326,6 +393,7 @@ export default function UsersPage() {
value={addForm.password} value={addForm.password}
onChange={e => setAddForm({ ...addForm, password: e.target.value })} onChange={e => setAddForm({ ...addForm, password: e.target.value })}
margin="normal" required /> margin="normal" required />
<PasswordStrengthIndicator password={addForm.password} />
<FormControl fullWidth sx={{ mt: 2 }}> <FormControl fullWidth sx={{ mt: 2 }}>
<InputLabel>Role</InputLabel> <InputLabel>Role</InputLabel>
<Select value={addForm.role} label="Role" <Select value={addForm.role} label="Role"
@ -338,7 +406,7 @@ export default function UsersPage() {
<DialogActions> <DialogActions>
<Button onClick={() => setAddOpen(false)}>Cancel</Button> <Button onClick={() => setAddOpen(false)}>Cancel</Button>
<Button variant="contained" onClick={handleCreate} <Button variant="contained" onClick={handleCreate}
disabled={!addForm.username || !addForm.email || !addForm.password}> disabled={!addForm.username || !addForm.email || !addForm.password || !addPwValid}>
Create Create
</Button> </Button>
</DialogActions> </DialogActions>
@ -427,17 +495,14 @@ export default function UsersPage() {
value={resetForm.new_password} value={resetForm.new_password}
onChange={e => setResetForm({ ...resetForm, new_password: e.target.value })} onChange={e => setResetForm({ ...resetForm, new_password: e.target.value })}
margin="normal" required 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" <TextField fullWidth label="Confirm Password" type="password"
value={resetForm.confirm_password} value={resetForm.confirm_password}
onChange={e => setResetForm({ ...resetForm, confirm_password: e.target.value })} onChange={e => setResetForm({ ...resetForm, confirm_password: e.target.value })}
margin="normal" required margin="normal" required
error={resetForm.confirm_password.length > 0 && resetForm.new_password !== resetForm.confirm_password} error={resetPwMismatch}
helperText={resetForm.confirm_password.length > 0 && resetForm.new_password !== resetForm.confirm_password helperText={resetPwMismatch ? 'Passwords do not match' : ''}
? 'Passwords do not match' : ''}
/> />
<FormControlLabel <FormControlLabel
control={ control={
@ -453,8 +518,8 @@ export default function UsersPage() {
<Button variant="contained" color="warning" onClick={handleResetSave} <Button variant="contained" color="warning" onClick={handleResetSave}
disabled={ disabled={
!resetForm.new_password || !resetForm.new_password ||
resetForm.new_password.length < 8 || !resetPwValid ||
resetForm.new_password !== resetForm.confirm_password resetPwMismatch
}> }>
Reset Password Reset Password
</Button> </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;