feat: User Management menu — My Settings, Oracle IAM page, sidebar restructure

- Sidebar: "User Management" expandable group with sub-items (Users, My Settings, Oracle IAM)
- MySettingsPage: self-service timezone + MFA setup/disable (any user)
- OracleIamPage: admin-only OIDC config (auth mode, issuer, client ID/secret, group→role mapping, test connection)
- UsersPage: removed timezone card (moved to My Settings)
- i18n: 35+ new keys (pt/en) for settings and IAM pages
- Routes: /admin/user-settings, /admin/iam registered with code splitting
This commit is contained in:
nogueiraguh
2026-04-01 15:00:23 -03:00
parent 7b50767159
commit c3035abf81
7 changed files with 593 additions and 48 deletions

View File

@@ -21,6 +21,8 @@ const AdbConfigPage = lazy(() => import('@/pages/config/AdbConfigPage'));
const EmbeddingsPage = lazy(() => import('@/pages/config/EmbeddingsPage'));
const EmbConsultPage = lazy(() => import('@/pages/config/EmbConsultPage'));
const UsersPage = lazy(() => import('@/pages/admin/UsersPage'));
const MySettingsPage = lazy(() => import('@/pages/admin/MySettingsPage'));
const OracleIamPage = lazy(() => import('@/pages/admin/OracleIamPage'));
const AuditPage = lazy(() => import('@/pages/admin/AuditPage'));
function PageLoader() {
@@ -66,6 +68,8 @@ export default function App() {
<Route path="/config/embeddings" element={<EmbeddingsPage />} />
<Route path="/config/embeddings/consult" element={<EmbConsultPage />} />
<Route path="/admin/users" element={<UsersPage />} />
<Route path="/admin/user-settings" element={<MySettingsPage />} />
<Route path="/admin/iam" element={<OracleIamPage />} />
<Route path="/admin/audit" element={<AuditPage />} />
</Route>
<Route path="*" element={<Navigate to="/chat" replace />} />

View File

@@ -3,7 +3,8 @@ import { useState } from 'react';
import {
MessageSquare, Search, BarChart3, Cloud, Shield,
Brain, Plug, Database, Dna, Users, FileText,
Sparkles, Sun, Moon, LogOut, FolderOpen, Languages, Settings, ArrowLeft
Sparkles, Sun, Moon, LogOut, FolderOpen, Languages, Settings, ArrowLeft,
UserCog, ChevronDown, ChevronRight,
} from 'lucide-react';
import { useTheme } from '@/hooks/useTheme';
import { useAuthStore } from '@/stores/auth';
@@ -59,15 +60,22 @@ const ADMIN_ITEMS: NavItem[] = [
{ to: '/config/mcp', icon: <Plug size={SZ} />, labelKey: 'nav.mcp' },
{ to: '/config/adb', icon: <Database size={SZ} />, labelKey: 'nav.adb' },
{ to: '/config/embeddings', icon: <Dna size={SZ} />, labelKey: 'nav.embeddings' },
{ to: '/admin/users', icon: <Users size={SZ} />, labelKey: 'nav.users', adminOnly: true },
// User Management group — rendered separately with expand/collapse
{ to: '/admin/audit', icon: <FileText size={SZ} />, labelKey: 'nav.audit', adminOnly: true },
];
const USER_MGMT_ITEMS: NavItem[] = [
{ to: '/admin/users', icon: <Users size={SZ} />, labelKey: 'nav.users', adminOnly: true },
{ to: '/admin/user-settings', icon: <UserCog size={SZ} />, labelKey: 'nav.mySettings' },
{ to: '/admin/iam', icon: <Shield size={SZ} />, labelKey: 'nav.oracleIam', adminOnly: true },
];
export default function Sidebar() {
const { toggle, isDark } = useTheme();
const { user, logout } = useAuthStore();
const { t, lang, setLang } = useI18n();
const [showSettings, setShowSettings] = useState(false);
const [userMgmtOpen, setUserMgmtOpen] = useState(false);
const linkCls = (sub?: boolean) =>
`flex items-center gap-3 rounded-xl font-medium transition-all duration-200 ${
@@ -131,6 +139,31 @@ export default function Sidebar() {
{t(item.labelKey)}
</NavLink>
))}
{/* User Management group */}
<button
onClick={() => setUserMgmtOpen(!userMgmtOpen)}
className={linkCls()}
style={{ color: 'var(--t2)', background: 'transparent', border: 'none', width: '100%', cursor: 'pointer' }}
onMouseEnter={(e) => { e.currentTarget.style.background = 'var(--bg2)'; }}
onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; }}
>
<Users size={SZ} />
<span className="flex-1 text-left">{t('nav.userMgmt')}</span>
{userMgmtOpen ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
</button>
{userMgmtOpen && USER_MGMT_ITEMS
.filter((item) => !item.adminOnly || user?.role === 'admin')
.map((item) => (
<NavLink
key={item.to}
to={item.to}
className={linkCls(true)}
style={({ isActive }) => linkStyle(isActive)}
>
{item.icon}
{t(item.labelKey)}
</NavLink>
))}
</>
) : (
NAV_ITEMS

View File

@@ -20,7 +20,10 @@ const en: Record<string, string> = {
'nav.adb': 'ADB Vector',
'nav.embeddings': 'Embeddings',
'nav.embConsult': 'Query Embeddings',
'nav.userMgmt': 'User Management',
'nav.users': 'Users',
'nav.mySettings': 'My Settings',
'nav.oracleIam': 'Oracle IAM',
'nav.audit': 'Audit Log',
'sidebar.lightMode': 'Light mode',
'sidebar.darkMode': 'Dark mode',
@@ -747,6 +750,38 @@ const en: Record<string, string> = {
'common.noData': 'No data found',
'common.error': 'Error',
'common.success': 'Success',
// ── My Settings ──
'settings.title': 'My Settings',
'settings.timezone': 'Timezone',
'settings.tzDesc': 'Set your timezone for date and time display.',
'settings.tzChanged': 'Timezone changed successfully',
'settings.mfa': 'Multi-Factor Authentication (MFA)',
// ── Oracle IAM ──
'iam.title': 'Oracle IAM Identity Domains',
'iam.subtitle': 'OIDC integration for corporate authentication',
'iam.authMode': 'Authentication Mode',
'iam.authModeDesc': 'Defines how users authenticate to the system.',
'iam.modeLocal': 'Local',
'iam.modeLocalDesc': 'Local users with password',
'iam.modeOidc': 'Oracle IAM',
'iam.modeOidcDesc': 'SSO only',
'iam.modeBoth': 'Both',
'iam.modeBothDesc': 'Local + Oracle IAM',
'iam.oidcConfig': 'OIDC Configuration',
'iam.testConnection': 'Test Connection',
'iam.issuerUrl': 'Issuer URL (Identity Domain)',
'iam.clientId': 'Client ID',
'iam.clientSecret': 'Client Secret',
'iam.redirectUri': 'Redirect URI',
'iam.groupMapping': 'Group Mapping',
'iam.groupMappingDesc': 'Map OCI Identity Domain groups to system roles.',
'iam.save': 'Save Configuration',
'iam.saving': 'Saving...',
'iam.saved': 'Configuration saved successfully',
'iam.issuerRequired': 'Issuer URL is required',
'iam.testOk': 'Connection OK',
'iam.testInvalid': 'Invalid discovery endpoint response',
'iam.testFail': 'Connection failed',
};
export default en;

View File

@@ -20,7 +20,10 @@ const pt: Record<string, string> = {
'nav.adb': 'ADB Vector',
'nav.embeddings': 'Embeddings',
'nav.embConsult': 'Consultar Embeddings',
'nav.userMgmt': 'Gestão de Usuários',
'nav.users': 'Usuários',
'nav.mySettings': 'Minhas Configurações',
'nav.oracleIam': 'Oracle IAM',
'nav.audit': 'Audit Log',
'sidebar.lightMode': 'Modo claro',
'sidebar.darkMode': 'Modo escuro',
@@ -748,6 +751,38 @@ const pt: Record<string, string> = {
'common.noData': 'Nenhum dado encontrado',
'common.error': 'Erro',
'common.success': 'Sucesso',
// ── My Settings ──
'settings.title': 'Minhas Configurações',
'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.mfa': 'Autenticação Multi-Fator (MFA)',
// ── Oracle IAM ──
'iam.title': 'Oracle IAM Identity Domains',
'iam.subtitle': 'Integração OIDC para autenticação corporativa',
'iam.authMode': 'Modo de Autenticação',
'iam.authModeDesc': 'Define como os usuários se autenticam no sistema.',
'iam.modeLocal': 'Local',
'iam.modeLocalDesc': 'Usuários locais com senha',
'iam.modeOidc': 'Oracle IAM',
'iam.modeOidcDesc': 'Somente SSO corporativo',
'iam.modeBoth': 'Ambos',
'iam.modeBothDesc': 'Local + Oracle IAM',
'iam.oidcConfig': 'Configuração OIDC',
'iam.testConnection': 'Testar Conexão',
'iam.issuerUrl': 'Issuer URL (Identity Domain)',
'iam.clientId': 'Client ID',
'iam.clientSecret': 'Client Secret',
'iam.redirectUri': 'Redirect URI',
'iam.groupMapping': 'Mapeamento de Grupos',
'iam.groupMappingDesc': 'Mapeie os grupos do OCI Identity Domain para as roles do sistema.',
'iam.save': 'Salvar Configuração',
'iam.saving': 'Salvando...',
'iam.saved': 'Configuração salva com sucesso',
'iam.issuerRequired': 'Issuer URL é obrigatório',
'iam.testOk': 'Conexão OK',
'iam.testInvalid': 'Resposta inválida do discovery endpoint',
'iam.testFail': 'Falha na conexão',
};
export default pt;

View File

@@ -0,0 +1,216 @@
import { useState, useEffect } from 'react';
import { Settings, Clock, ShieldCheck, ShieldOff, KeyRound, Lock, Loader2, CheckCircle, AlertTriangle, X } from 'lucide-react';
import client from '@/api/client';
import { adminApi } from '@/api/endpoints/admin';
import { useAuthStore } from '@/stores/auth';
import { useI18n } from '@/i18n';
export default function MySettingsPage() {
const { t } = useI18n();
const { user: currentUser, checkAuth } = useAuthStore();
const [msg, setMsg] = useState<{ text: string; type: 'success' | 'error' } | null>(null);
// Timezone
const [timezone, setTimezone] = useState('');
const [tzOptions, setTzOptions] = useState<string[]>([]);
const [tzSaving, setTzSaving] = useState(false);
// MFA
const [mfaSecret, setMfaSecret] = useState<string | null>(null);
const [mfaTotpUri, setMfaTotpUri] = useState<string | null>(null);
const [mfaCode, setMfaCode] = useState('');
const [mfaLoading, setMfaLoading] = useState(false);
useEffect(() => {
adminApi.getMyTimezone().then((d) => setTimezone(d.timezone)).catch(() => {});
adminApi.getTimezoneOptions().then((d) => setTzOptions(d.timezones)).catch(() => {});
}, []);
useEffect(() => {
if (msg) { const t = setTimeout(() => setMsg(null), 4000); return () => clearTimeout(t); }
}, [msg]);
const handleTzChange = async (tz: string) => {
setTzSaving(true);
try {
await adminApi.setMyTimezone(tz);
setTimezone(tz);
setMsg({ text: t('settings.tzChanged') || `Timezone alterado para ${tz}`, type: 'success' });
} catch (err) {
setMsg({ text: err instanceof Error ? err.message : 'Erro ao alterar timezone', type: 'error' });
} finally {
setTzSaving(false);
}
};
const handleMfaSetup = async () => {
setMfaLoading(true);
try {
const data = await client.post('/mfa/setup') as unknown as { secret: string; uri: string };
setMfaSecret(data.secret);
setMfaTotpUri(data.uri);
} catch (err) {
setMsg({ text: err instanceof Error ? err.message : 'Erro', type: 'error' });
} finally {
setMfaLoading(false);
}
};
const handleMfaVerify = async () => {
if (mfaCode.length !== 6) { setMsg({ text: t('mfa.invalidCode'), type: 'error' }); return; }
setMfaLoading(true);
try {
await client.post('/mfa/verify', { totp_code: mfaCode });
setMsg({ text: t('mfa.activated'), type: 'success' });
setMfaSecret(null); setMfaTotpUri(null); setMfaCode('');
await checkAuth();
} catch (err) {
setMsg({ text: err instanceof Error ? err.message : 'Codigo invalido', type: 'error' });
} finally {
setMfaLoading(false);
}
};
const handleMfaDisable = async () => {
if (!currentUser) return;
if (!window.confirm(t('mfa.confirmDisable'))) return;
setMfaLoading(true);
try {
await client.post(`/mfa/disable/${currentUser.id}`);
setMsg({ text: t('mfa.deactivated'), type: 'success' });
setMfaSecret(null); setMfaTotpUri(null); setMfaCode('');
await checkAuth();
} catch (err) {
setMsg({ text: err instanceof Error ? err.message : 'Erro', type: 'error' });
} finally {
setMfaLoading(false);
}
};
const mfaOn = !!currentUser?.mfa_enabled;
return (
<div className="page">
{msg && (
<div className="fixed top-4 right-4 z-50 flex items-center gap-2 px-4 py-2.5 rounded-xl text-sm font-medium shadow-lg"
style={{
background: msg.type === 'success' ? 'var(--gnl)' : 'var(--rdl)',
color: msg.type === 'success' ? 'var(--gn)' : 'var(--rd)',
border: `1px solid color-mix(in srgb, ${msg.type === 'success' ? 'var(--gn)' : 'var(--rd)'} 30%, transparent)`,
animation: 'fadeIn .3s ease',
}}>
{msg.type === 'success' ? <CheckCircle size={16} /> : <AlertTriangle size={16} />}
{msg.text}
</div>
)}
<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>
</div>
{/* Timezone */}
<div className="card">
<div className="card-header">
<div className="icon" style={{ background: 'var(--acl)' }}>
<Clock size={16} style={{ color: 'var(--ac)' }} />
</div>
<h2>{t('settings.timezone') || 'Timezone'}</h2>
</div>
<p className="text-xs mb-3" style={{ color: 'var(--t3)' }}>
{t('settings.tzDesc') || 'Define o fuso horário para exibição de datas e horários.'}
</p>
<select
value={timezone}
onChange={(e) => handleTzChange(e.target.value)}
disabled={tzSaving}
className="px-3 py-2 rounded-lg text-sm outline-none cursor-pointer"
style={{ background: 'var(--bg2)', border: '1px solid var(--bd)', color: 'var(--t1)', minWidth: 280 }}
>
{tzOptions.map((tz) => (
<option key={tz} value={tz}>{tz}</option>
))}
</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)' }} />
</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>
</div>
);
}

View File

@@ -0,0 +1,267 @@
import { useState, useEffect } from 'react';
import { Shield, Globe, Key, Users, FlaskConical, Loader2, CheckCircle, AlertTriangle, Save, Eye, EyeOff } from 'lucide-react';
import client from '@/api/client';
import { useI18n } from '@/i18n';
interface IamConfig {
auth_mode: string;
oidc_issuer: string;
oidc_client_id: string;
oidc_client_secret: string;
oidc_redirect_uri: string;
oidc_group_admin: string;
oidc_group_user: string;
oidc_group_viewer: string;
}
const DEFAULT_CONFIG: IamConfig = {
auth_mode: 'local',
oidc_issuer: '',
oidc_client_id: '',
oidc_client_secret: '',
oidc_redirect_uri: '',
oidc_group_admin: 'CISAgent_Admins',
oidc_group_user: 'CISAgent_Users',
oidc_group_viewer: 'CISAgent_Viewers',
};
const SETTINGS_KEYS = [
'auth_mode', 'oidc_issuer', 'oidc_client_id', 'oidc_client_secret',
'oidc_redirect_uri', 'oidc_group_admin', 'oidc_group_user', 'oidc_group_viewer',
];
export default function OracleIamPage() {
const { t } = useI18n();
const [config, setConfig] = useState<IamConfig>(DEFAULT_CONFIG);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [testing, setTesting] = useState(false);
const [showSecret, setShowSecret] = useState(false);
const [msg, setMsg] = useState<{ text: string; type: 'success' | 'error' } | null>(null);
useEffect(() => {
const loadSettings = async () => {
try {
const results = await Promise.all(
SETTINGS_KEYS.map(k => client.get(`/settings/${k}`) as unknown as { key: string; value: string })
);
const loaded: any = { ...DEFAULT_CONFIG };
results.forEach(r => { if (r.value) loaded[r.key] = r.value; });
setConfig(loaded);
} catch {
// Settings not found — use defaults
} finally {
setLoading(false);
}
};
loadSettings();
}, []);
useEffect(() => {
if (msg) { const t = setTimeout(() => setMsg(null), 5000); return () => clearTimeout(t); }
}, [msg]);
const handleSave = async () => {
setSaving(true);
try {
await Promise.all(
SETTINGS_KEYS.map(k =>
client.put(`/settings/${k}`, { value: (config as any)[k] || '' })
)
);
setMsg({ text: t('iam.saved') || 'Configuração salva com sucesso', type: 'success' });
} catch (err) {
setMsg({ text: err instanceof Error ? err.message : 'Erro ao salvar', type: 'error' });
} finally {
setSaving(false);
}
};
const handleTest = async () => {
if (!config.oidc_issuer) {
setMsg({ text: t('iam.issuerRequired') || 'Issuer URL é obrigatório', type: 'error' });
return;
}
setTesting(true);
try {
const url = config.oidc_issuer.replace(/\/$/, '') + '/.well-known/openid-configuration';
const resp = await fetch(url);
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const disco = await resp.json();
if (disco.authorization_endpoint && disco.token_endpoint) {
setMsg({ text: t('iam.testOk') || `Conexão OK — ${disco.issuer}`, type: 'success' });
} else {
setMsg({ text: t('iam.testInvalid') || 'Resposta inválida do discovery endpoint', type: 'error' });
}
} catch (err) {
setMsg({ text: `${t('iam.testFail') || 'Falha na conexão'}: ${err instanceof Error ? err.message : 'Erro'}`, type: 'error' });
} finally {
setTesting(false);
}
};
const update = (key: keyof IamConfig, value: string) => setConfig(prev => ({ ...prev, [key]: value }));
if (loading) {
return (
<div className="flex items-center justify-center h-full">
<Loader2 size={28} className="animate-spin" style={{ color: 'var(--ac)' }} />
</div>
);
}
return (
<div className="page">
{msg && (
<div className="fixed top-4 right-4 z-50 flex items-center gap-2 px-4 py-2.5 rounded-xl text-sm font-medium shadow-lg"
style={{
background: msg.type === 'success' ? 'var(--gnl)' : 'var(--rdl)',
color: msg.type === 'success' ? 'var(--gn)' : 'var(--rd)',
border: `1px solid color-mix(in srgb, ${msg.type === 'success' ? 'var(--gn)' : 'var(--rd)'} 30%, transparent)`,
animation: 'fadeIn .3s ease',
}}>
{msg.type === 'success' ? <CheckCircle size={16} /> : <AlertTriangle size={16} />}
{msg.text}
</div>
)}
<div className="page-header">
<div className="icon" style={{ background: 'var(--rdl)' }}>
<Shield size={24} style={{ color: 'var(--rd)' }} />
</div>
<div>
<h1>{t('iam.title') || 'Oracle IAM Identity Domains'}</h1>
<div className="subtitle">{t('iam.subtitle') || 'Integração OIDC para autenticação corporativa'}</div>
</div>
</div>
{/* Auth Mode */}
<div className="card">
<div className="card-header">
<div className="icon" style={{ background: 'var(--acl)' }}>
<Key size={16} style={{ color: 'var(--ac)' }} />
</div>
<h2>{t('iam.authMode') || 'Modo de Autenticação'}</h2>
</div>
<p className="text-xs mb-3" style={{ color: 'var(--t3)' }}>
{t('iam.authModeDesc') || 'Define como os usuários se autenticam no sistema.'}
</p>
<div className="flex gap-2">
{(['local', 'oidc', 'both'] as const).map(mode => {
const selected = config.auth_mode === mode;
const labels: Record<string, { label: string; desc: string }> = {
local: { label: t('iam.modeLocal') || 'Local', desc: t('iam.modeLocalDesc') || 'Usuários locais com senha' },
oidc: { label: t('iam.modeOidc') || 'Oracle IAM', desc: t('iam.modeOidcDesc') || 'Somente SSO corporativo' },
both: { label: t('iam.modeBoth') || 'Ambos', desc: t('iam.modeBothDesc') || 'Local + Oracle IAM' },
};
return (
<button key={mode} onClick={() => update('auth_mode', mode)}
className="flex-1 flex flex-col items-center gap-1 py-3 rounded-lg text-xs font-semibold transition-all"
style={{
background: selected ? 'var(--acl)' : 'var(--bg2)',
border: `1.5px solid ${selected ? 'var(--ac)' : 'var(--bd)'}`,
color: selected ? 'var(--ac)' : 'var(--t3)',
}}>
<span className="text-sm">{labels[mode].label}</span>
<span className="text-[.6rem] font-normal" style={{ color: selected ? 'var(--ac)' : 'var(--t4)' }}>{labels[mode].desc}</span>
</button>
);
})}
</div>
</div>
{/* OIDC Configuration */}
{config.auth_mode !== 'local' && (
<>
<div className="card">
<div className="card-header">
<div className="icon" style={{ background: 'var(--rdl)' }}>
<Globe size={16} style={{ color: 'var(--rd)' }} />
</div>
<h2>{t('iam.oidcConfig') || 'Configuração OIDC'}</h2>
<div className="spacer" />
<button onClick={handleTest} disabled={testing} className="btn btn-secondary btn-sm">
{testing ? <Loader2 size={12} className="animate-spin" /> : <FlaskConical size={12} />}
{t('iam.testConnection') || 'Testar Conexão'}
</button>
</div>
<div className="form-row" style={{ marginBottom: 12 }}>
<div className="form-group" style={{ flex: 2 }}>
<label>{t('iam.issuerUrl') || 'Issuer URL (Identity Domain)'}</label>
<input type="text" value={config.oidc_issuer} onChange={e => update('oidc_issuer', e.target.value)}
placeholder="https://idcs-xxx.identity.oraclecloud.com" style={{ fontFamily: 'var(--fm)' }} />
</div>
</div>
<div className="form-row" style={{ marginBottom: 12 }}>
<div className="form-group">
<label>{t('iam.clientId') || 'Client ID'}</label>
<input type="text" value={config.oidc_client_id} onChange={e => update('oidc_client_id', e.target.value)}
placeholder="ea58b04047fd4d8182a8bad4e3d0aff5" style={{ fontFamily: 'var(--fm)' }} />
</div>
<div className="form-group">
<label>{t('iam.clientSecret') || 'Client Secret'}</label>
<div className="flex gap-1">
<input type={showSecret ? 'text' : 'password'} value={config.oidc_client_secret}
onChange={e => update('oidc_client_secret', e.target.value)}
placeholder="••••••••" className="flex-1" style={{ fontFamily: 'var(--fm)' }} />
<button onClick={() => setShowSecret(!showSecret)} className="btn btn-secondary btn-sm" style={{ padding: '0 8px' }}>
{showSecret ? <EyeOff size={14} /> : <Eye size={14} />}
</button>
</div>
</div>
</div>
<div className="form-row">
<div className="form-group">
<label>{t('iam.redirectUri') || 'Redirect URI'}</label>
<input type="text" value={config.oidc_redirect_uri} onChange={e => update('oidc_redirect_uri', e.target.value)}
placeholder="https://yourapp.com/api/auth/oidc/callback" style={{ fontFamily: 'var(--fm)' }} />
</div>
</div>
</div>
{/* Group Mapping */}
<div className="card">
<div className="card-header">
<div className="icon" style={{ background: 'var(--bll)' }}>
<Users size={16} style={{ color: 'var(--bl)' }} />
</div>
<h2>{t('iam.groupMapping') || 'Mapeamento de Grupos'}</h2>
</div>
<p className="text-xs mb-3" style={{ color: 'var(--t3)' }}>
{t('iam.groupMappingDesc') || 'Mapeie os grupos do OCI Identity Domain para as roles do sistema.'}
</p>
<div className="form-row" style={{ marginBottom: 12 }}>
<div className="form-group">
<label style={{ color: 'var(--rd)' }}>Admin</label>
<input type="text" value={config.oidc_group_admin} onChange={e => update('oidc_group_admin', e.target.value)}
placeholder="CISAgent_Admins" />
</div>
<div className="form-group">
<label style={{ color: 'var(--bl)' }}>User</label>
<input type="text" value={config.oidc_group_user} onChange={e => update('oidc_group_user', e.target.value)}
placeholder="CISAgent_Users" />
</div>
<div className="form-group">
<label style={{ color: 'var(--t3)' }}>Viewer</label>
<input type="text" value={config.oidc_group_viewer} onChange={e => update('oidc_group_viewer', e.target.value)}
placeholder="CISAgent_Viewers" />
</div>
</div>
</div>
</>
)}
{/* Save */}
<div className="flex justify-end gap-2 mt-2">
<button onClick={handleSave} disabled={saving} className="btn btn-primary">
{saving ? <Loader2 size={15} className="animate-spin" /> : <Save size={15} />}
{saving ? (t('iam.saving') || 'Salvando...') : (t('iam.save') || 'Salvar Configuração')}
</button>
</div>
</div>
);
}

View File

@@ -1,7 +1,6 @@
import { useState, useEffect, useCallback } from 'react';
import { Users, Plus, Shield, Eye, User as UserIcon, Trash2, X, AlertTriangle, CheckCircle, Loader2, UserPlus, Lock, ShieldCheck, ShieldOff, KeyRound, Clock } from 'lucide-react';
import { Users, Plus, Shield, Eye, User as UserIcon, Trash2, X, AlertTriangle, CheckCircle, Loader2, UserPlus, Lock, ShieldCheck, ShieldOff, KeyRound } from 'lucide-react';
import client from '@/api/client';
import { adminApi } from '@/api/endpoints/admin';
import { useAuthStore } from '@/stores/auth';
import { useI18n } from '@/i18n';
@@ -38,29 +37,6 @@ export default function UsersPage() {
const [msg, setMsg] = useState<{ text: string; type: 'success' | 'error' } | null>(null);
const [submitting, setSubmitting] = useState(false);
// Timezone
const [timezone, setTimezone] = useState('');
const [tzOptions, setTzOptions] = useState<string[]>([]);
const [tzSaving, setTzSaving] = useState(false);
useEffect(() => {
adminApi.getMyTimezone().then((d) => setTimezone(d.timezone)).catch(() => {});
adminApi.getTimezoneOptions().then((d) => setTzOptions(d.timezones)).catch(() => {});
}, []);
const handleTzChange = async (tz: string) => {
setTzSaving(true);
try {
await adminApi.setMyTimezone(tz);
setTimezone(tz);
setMsg({ text: `Timezone alterado para ${tz}`, type: 'success' });
} catch (err) {
setMsg({ text: err instanceof Error ? err.message : 'Erro ao alterar timezone', type: 'error' });
} finally {
setTzSaving(false);
}
};
// MFA modal state
const [mfaUserId, setMfaUserId] = useState<string | null>(null);
const [mfaSecret, setMfaSecret] = useState<string | null>(null);
@@ -257,27 +233,6 @@ export default function UsersPage() {
</div>
)}
{/* Per-user Timezone */}
<div className="card" style={{ padding: '14px 20px', marginBottom: 16 }}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Clock size={16} style={{ color: 'var(--ac)' }} />
<span className="text-sm font-semibold" style={{ color: 'var(--t1)' }}>{t('usr.myTimezone') || 'Meu Timezone'}</span>
</div>
<select
value={timezone}
onChange={(e) => handleTzChange(e.target.value)}
disabled={tzSaving}
className="px-3 py-1.5 rounded-lg text-xs outline-none cursor-pointer"
style={{ background: 'var(--bg2)', border: '1px solid var(--bd)', color: 'var(--t1)', minWidth: 200 }}
>
{tzOptions.map((tz) => (
<option key={tz} value={tz}>{tz}</option>
))}
</select>
</div>
</div>
{/* Header */}
<div className="page-header">
<div className="icon" style={{ background: 'var(--acl)' }}>