feat: My Settings — password change (local only), timezone, MFA, auth_provider field

- MySettingsPage: timezone, password change (local users), MFA setup/disable
- Password change hidden for federated users (auth_provider != local)
- Federated users see info message: "managed by Oracle IAM"
- Backend: auth_provider + oidc_subject columns in users table (migration)
- /users/me returns auth_provider field
- User interface updated with auth_provider
- i18n: 12 new keys (pt/en) for password change
This commit is contained in:
nogueiraguh
2026-04-01 15:20:16 -03:00
parent c3035abf81
commit 62f2a3d1ab
5 changed files with 207 additions and 70 deletions

View File

@@ -465,7 +465,7 @@ def init_db():
c.execute(f"ALTER TABLE oci_configs ADD COLUMN {col}")
except sqlite3.OperationalError:
pass
for col in ["timezone TEXT DEFAULT 'America/Sao_Paulo'"]:
for col in ["timezone TEXT DEFAULT 'America/Sao_Paulo'", "auth_provider TEXT DEFAULT 'local'", "oidc_subject TEXT"]:
try:
c.execute(f"ALTER TABLE users ADD COLUMN {col}")
except sqlite3.OperationalError:
@@ -835,7 +835,8 @@ async def list_users(u=Depends(require("admin"))):
@app.get("/api/users/me")
async def me(u=Depends(current_user)):
return {k: u[k] for k in ("id","first_name","last_name","username","email","role","mfa_enabled","timezone")}
fields = ("id","first_name","last_name","username","email","role","mfa_enabled","timezone","auth_provider")
return {k: u.get(k, "local" if k == "auth_provider" else None) for k in fields}
@app.put("/api/users/{uid}")
async def update_user(uid: str, req: UserUpdateReq, adm=Depends(require("admin"))):

View File

@@ -9,6 +9,7 @@ export interface User {
email?: string;
mfa_enabled?: boolean;
timezone?: string;
auth_provider?: string;
}
export interface LoginResponse {

View File

@@ -755,6 +755,17 @@ const en: Record<string, string> = {
'settings.timezone': 'Timezone',
'settings.tzDesc': 'Set your timezone for date and time display.',
'settings.tzChanged': 'Timezone changed successfully',
'settings.changePassword': 'Change Password',
'settings.currentPw': 'Current Password',
'settings.newPw': 'New Password',
'settings.confirmPw': 'Confirm New Password',
'settings.savePw': 'Change Password',
'settings.saving': 'Saving...',
'settings.pwChanged': 'Password changed successfully',
'settings.pwRequired': 'Fill in all fields',
'settings.pwMismatch': 'Passwords do not match',
'settings.pwMinLength': 'New password must be at least 8 characters',
'settings.federatedInfo': 'Password and MFA are managed by Oracle IAM Identity Domain. Configure directly in the OCI portal.',
'settings.mfa': 'Multi-Factor Authentication (MFA)',
// ── Oracle IAM ──
'iam.title': 'Oracle IAM Identity Domains',

View File

@@ -756,6 +756,17 @@ const pt: Record<string, string> = {
'settings.timezone': 'Timezone',
'settings.tzDesc': 'Define o fuso horário para exibição de datas e horários.',
'settings.tzChanged': 'Timezone alterado com sucesso',
'settings.changePassword': 'Alterar Senha',
'settings.currentPw': 'Senha Atual',
'settings.newPw': 'Nova Senha',
'settings.confirmPw': 'Confirmar Nova Senha',
'settings.savePw': 'Alterar Senha',
'settings.saving': 'Salvando...',
'settings.pwChanged': 'Senha alterada com sucesso',
'settings.pwRequired': 'Preencha todos os campos',
'settings.pwMismatch': 'As senhas não conferem',
'settings.pwMinLength': 'A nova senha deve ter pelo menos 8 caracteres',
'settings.federatedInfo': 'Senha e MFA são gerenciados pelo Oracle IAM Identity Domain. Configure diretamente no portal OCI.',
'settings.mfa': 'Autenticação Multi-Fator (MFA)',
// ── Oracle IAM ──
'iam.title': 'Oracle IAM Identity Domains',

View File

@@ -1,5 +1,8 @@
import { useState, useEffect } from 'react';
import { Settings, Clock, ShieldCheck, ShieldOff, KeyRound, Lock, Loader2, CheckCircle, AlertTriangle, X } from 'lucide-react';
import {
Settings, Clock, ShieldCheck, ShieldOff, KeyRound, Lock, Loader2,
CheckCircle, AlertTriangle, Eye, EyeOff, Save,
} from 'lucide-react';
import client from '@/api/client';
import { adminApi } from '@/api/endpoints/admin';
import { useAuthStore } from '@/stores/auth';
@@ -10,11 +13,20 @@ export default function MySettingsPage() {
const { user: currentUser, checkAuth } = useAuthStore();
const [msg, setMsg] = useState<{ text: string; type: 'success' | 'error' } | null>(null);
const isLocal = !currentUser?.auth_provider || currentUser.auth_provider === 'local';
// Timezone
const [timezone, setTimezone] = useState('');
const [tzOptions, setTzOptions] = useState<string[]>([]);
const [tzSaving, setTzSaving] = useState(false);
// Password
const [currentPw, setCurrentPw] = useState('');
const [newPw, setNewPw] = useState('');
const [confirmPw, setConfirmPw] = useState('');
const [showPw, setShowPw] = useState(false);
const [pwSaving, setPwSaving] = useState(false);
// MFA
const [mfaSecret, setMfaSecret] = useState<string | null>(null);
const [mfaTotpUri, setMfaTotpUri] = useState<string | null>(null);
@@ -27,9 +39,10 @@ export default function MySettingsPage() {
}, []);
useEffect(() => {
if (msg) { const t = setTimeout(() => setMsg(null), 4000); return () => clearTimeout(t); }
if (msg) { const timer = setTimeout(() => setMsg(null), 4000); return () => clearTimeout(timer); }
}, [msg]);
/* ── Timezone ── */
const handleTzChange = async (tz: string) => {
setTzSaving(true);
try {
@@ -43,6 +56,24 @@ export default function MySettingsPage() {
}
};
/* ── Password ── */
const handleChangePw = async () => {
if (!currentPw || !newPw) { setMsg({ text: t('settings.pwRequired') || 'Preencha todos os campos', type: 'error' }); return; }
if (newPw !== confirmPw) { setMsg({ text: t('settings.pwMismatch') || 'As senhas não conferem', type: 'error' }); return; }
if (newPw.length < 8) { setMsg({ text: t('settings.pwMinLength') || 'A nova senha deve ter pelo menos 8 caracteres', type: 'error' }); return; }
setPwSaving(true);
try {
await client.post('/auth/change-password', { current_password: currentPw, new_password: newPw });
setMsg({ text: t('settings.pwChanged') || 'Senha alterada com sucesso', type: 'success' });
setCurrentPw(''); setNewPw(''); setConfirmPw('');
} catch (err) {
setMsg({ text: err instanceof Error ? err.message : 'Erro ao alterar senha', type: 'error' });
} finally {
setPwSaving(false);
}
};
/* ── MFA ── */
const handleMfaSetup = async () => {
setMfaLoading(true);
try {
@@ -104,13 +135,21 @@ export default function MySettingsPage() {
</div>
)}
{/* Header */}
<div className="page-header">
<div className="icon" style={{ background: 'var(--acl)' }}>
<Settings size={24} style={{ color: 'var(--ac)' }} />
</div>
<div>
<h1>{t('settings.title') || 'Minhas Configurações'}</h1>
<div className="subtitle">{currentUser?.username} {currentUser?.role}</div>
<div className="subtitle">
{currentUser?.first_name} {currentUser?.last_name} {currentUser?.username}
{!isLocal && (
<span className="ml-2 px-1.5 py-0.5 rounded text-[.55rem] font-bold" style={{ background: 'var(--rdl)', color: 'var(--rd)' }}>
ORACLE IAM
</span>
)}
</div>
</div>
</div>
@@ -138,79 +177,153 @@ export default function MySettingsPage() {
</select>
</div>
{/* MFA */}
<div className="card">
<div className="card-header">
<div className="icon" style={{ background: mfaOn ? 'var(--gnl)' : 'var(--bg3)' }}>
{mfaOn ? <ShieldCheck size={16} style={{ color: 'var(--gn)' }} /> : <Lock size={16} style={{ color: 'var(--t4)' }} />}
</div>
<h2>{t('settings.mfa') || 'Autenticação Multi-Fator (MFA)'}</h2>
<div className="spacer" />
<span className="inline-flex items-center gap-1 px-2.5 py-1 rounded-lg text-[.7rem] font-semibold"
style={{ background: mfaOn ? 'var(--gnl)' : 'var(--bg3)', color: mfaOn ? 'var(--gn)' : 'var(--t4)' }}>
{mfaOn ? t('usr.active') || 'Ativo' : t('usr.inactive') || 'Inativo'}
</span>
</div>
{mfaOn ? (
<div className="flex flex-col items-center gap-4 py-6">
<div className="w-16 h-16 rounded-full flex items-center justify-center" style={{ background: 'color-mix(in srgb, var(--gn) 10%, transparent)' }}>
<ShieldCheck size={32} style={{ color: 'var(--gn)' }} />
{/* Password Change — only for local users */}
{isLocal && (
<div className="card">
<div className="card-header">
<div className="icon" style={{ background: 'var(--bll)' }}>
<Lock size={16} style={{ color: 'var(--bl)' }} />
</div>
<p className="text-xs text-center" style={{ color: 'var(--t3)' }}>{t('mfa.enabledDesc')}</p>
<button onClick={handleMfaDisable} disabled={mfaLoading}
className="flex items-center gap-1.5 px-4 py-2 rounded-lg text-xs font-semibold cursor-pointer"
style={{ background: 'var(--rdl)', color: 'var(--rd)', border: '1px solid color-mix(in srgb, var(--rd) 25%, transparent)' }}>
{mfaLoading ? <Loader2 size={14} className="animate-spin" /> : <ShieldOff size={14} />}
{t('mfa.disableBtn')}
</button>
<h2>{t('settings.changePassword') || 'Alterar Senha'}</h2>
</div>
) : mfaSecret && mfaTotpUri ? (
<div className="flex flex-col gap-4 py-4">
<div className="flex justify-center">
<div className="p-3 rounded-xl" style={{ background: '#fff' }}>
<img src={`https://api.qrserver.com/v1/create-qr-code/?size=180x180&data=${encodeURIComponent(mfaTotpUri)}`}
alt="QR Code" width={180} height={180} className="rounded" />
<div className="flex flex-col gap-3" style={{ maxWidth: 400 }}>
<div className="form-group">
<label>{t('settings.currentPw') || 'Senha Atual'}</label>
<div className="flex gap-1">
<input
type={showPw ? 'text' : 'password'}
value={currentPw}
onChange={e => setCurrentPw(e.target.value)}
placeholder="••••••••"
className="flex-1"
style={{ fontFamily: 'var(--fm)' }}
/>
<button onClick={() => setShowPw(!showPw)} className="btn btn-secondary btn-sm" style={{ padding: '0 8px' }}>
{showPw ? <EyeOff size={14} /> : <Eye size={14} />}
</button>
</div>
</div>
<div className="px-3 py-2 rounded-lg text-center" style={{ background: 'var(--bg2)', border: '1px solid var(--bd)' }}>
<span className="text-[.6rem] block mb-1" style={{ color: 'var(--t4)' }}>SECRET</span>
<code className="text-xs font-bold tracking-wider select-all" style={{ color: 'var(--ac)', fontFamily: 'var(--fm)' }}>{mfaSecret}</code>
<div className="form-group">
<label>{t('settings.newPw') || 'Nova Senha'}</label>
<input
type={showPw ? 'text' : 'password'}
value={newPw}
onChange={e => setNewPw(e.target.value)}
placeholder="••••••••"
style={{ fontFamily: 'var(--fm)' }}
/>
</div>
<p className="text-xs text-center" style={{ color: 'var(--t3)' }}>
{t('mfa.scanQr') || 'Escaneie o QR code com seu app autenticador e insira o código de 6 dígitos.'}
</p>
<div className="flex gap-2 justify-center">
<input type="text" value={mfaCode} onChange={e => setMfaCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
placeholder="000000" maxLength={6} autoFocus
className="px-3 py-2 rounded-lg text-sm text-center tracking-[.3em] font-semibold outline-none"
style={{ background: 'var(--bg2)', border: '1px solid var(--bd)', color: 'var(--t1)', fontFamily: 'var(--fm)', width: 160 }}
onKeyDown={e => { if (e.key === 'Enter') handleMfaVerify(); }} />
<button onClick={handleMfaVerify} disabled={mfaLoading || mfaCode.length !== 6}
className="flex items-center gap-1.5 px-4 py-2 rounded-lg text-xs font-semibold cursor-pointer disabled:opacity-50"
style={{ background: 'var(--gn)', color: '#fff' }}>
{mfaLoading ? <Loader2 size={14} className="animate-spin" /> : <KeyRound size={14} />}
{t('mfa.activateMfa')}
</button>
<div className="form-group">
<label>{t('settings.confirmPw') || 'Confirmar Nova Senha'}</label>
<input
type={showPw ? 'text' : 'password'}
value={confirmPw}
onChange={e => setConfirmPw(e.target.value)}
placeholder="••••••••"
style={{ fontFamily: 'var(--fm)' }}
onKeyDown={e => { if (e.key === 'Enter') handleChangePw(); }}
/>
</div>
</div>
) : (
<div className="flex flex-col items-center gap-4 py-6">
<div className="w-16 h-16 rounded-full flex items-center justify-center" style={{ background: 'color-mix(in srgb, var(--yl) 10%, transparent)' }}>
<KeyRound size={32} style={{ color: 'var(--yl)' }} />
</div>
<p className="text-xs text-center max-w-sm" style={{ color: 'var(--t3)' }}>
{t('mfa.generateHint')}
</p>
<button onClick={handleMfaSetup} disabled={mfaLoading}
className="flex items-center gap-2 px-4 py-2 rounded-lg text-xs font-semibold cursor-pointer"
style={{ background: 'var(--ac)', color: '#fff' }}>
{mfaLoading ? <Loader2 size={14} className="animate-spin" /> : <KeyRound size={14} />}
{t('mfa.generateSecret')}
<button onClick={handleChangePw} disabled={pwSaving || !currentPw || !newPw || !confirmPw}
className="btn btn-primary self-start disabled:opacity-50">
{pwSaving ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />}
{pwSaving ? (t('settings.saving') || 'Salvando...') : (t('settings.savePw') || 'Alterar Senha')}
</button>
</div>
)}
</div>
</div>
)}
{/* MFA — only for local users */}
{isLocal && (
<div className="card">
<div className="card-header">
<div className="icon" style={{ background: mfaOn ? 'var(--gnl)' : 'var(--bg3)' }}>
{mfaOn ? <ShieldCheck size={16} style={{ color: 'var(--gn)' }} /> : <Lock size={16} style={{ color: 'var(--t4)' }} />}
</div>
<h2>{t('settings.mfa') || 'Autenticação Multi-Fator (MFA)'}</h2>
<div className="spacer" />
<span className="inline-flex items-center gap-1 px-2.5 py-1 rounded-lg text-[.7rem] font-semibold"
style={{ background: mfaOn ? 'var(--gnl)' : 'var(--bg3)', color: mfaOn ? 'var(--gn)' : 'var(--t4)' }}>
{mfaOn ? t('usr.active') || 'Ativo' : t('usr.inactive') || 'Inativo'}
</span>
</div>
{mfaOn ? (
<div className="flex flex-col items-center gap-4 py-6">
<div className="w-16 h-16 rounded-full flex items-center justify-center" style={{ background: 'color-mix(in srgb, var(--gn) 10%, transparent)' }}>
<ShieldCheck size={32} style={{ color: 'var(--gn)' }} />
</div>
<p className="text-xs text-center" style={{ color: 'var(--t3)' }}>{t('mfa.enabledDesc')}</p>
<button onClick={handleMfaDisable} disabled={mfaLoading}
className="flex items-center gap-1.5 px-4 py-2 rounded-lg text-xs font-semibold cursor-pointer"
style={{ background: 'var(--rdl)', color: 'var(--rd)', border: '1px solid color-mix(in srgb, var(--rd) 25%, transparent)' }}>
{mfaLoading ? <Loader2 size={14} className="animate-spin" /> : <ShieldOff size={14} />}
{t('mfa.disableBtn')}
</button>
</div>
) : mfaSecret && mfaTotpUri ? (
<div className="flex flex-col gap-4 py-4">
<div className="flex justify-center">
<div className="p-3 rounded-xl" style={{ background: '#fff' }}>
<img src={`https://api.qrserver.com/v1/create-qr-code/?size=180x180&data=${encodeURIComponent(mfaTotpUri)}`}
alt="QR Code" width={180} height={180} className="rounded" />
</div>
</div>
<div className="px-3 py-2 rounded-lg text-center" style={{ background: 'var(--bg2)', border: '1px solid var(--bd)' }}>
<span className="text-[.6rem] block mb-1" style={{ color: 'var(--t4)' }}>SECRET</span>
<code className="text-xs font-bold tracking-wider select-all" style={{ color: 'var(--ac)', fontFamily: 'var(--fm)' }}>{mfaSecret}</code>
</div>
<p className="text-xs text-center" style={{ color: 'var(--t3)' }}>
{t('mfa.scanQr') || 'Escaneie o QR code com seu app autenticador e insira o código de 6 dígitos.'}
</p>
<div className="flex gap-2 justify-center">
<input type="text" value={mfaCode} onChange={e => setMfaCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
placeholder="000000" maxLength={6} autoFocus
className="px-3 py-2 rounded-lg text-sm text-center tracking-[.3em] font-semibold outline-none"
style={{ background: 'var(--bg2)', border: '1px solid var(--bd)', color: 'var(--t1)', fontFamily: 'var(--fm)', width: 160 }}
onKeyDown={e => { if (e.key === 'Enter') handleMfaVerify(); }} />
<button onClick={handleMfaVerify} disabled={mfaLoading || mfaCode.length !== 6}
className="flex items-center gap-1.5 px-4 py-2 rounded-lg text-xs font-semibold cursor-pointer disabled:opacity-50"
style={{ background: 'var(--gn)', color: '#fff' }}>
{mfaLoading ? <Loader2 size={14} className="animate-spin" /> : <KeyRound size={14} />}
{t('mfa.activateMfa')}
</button>
</div>
</div>
) : (
<div className="flex flex-col items-center gap-4 py-6">
<div className="w-16 h-16 rounded-full flex items-center justify-center" style={{ background: 'color-mix(in srgb, var(--yl) 10%, transparent)' }}>
<KeyRound size={32} style={{ color: 'var(--yl)' }} />
</div>
<p className="text-xs text-center max-w-sm" style={{ color: 'var(--t3)' }}>
{t('mfa.generateHint')}
</p>
<button onClick={handleMfaSetup} disabled={mfaLoading}
className="flex items-center gap-2 px-4 py-2 rounded-lg text-xs font-semibold cursor-pointer"
style={{ background: 'var(--ac)', color: '#fff' }}>
{mfaLoading ? <Loader2 size={14} className="animate-spin" /> : <KeyRound size={14} />}
{t('mfa.generateSecret')}
</button>
</div>
)}
</div>
)}
{/* Info for federated users */}
{!isLocal && (
<div className="card" style={{ opacity: 0.7 }}>
<div className="flex items-center gap-3 py-2">
<ShieldCheck size={18} style={{ color: 'var(--t3)' }} />
<p className="text-xs" style={{ color: 'var(--t3)' }}>
{t('settings.federatedInfo') || 'Senha e MFA são gerenciados pelo Oracle IAM Identity Domain. Configure diretamente no portal OCI.'}
</p>
</div>
</div>
)}
</div>
);
}