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:
@@ -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"))):
|
||||
|
||||
@@ -9,6 +9,7 @@ export interface User {
|
||||
email?: string;
|
||||
mfa_enabled?: boolean;
|
||||
timezone?: string;
|
||||
auth_provider?: string;
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,7 +177,68 @@ export default function MySettingsPage() {
|
||||
</select>
|
||||
</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-header">
|
||||
<div className="icon" style={{ background: mfaOn ? 'var(--gnl)' : 'var(--bg3)' }}>
|
||||
@@ -211,6 +311,19 @@ export default function MySettingsPage() {
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user