|
|
|
|
@@ -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>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|