From 856e3b667443fed8758c2ced54237bc58d5840df Mon Sep 17 00:00:00 2001 From: nogueiraguh Date: Thu, 2 Apr 2026 18:09:44 -0300 Subject: [PATCH] feat: password change 3-step modal with strength meter, default admin/admin123 --- backend/app.py | 16 ++- frontend-react/src/App.tsx | 180 +++++++++++++++++++++++++++------- frontend-react/src/i18n/en.ts | 26 +++++ frontend-react/src/i18n/es.ts | 26 +++++ frontend-react/src/i18n/pt.ts | 26 +++++ 5 files changed, 233 insertions(+), 41 deletions(-) diff --git a/backend/app.py b/backend/app.py index 9bde731..80e1593 100644 --- a/backend/app.py +++ b/backend/app.py @@ -543,13 +543,11 @@ def init_db(): log.info(f"Recovered stuck workspace {ws['id']}: {ws['status']} -> {prev}") adm = c.execute("SELECT id FROM users WHERE username='admin'").fetchone() if not adm: - _tmp_pw = secrets.token_urlsafe(16) c.execute( "INSERT INTO users (id,first_name,last_name,username,email,password_hash,role,must_change_password) VALUES (?,?,?,?,?,?,?,?)", - (str(uuid.uuid4()), "Admin", "Sistema", "admin", "admin@local", _hash_pw(_tmp_pw), "admin", 1) + (str(uuid.uuid4()), "Admin", "Sistema", "admin", "admin@local", _hash_pw("admin123"), "admin", 1) ) - log.info(f"Default admin created — username: admin / password: {_tmp_pw}") - log.info("⚠ Change the admin password on first login!") + log.info("Default admin created — username: admin / password: admin123") # ── Crypto helpers ──────────────────────────────────────────────────────────── def _hash_pw(pw): salt=secrets.token_hex(16); h=hashlib.pbkdf2_hmac("sha256",pw.encode(),salt.encode(),100_000); return f"{salt}:{h.hex()}" @@ -899,7 +897,15 @@ async def change_pw(req: ChangePwReq, u=Depends(current_user)): if u.get("auth_provider") == "oidc": raise HTTPException(400, "Usuários OIDC não podem alterar senha localmente") if not _verify_pw(req.current_password, u["password_hash"]): raise HTTPException(400, "Senha atual incorreta") - with db() as c: c.execute("UPDATE users SET password_hash=?, must_change_password=0 WHERE id=?", (_hash_pw(req.new_password), u["id"])) + pw = req.new_password + import re + if len(pw) < 8: raise HTTPException(400, "Senha deve ter no mínimo 8 caracteres") + if not re.search(r'[A-Z]', pw): raise HTTPException(400, "Senha deve conter ao menos uma letra maiúscula") + if not re.search(r'[a-z]', pw): raise HTTPException(400, "Senha deve conter ao menos uma letra minúscula") + if not re.search(r'\d', pw): raise HTTPException(400, "Senha deve conter ao menos um número") + if not re.search(r'[^A-Za-z0-9]', pw): raise HTTPException(400, "Senha deve conter ao menos um caractere especial") + if pw == req.current_password: raise HTTPException(400, "A nova senha deve ser diferente da atual") + with db() as c: c.execute("UPDATE users SET password_hash=?, must_change_password=0 WHERE id=?", (_hash_pw(pw), u["id"])) return {"ok": True} # ── OIDC endpoints ─────────────────────────────────────────────────────────── diff --git a/frontend-react/src/App.tsx b/frontend-react/src/App.tsx index 2629fb2..5f30d7d 100644 --- a/frontend-react/src/App.tsx +++ b/frontend-react/src/App.tsx @@ -1,10 +1,11 @@ -import { useEffect, useState, lazy, Suspense } from 'react'; +import { useEffect, useState, useMemo, lazy, Suspense } from 'react'; import { Routes, Route, Navigate } from 'react-router-dom'; import { useAuthStore } from '@/stores/auth'; import { useAppStore } from '@/stores/app'; import { authApi } from '@/api/endpoints/auth'; +import { useI18n } from '@/i18n'; import AppShell from '@/components/layout/AppShell'; -import { Loader2, Lock } from 'lucide-react'; +import { Loader2, Lock, CheckCircle, XCircle, Shield } from 'lucide-react'; // Lazy-loaded pages (code splitting) const LoginPage = lazy(() => import('@/pages/LoginPage')); @@ -36,25 +37,43 @@ function PageLoader() { } function ForcePasswordModal() { + const { t } = useI18n(); + const [step, setStep] = useState<'welcome' | 'form' | 'done'>('welcome'); const [curPw, setCurPw] = useState(''); const [newPw, setNewPw] = useState(''); const [confirmPw, setConfirmPw] = useState(''); const [error, setError] = useState(''); const [loading, setLoading] = useState(false); - const clearMustChangePassword = useAuthStore((s) => s.clearMustChangePassword); + const checkAuth = useAuthStore((s) => s.checkAuth); + const user = useAuthStore((s) => s.user); + + const rules = useMemo(() => [ + { label: t('pw.minLength') || 'Minimum 8 characters', ok: newPw.length >= 8 }, + { label: t('pw.hasUpper') || 'One uppercase letter', ok: /[A-Z]/.test(newPw) }, + { label: t('pw.hasLower') || 'One lowercase letter', ok: /[a-z]/.test(newPw) }, + { label: t('pw.hasNumber') || 'One number', ok: /\d/.test(newPw) }, + { label: t('pw.hasSpecial') || 'One special character (!@#$...)', ok: /[^A-Za-z0-9]/.test(newPw) }, + { label: t('pw.match') || 'Passwords match', ok: newPw.length > 0 && newPw === confirmPw }, + ], [newPw, confirmPw, t]); + + const allValid = rules.every(r => r.ok) && curPw.length > 0 && newPw !== curPw; + const strength = rules.filter(r => r.ok).length; + const strengthColor = strength <= 2 ? 'var(--rd)' : strength <= 4 ? 'var(--yl, #e6a817)' : 'var(--gn)'; + const strengthLabel = strength <= 2 ? (t('pw.weak') || 'Weak') : strength <= 4 ? (t('pw.medium') || 'Medium') : (t('pw.strong') || 'Strong'); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setError(''); - if (newPw.length < 8) { setError('A nova senha deve ter no minimo 8 caracteres'); return; } - if (newPw !== confirmPw) { setError('As senhas nao coincidem'); return; } - if (newPw === curPw) { setError('A nova senha deve ser diferente da atual'); return; } + if (!allValid) return; setLoading(true); try { await authApi.changePassword(curPw, newPw); - clearMustChangePassword(); + setStep('done'); + setTimeout(async () => { + await checkAuth(); + }, 2000); } catch (err) { - setError(err instanceof Error ? err.message : 'Erro ao alterar senha'); + setError(err instanceof Error ? err.message : t('pw.error') || 'Error changing password'); } finally { setLoading(false); } @@ -62,35 +81,124 @@ function ForcePasswordModal() { return (
-
-
-
- +
+ + {step === 'welcome' && ( +
+
+ +
+

+ {t('pw.welcomeTitle') || 'Welcome!'} +

+

+ {t('pw.welcomeUser') || 'Logged in as'} {user?.username} +

+

+ {t('pw.welcomeMsg') || 'For security, you must change your password before continuing.'} +

+
-

Alterar Senha

-

- Por seguranca, altere sua senha antes de continuar. -

-
-
- setCurPw(e.target.value)} - className="w-full px-3 py-2.5 rounded-lg text-sm outline-none" - style={{ background: 'var(--bg2)', border: '1px solid var(--bd)', color: 'var(--t1)' }} autoFocus /> - setNewPw(e.target.value)} - className="w-full px-3 py-2.5 rounded-lg text-sm outline-none" - style={{ background: 'var(--bg2)', border: '1px solid var(--bd)', color: 'var(--t1)' }} /> - setConfirmPw(e.target.value)} - className="w-full px-3 py-2.5 rounded-lg text-sm outline-none" - style={{ background: 'var(--bg2)', border: '1px solid var(--bd)', color: 'var(--t1)' }} /> - {error && ( -
{error}
- )} - -
+ )} + + {step === 'form' && ( + <> +
+
+ +
+

+ {t('pw.changeTitle') || 'Change Password'} +

+
+
+
+ + setCurPw(e.target.value)} + className="w-full px-3 py-2.5 rounded-lg text-sm outline-none" + style={{ background: 'var(--bg2)', border: '1px solid var(--bd)', color: 'var(--t1)' }} autoFocus /> +
+
+ + setNewPw(e.target.value)} + className="w-full px-3 py-2.5 rounded-lg text-sm outline-none" + style={{ background: 'var(--bg2)', border: '1px solid var(--bd)', color: 'var(--t1)' }} /> +
+
+ + setConfirmPw(e.target.value)} + className="w-full px-3 py-2.5 rounded-lg text-sm outline-none" + style={{ background: 'var(--bg2)', border: '1px solid var(--bd)', color: 'var(--t1)' }} /> +
+ + {newPw.length > 0 && ( +
+
+ + {t('pw.strength') || 'Password strength'} + + {strengthLabel} +
+
+
+
+
+ {rules.map((r, i) => ( +
+ {r.ok + ? + : } + {r.label} +
+ ))} +
+
+ )} + + {newPw.length > 0 && newPw === curPw && ( +
+ {t('pw.sameAsOld') || 'New password must be different from the current one'} +
+ )} + + {error && ( +
{error}
+ )} + + + + + )} + + {step === 'done' && ( +
+
+ +
+

+ {t('pw.successTitle') || 'Password changed!'} +

+

+ {t('pw.successMsg') || 'Redirecting...'} +

+
+ )} +
); diff --git a/frontend-react/src/i18n/en.ts b/frontend-react/src/i18n/en.ts index 2adfada..6f91415 100644 --- a/frontend-react/src/i18n/en.ts +++ b/frontend-react/src/i18n/en.ts @@ -48,6 +48,32 @@ const en: Record = { 'login.or': 'or', 'login.oidcButton': 'Sign in with Oracle IAM', + // ── Password Change ── + 'pw.welcomeTitle': 'Welcome!', + 'pw.welcomeUser': 'Logged in as', + 'pw.welcomeMsg': 'For security, you must change your password before continuing.', + 'pw.continue': 'Continue', + 'pw.changeTitle': 'Change Password', + 'pw.currentPassword': 'Current password', + 'pw.newPassword': 'New password', + 'pw.confirmPassword': 'Confirm new password', + 'pw.strength': 'Password strength', + 'pw.weak': 'Weak', + 'pw.medium': 'Medium', + 'pw.strong': 'Strong', + 'pw.minLength': 'Minimum 8 characters', + 'pw.hasUpper': 'One uppercase letter', + 'pw.hasLower': 'One lowercase letter', + 'pw.hasNumber': 'One number', + 'pw.hasSpecial': 'One special character (!@#$...)', + 'pw.match': 'Passwords match', + 'pw.sameAsOld': 'New password must be different from the current one', + 'pw.error': 'Error changing password', + 'pw.saving': 'Saving...', + 'pw.changeButton': 'Change Password', + 'pw.successTitle': 'Password changed!', + 'pw.successMsg': 'Redirecting...', + // ── Chat ── 'chat.timeNow': 'now', 'chat.today': 'Today', diff --git a/frontend-react/src/i18n/es.ts b/frontend-react/src/i18n/es.ts index ec0ea53..dcd3902 100644 --- a/frontend-react/src/i18n/es.ts +++ b/frontend-react/src/i18n/es.ts @@ -48,6 +48,32 @@ const es: Record = { 'login.or': 'o', 'login.oidcButton': 'Iniciar sesion con Oracle IAM', + // ── Password Change ── + 'pw.welcomeTitle': 'Bienvenido!', + 'pw.welcomeUser': 'Conectado como', + 'pw.welcomeMsg': 'Por seguridad, cambie su contrasena antes de continuar.', + 'pw.continue': 'Continuar', + 'pw.changeTitle': 'Cambiar Contrasena', + 'pw.currentPassword': 'Contrasena actual', + 'pw.newPassword': 'Nueva contrasena', + 'pw.confirmPassword': 'Confirmar nueva contrasena', + 'pw.strength': 'Fuerza de la contrasena', + 'pw.weak': 'Debil', + 'pw.medium': 'Media', + 'pw.strong': 'Fuerte', + 'pw.minLength': 'Minimo 8 caracteres', + 'pw.hasUpper': 'Una letra mayuscula', + 'pw.hasLower': 'Una letra minuscula', + 'pw.hasNumber': 'Un numero', + 'pw.hasSpecial': 'Un caracter especial (!@#$...)', + 'pw.match': 'Las contrasenas coinciden', + 'pw.sameAsOld': 'La nueva contrasena debe ser diferente de la actual', + 'pw.error': 'Error al cambiar contrasena', + 'pw.saving': 'Guardando...', + 'pw.changeButton': 'Cambiar Contrasena', + 'pw.successTitle': 'Contrasena cambiada!', + 'pw.successMsg': 'Redirigiendo...', + // ── Chat ── 'chat.timeNow': 'ahora', 'chat.today': 'Hoy', diff --git a/frontend-react/src/i18n/pt.ts b/frontend-react/src/i18n/pt.ts index 3ac2157..38ca2a2 100644 --- a/frontend-react/src/i18n/pt.ts +++ b/frontend-react/src/i18n/pt.ts @@ -48,6 +48,32 @@ const pt: Record = { 'login.or': 'ou', 'login.oidcButton': 'Login com Oracle IAM', + // ── Password Change ── + 'pw.welcomeTitle': 'Bem-vindo!', + 'pw.welcomeUser': 'Conectado como', + 'pw.welcomeMsg': 'Por seguranca, altere sua senha antes de continuar.', + 'pw.continue': 'Continuar', + 'pw.changeTitle': 'Alterar Senha', + 'pw.currentPassword': 'Senha atual', + 'pw.newPassword': 'Nova senha', + 'pw.confirmPassword': 'Confirmar nova senha', + 'pw.strength': 'Forca da senha', + 'pw.weak': 'Fraca', + 'pw.medium': 'Media', + 'pw.strong': 'Forte', + 'pw.minLength': 'Minimo 8 caracteres', + 'pw.hasUpper': 'Uma letra maiuscula', + 'pw.hasLower': 'Uma letra minuscula', + 'pw.hasNumber': 'Um numero', + 'pw.hasSpecial': 'Um caractere especial (!@#$...)', + 'pw.match': 'Senhas coincidem', + 'pw.sameAsOld': 'A nova senha deve ser diferente da atual', + 'pw.error': 'Erro ao alterar senha', + 'pw.saving': 'Salvando...', + 'pw.changeButton': 'Alterar Senha', + 'pw.successTitle': 'Senha alterada!', + 'pw.successMsg': 'Redirecionando...', + // ── Chat ── 'chat.timeNow': 'agora', 'chat.today': 'Hoje',