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
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:
@ -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};
|
||||
|
||||
@ -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::*;
|
||||
|
||||
@ -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
|
||||
"#,
|
||||
)
|
||||
|
||||
@ -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>)> {
|
||||
|
||||
@ -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,
|
||||
|
||||
267
frontend/package-lock.json
generated
267
frontend/package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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'),
|
||||
|
||||
|
||||
@ -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>
|
||||
)
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
3
migrations/012_account_lockout.sql
Normal file
3
migrations/012_account_lockout.sql
Normal 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;
|
||||
Reference in New Issue
Block a user