feat: password change 3-step modal with strength meter, default admin/admin123

This commit is contained in:
nogueiraguh
2026-04-02 18:09:44 -03:00
parent eb34a44664
commit 856e3b6674
5 changed files with 233 additions and 41 deletions

View File

@@ -543,13 +543,11 @@ def init_db():
log.info(f"Recovered stuck workspace {ws['id']}: {ws['status']} -> {prev}") log.info(f"Recovered stuck workspace {ws['id']}: {ws['status']} -> {prev}")
adm = c.execute("SELECT id FROM users WHERE username='admin'").fetchone() adm = c.execute("SELECT id FROM users WHERE username='admin'").fetchone()
if not adm: if not adm:
_tmp_pw = secrets.token_urlsafe(16)
c.execute( c.execute(
"INSERT INTO users (id,first_name,last_name,username,email,password_hash,role,must_change_password) VALUES (?,?,?,?,?,?,?,?)", "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("Default admin created — username: admin / password: admin123")
log.info("⚠ Change the admin password on first login!")
# ── Crypto helpers ──────────────────────────────────────────────────────────── # ── 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()}" 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": if u.get("auth_provider") == "oidc":
raise HTTPException(400, "Usuários OIDC não podem alterar senha localmente") 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") 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} return {"ok": True}
# ── OIDC endpoints ─────────────────────────────────────────────────────────── # ── OIDC endpoints ───────────────────────────────────────────────────────────

View File

@@ -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 { Routes, Route, Navigate } from 'react-router-dom';
import { useAuthStore } from '@/stores/auth'; import { useAuthStore } from '@/stores/auth';
import { useAppStore } from '@/stores/app'; import { useAppStore } from '@/stores/app';
import { authApi } from '@/api/endpoints/auth'; import { authApi } from '@/api/endpoints/auth';
import { useI18n } from '@/i18n';
import AppShell from '@/components/layout/AppShell'; 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) // Lazy-loaded pages (code splitting)
const LoginPage = lazy(() => import('@/pages/LoginPage')); const LoginPage = lazy(() => import('@/pages/LoginPage'));
@@ -36,25 +37,43 @@ function PageLoader() {
} }
function ForcePasswordModal() { function ForcePasswordModal() {
const { t } = useI18n();
const [step, setStep] = useState<'welcome' | 'form' | 'done'>('welcome');
const [curPw, setCurPw] = useState(''); const [curPw, setCurPw] = useState('');
const [newPw, setNewPw] = useState(''); const [newPw, setNewPw] = useState('');
const [confirmPw, setConfirmPw] = useState(''); const [confirmPw, setConfirmPw] = useState('');
const [error, setError] = useState(''); const [error, setError] = useState('');
const [loading, setLoading] = useState(false); 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) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
setError(''); setError('');
if (newPw.length < 8) { setError('A nova senha deve ter no minimo 8 caracteres'); return; } if (!allValid) return;
if (newPw !== confirmPw) { setError('As senhas nao coincidem'); return; }
if (newPw === curPw) { setError('A nova senha deve ser diferente da atual'); return; }
setLoading(true); setLoading(true);
try { try {
await authApi.changePassword(curPw, newPw); await authApi.changePassword(curPw, newPw);
clearMustChangePassword(); setStep('done');
setTimeout(async () => {
await checkAuth();
}, 2000);
} catch (err) { } 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 { } finally {
setLoading(false); setLoading(false);
} }
@@ -62,35 +81,124 @@ function ForcePasswordModal() {
return ( return (
<div className="fixed inset-0 z-[9999] flex items-center justify-center" style={{ background: 'rgba(0,0,0,0.7)', backdropFilter: 'blur(4px)' }}> <div className="fixed inset-0 z-[9999] flex items-center justify-center" style={{ background: 'rgba(0,0,0,0.7)', backdropFilter: 'blur(4px)' }}>
<div className="w-[400px] p-8 rounded-2xl" style={{ background: 'var(--bg1)', boxShadow: 'var(--sh3)', border: '1px solid var(--bd)' }}> <div className="w-[420px] p-8 rounded-2xl" style={{ background: 'var(--bg1)', boxShadow: 'var(--sh3)', border: '1px solid var(--bd)' }}>
<div className="flex flex-col items-center mb-5">
<div className="w-12 h-12 rounded-xl flex items-center justify-center mb-3" style={{ background: 'var(--rdl)' }}> {step === 'welcome' && (
<Lock size={24} style={{ color: 'var(--rd)' }} /> <div className="flex flex-col items-center">
<div className="w-14 h-14 rounded-xl flex items-center justify-center mb-4" style={{ background: 'var(--acl)' }}>
<Shield size={28} style={{ color: 'var(--ac)' }} />
</div>
<h2 className="text-lg font-bold mb-2" style={{ color: 'var(--t1)' }}>
{t('pw.welcomeTitle') || 'Welcome!'}
</h2>
<p className="text-xs text-center mb-1" style={{ color: 'var(--t2)' }}>
{t('pw.welcomeUser') || 'Logged in as'} <strong>{user?.username}</strong>
</p>
<p className="text-xs text-center mb-6" style={{ color: 'var(--t3)' }}>
{t('pw.welcomeMsg') || 'For security, you must change your password before continuing.'}
</p>
<button onClick={() => setStep('form')}
className="w-full py-2.5 rounded-lg text-sm font-semibold text-white transition-colors"
style={{ background: 'var(--ac)' }}>
{t('pw.continue') || 'Continue'}
</button>
</div> </div>
<h2 className="text-base font-bold" style={{ color: 'var(--t1)' }}>Alterar Senha</h2> )}
<p className="text-xs mt-1 text-center" style={{ color: 'var(--t3)' }}>
Por seguranca, altere sua senha antes de continuar. {step === 'form' && (
</p> <>
</div> <div className="flex flex-col items-center mb-5">
<form onSubmit={handleSubmit} className="flex flex-col gap-3"> <div className="w-12 h-12 rounded-xl flex items-center justify-center mb-3" style={{ background: 'var(--rdl)' }}>
<input type="password" placeholder="Senha atual" value={curPw} onChange={e => setCurPw(e.target.value)} <Lock size={24} style={{ color: 'var(--rd)' }} />
className="w-full px-3 py-2.5 rounded-lg text-sm outline-none" </div>
style={{ background: 'var(--bg2)', border: '1px solid var(--bd)', color: 'var(--t1)' }} autoFocus /> <h2 className="text-base font-bold" style={{ color: 'var(--t1)' }}>
<input type="password" placeholder="Nova senha (min. 8 caracteres)" value={newPw} onChange={e => setNewPw(e.target.value)} {t('pw.changeTitle') || 'Change Password'}
className="w-full px-3 py-2.5 rounded-lg text-sm outline-none" </h2>
style={{ background: 'var(--bg2)', border: '1px solid var(--bd)', color: 'var(--t1)' }} /> </div>
<input type="password" placeholder="Confirmar nova senha" value={confirmPw} onChange={e => setConfirmPw(e.target.value)} <form onSubmit={handleSubmit} className="flex flex-col gap-3">
className="w-full px-3 py-2.5 rounded-lg text-sm outline-none" <div>
style={{ background: 'var(--bg2)', border: '1px solid var(--bd)', color: 'var(--t1)' }} /> <label className="text-[.65rem] font-medium mb-1 block" style={{ color: 'var(--t3)' }}>
{error && ( {t('pw.currentPassword') || 'Current password'}
<div className="text-xs px-3 py-2 rounded-lg" style={{ background: 'var(--rdl)', color: 'var(--rd)' }}>{error}</div> </label>
)} <input type="password" value={curPw} onChange={e => setCurPw(e.target.value)}
<button type="submit" disabled={loading} className="w-full px-3 py-2.5 rounded-lg text-sm outline-none"
className="w-full py-2.5 rounded-lg text-sm font-semibold text-white transition-colors disabled:opacity-60" style={{ background: 'var(--bg2)', border: '1px solid var(--bd)', color: 'var(--t1)' }} autoFocus />
style={{ background: 'var(--ac)' }}> </div>
{loading ? 'Salvando...' : 'Alterar Senha'} <div>
</button> <label className="text-[.65rem] font-medium mb-1 block" style={{ color: 'var(--t3)' }}>
</form> {t('pw.newPassword') || 'New password'}
</label>
<input type="password" value={newPw} onChange={e => 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)' }} />
</div>
<div>
<label className="text-[.65rem] font-medium mb-1 block" style={{ color: 'var(--t3)' }}>
{t('pw.confirmPassword') || 'Confirm new password'}
</label>
<input type="password" value={confirmPw} onChange={e => 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)' }} />
</div>
{newPw.length > 0 && (
<div className="rounded-lg px-3 py-2.5" style={{ background: 'var(--bg2)', border: '1px solid var(--bd)' }}>
<div className="flex items-center justify-between mb-2">
<span className="text-[.6rem] font-semibold" style={{ color: 'var(--t3)' }}>
{t('pw.strength') || 'Password strength'}
</span>
<span className="text-[.6rem] font-bold" style={{ color: strengthColor }}>{strengthLabel}</span>
</div>
<div className="w-full h-1.5 rounded-full mb-2.5" style={{ background: 'var(--bg3)' }}>
<div className="h-full rounded-full transition-all duration-300"
style={{ width: `${(strength / 6) * 100}%`, background: strengthColor }} />
</div>
<div className="flex flex-col gap-1">
{rules.map((r, i) => (
<div key={i} className="flex items-center gap-1.5">
{r.ok
? <CheckCircle size={11} style={{ color: 'var(--gn)', flexShrink: 0 }} />
: <XCircle size={11} style={{ color: 'var(--t4)', flexShrink: 0 }} />}
<span className="text-[.6rem]" style={{ color: r.ok ? 'var(--gn)' : 'var(--t4)' }}>{r.label}</span>
</div>
))}
</div>
</div>
)}
{newPw.length > 0 && newPw === curPw && (
<div className="text-[.65rem] px-3 py-2 rounded-lg" style={{ background: 'var(--rdl)', color: 'var(--rd)' }}>
{t('pw.sameAsOld') || 'New password must be different from the current one'}
</div>
)}
{error && (
<div className="text-[.65rem] px-3 py-2 rounded-lg" style={{ background: 'var(--rdl)', color: 'var(--rd)' }}>{error}</div>
)}
<button type="submit" disabled={loading || !allValid}
className="w-full py-2.5 rounded-lg text-sm font-semibold text-white transition-colors disabled:opacity-40"
style={{ background: 'var(--ac)' }}>
{loading ? (t('pw.saving') || 'Saving...') : (t('pw.changeButton') || 'Change Password')}
</button>
</form>
</>
)}
{step === 'done' && (
<div className="flex flex-col items-center py-4">
<div className="w-14 h-14 rounded-xl flex items-center justify-center mb-4" style={{ background: 'var(--gnl)' }}>
<CheckCircle size={28} style={{ color: 'var(--gn)' }} />
</div>
<h2 className="text-base font-bold mb-2" style={{ color: 'var(--t1)' }}>
{t('pw.successTitle') || 'Password changed!'}
</h2>
<p className="text-xs text-center" style={{ color: 'var(--t3)' }}>
{t('pw.successMsg') || 'Redirecting...'}
</p>
</div>
)}
</div> </div>
</div> </div>
); );

View File

@@ -48,6 +48,32 @@ const en: Record<string, string> = {
'login.or': 'or', 'login.or': 'or',
'login.oidcButton': 'Sign in with Oracle IAM', '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 ──
'chat.timeNow': 'now', 'chat.timeNow': 'now',
'chat.today': 'Today', 'chat.today': 'Today',

View File

@@ -48,6 +48,32 @@ const es: Record<string, string> = {
'login.or': 'o', 'login.or': 'o',
'login.oidcButton': 'Iniciar sesion con Oracle IAM', '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 ──
'chat.timeNow': 'ahora', 'chat.timeNow': 'ahora',
'chat.today': 'Hoy', 'chat.today': 'Hoy',

View File

@@ -48,6 +48,32 @@ const pt: Record<string, string> = {
'login.or': 'ou', 'login.or': 'ou',
'login.oidcButton': 'Login com Oracle IAM', '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 ──
'chat.timeNow': 'agora', 'chat.timeNow': 'agora',
'chat.today': 'Hoje', 'chat.today': 'Hoje',