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}")
|
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"))):
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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,79 +177,153 @@ export default function MySettingsPage() {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* MFA */}
|
{/* Password Change — only for local users */}
|
||||||
<div className="card">
|
{isLocal && (
|
||||||
<div className="card-header">
|
<div className="card">
|
||||||
<div className="icon" style={{ background: mfaOn ? 'var(--gnl)' : 'var(--bg3)' }}>
|
<div className="card-header">
|
||||||
{mfaOn ? <ShieldCheck size={16} style={{ color: 'var(--gn)' }} /> : <Lock size={16} style={{ color: 'var(--t4)' }} />}
|
<div className="icon" style={{ background: 'var(--bll)' }}>
|
||||||
</div>
|
<Lock size={16} style={{ color: 'var(--bl)' }} />
|
||||||
<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>
|
</div>
|
||||||
<p className="text-xs text-center" style={{ color: 'var(--t3)' }}>{t('mfa.enabledDesc')}</p>
|
<h2>{t('settings.changePassword') || 'Alterar Senha'}</h2>
|
||||||
<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>
|
</div>
|
||||||
) : mfaSecret && mfaTotpUri ? (
|
|
||||||
<div className="flex flex-col gap-4 py-4">
|
<div className="flex flex-col gap-3" style={{ maxWidth: 400 }}>
|
||||||
<div className="flex justify-center">
|
<div className="form-group">
|
||||||
<div className="p-3 rounded-xl" style={{ background: '#fff' }}>
|
<label>{t('settings.currentPw') || 'Senha Atual'}</label>
|
||||||
<img src={`https://api.qrserver.com/v1/create-qr-code/?size=180x180&data=${encodeURIComponent(mfaTotpUri)}`}
|
<div className="flex gap-1">
|
||||||
alt="QR Code" width={180} height={180} className="rounded" />
|
<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>
|
</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>
|
<div className="form-group">
|
||||||
<code className="text-xs font-bold tracking-wider select-all" style={{ color: 'var(--ac)', fontFamily: 'var(--fm)' }}>{mfaSecret}</code>
|
<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>
|
||||||
<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.'}
|
<div className="form-group">
|
||||||
</p>
|
<label>{t('settings.confirmPw') || 'Confirmar Nova Senha'}</label>
|
||||||
<div className="flex gap-2 justify-center">
|
<input
|
||||||
<input type="text" value={mfaCode} onChange={e => setMfaCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
|
type={showPw ? 'text' : 'password'}
|
||||||
placeholder="000000" maxLength={6} autoFocus
|
value={confirmPw}
|
||||||
className="px-3 py-2 rounded-lg text-sm text-center tracking-[.3em] font-semibold outline-none"
|
onChange={e => setConfirmPw(e.target.value)}
|
||||||
style={{ background: 'var(--bg2)', border: '1px solid var(--bd)', color: 'var(--t1)', fontFamily: 'var(--fm)', width: 160 }}
|
placeholder="••••••••"
|
||||||
onKeyDown={e => { if (e.key === 'Enter') handleMfaVerify(); }} />
|
style={{ fontFamily: 'var(--fm)' }}
|
||||||
<button onClick={handleMfaVerify} disabled={mfaLoading || mfaCode.length !== 6}
|
onKeyDown={e => { if (e.key === 'Enter') handleChangePw(); }}
|
||||||
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>
|
|
||||||
) : (
|
<button onClick={handleChangePw} disabled={pwSaving || !currentPw || !newPw || !confirmPw}
|
||||||
<div className="flex flex-col items-center gap-4 py-6">
|
className="btn btn-primary self-start disabled:opacity-50">
|
||||||
<div className="w-16 h-16 rounded-full flex items-center justify-center" style={{ background: 'color-mix(in srgb, var(--yl) 10%, transparent)' }}>
|
{pwSaving ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />}
|
||||||
<KeyRound size={32} style={{ color: 'var(--yl)' }} />
|
{pwSaving ? (t('settings.saving') || 'Salvando...') : (t('settings.savePw') || 'Alterar Senha')}
|
||||||
</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>
|
</button>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user