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}") c.execute(f"ALTER TABLE oci_configs ADD COLUMN {col}")
except sqlite3.OperationalError: except sqlite3.OperationalError:
pass 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: try:
c.execute(f"ALTER TABLE users ADD COLUMN {col}") c.execute(f"ALTER TABLE users ADD COLUMN {col}")
except sqlite3.OperationalError: except sqlite3.OperationalError:
@@ -835,7 +835,8 @@ async def list_users(u=Depends(require("admin"))):
@app.get("/api/users/me") @app.get("/api/users/me")
async def me(u=Depends(current_user)): 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}") @app.put("/api/users/{uid}")
async def update_user(uid: str, req: UserUpdateReq, adm=Depends(require("admin"))): async def update_user(uid: str, req: UserUpdateReq, adm=Depends(require("admin"))):

View File

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

View File

@@ -755,6 +755,17 @@ const en: Record<string, string> = {
'settings.timezone': 'Timezone', 'settings.timezone': 'Timezone',
'settings.tzDesc': 'Set your timezone for date and time display.', 'settings.tzDesc': 'Set your timezone for date and time display.',
'settings.tzChanged': 'Timezone changed successfully', '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)', 'settings.mfa': 'Multi-Factor Authentication (MFA)',
// ── Oracle IAM ── // ── Oracle IAM ──
'iam.title': 'Oracle IAM Identity Domains', 'iam.title': 'Oracle IAM Identity Domains',

View File

@@ -756,6 +756,17 @@ const pt: Record<string, string> = {
'settings.timezone': 'Timezone', 'settings.timezone': 'Timezone',
'settings.tzDesc': 'Define o fuso horário para exibição de datas e horários.', 'settings.tzDesc': 'Define o fuso horário para exibição de datas e horários.',
'settings.tzChanged': 'Timezone alterado com sucesso', '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)', 'settings.mfa': 'Autenticação Multi-Fator (MFA)',
// ── Oracle IAM ── // ── Oracle IAM ──
'iam.title': 'Oracle IAM Identity Domains', 'iam.title': 'Oracle IAM Identity Domains',

View File

@@ -1,5 +1,8 @@
import { useState, useEffect } from 'react'; 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 client from '@/api/client';
import { adminApi } from '@/api/endpoints/admin'; import { adminApi } from '@/api/endpoints/admin';
import { useAuthStore } from '@/stores/auth'; import { useAuthStore } from '@/stores/auth';
@@ -10,11 +13,20 @@ export default function MySettingsPage() {
const { user: currentUser, checkAuth } = useAuthStore(); const { user: currentUser, checkAuth } = useAuthStore();
const [msg, setMsg] = useState<{ text: string; type: 'success' | 'error' } | null>(null); const [msg, setMsg] = useState<{ text: string; type: 'success' | 'error' } | null>(null);
const isLocal = !currentUser?.auth_provider || currentUser.auth_provider === 'local';
// Timezone // Timezone
const [timezone, setTimezone] = useState(''); const [timezone, setTimezone] = useState('');
const [tzOptions, setTzOptions] = useState<string[]>([]); const [tzOptions, setTzOptions] = useState<string[]>([]);
const [tzSaving, setTzSaving] = useState(false); 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 // MFA
const [mfaSecret, setMfaSecret] = useState<string | null>(null); const [mfaSecret, setMfaSecret] = useState<string | null>(null);
const [mfaTotpUri, setMfaTotpUri] = useState<string | null>(null); const [mfaTotpUri, setMfaTotpUri] = useState<string | null>(null);
@@ -27,9 +39,10 @@ export default function MySettingsPage() {
}, []); }, []);
useEffect(() => { useEffect(() => {
if (msg) { const t = setTimeout(() => setMsg(null), 4000); return () => clearTimeout(t); } if (msg) { const timer = setTimeout(() => setMsg(null), 4000); return () => clearTimeout(timer); }
}, [msg]); }, [msg]);
/* ── Timezone ── */
const handleTzChange = async (tz: string) => { const handleTzChange = async (tz: string) => {
setTzSaving(true); setTzSaving(true);
try { 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 () => { const handleMfaSetup = async () => {
setMfaLoading(true); setMfaLoading(true);
try { try {
@@ -104,13 +135,21 @@ export default function MySettingsPage() {
</div> </div>
)} )}
{/* Header */}
<div className="page-header"> <div className="page-header">
<div className="icon" style={{ background: 'var(--acl)' }}> <div className="icon" style={{ background: 'var(--acl)' }}>
<Settings size={24} style={{ color: 'var(--ac)' }} /> <Settings size={24} style={{ color: 'var(--ac)' }} />
</div> </div>
<div> <div>
<h1>{t('settings.title') || 'Minhas Configurações'}</h1> <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>
</div> </div>
@@ -138,7 +177,68 @@ export default function MySettingsPage() {
</select> </select>
</div> </div>
{/* MFA */} {/* 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>
<h2>{t('settings.changePassword') || 'Alterar Senha'}</h2>
</div>
<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="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>
<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>
<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>
)}
{/* MFA — only for local users */}
{isLocal && (
<div className="card"> <div className="card">
<div className="card-header"> <div className="card-header">
<div className="icon" style={{ background: mfaOn ? 'var(--gnl)' : 'var(--bg3)' }}> <div className="icon" style={{ background: mfaOn ? 'var(--gnl)' : 'var(--bg3)' }}>
@@ -211,6 +311,19 @@ export default function MySettingsPage() {
</div> </div>
)} )}
</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> </div>
); );
} }