diff --git a/frontend-react/src/App.tsx b/frontend-react/src/App.tsx index cda1f85..42d1e77 100644 --- a/frontend-react/src/App.tsx +++ b/frontend-react/src/App.tsx @@ -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() { } /> } /> } /> + } /> + } /> } /> } /> diff --git a/frontend-react/src/components/layout/Sidebar.tsx b/frontend-react/src/components/layout/Sidebar.tsx index fc31d06..9d8af17 100644 --- a/frontend-react/src/components/layout/Sidebar.tsx +++ b/frontend-react/src/components/layout/Sidebar.tsx @@ -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: , labelKey: 'nav.mcp' }, { to: '/config/adb', icon: , labelKey: 'nav.adb' }, { to: '/config/embeddings', icon: , labelKey: 'nav.embeddings' }, - { to: '/admin/users', icon: , labelKey: 'nav.users', adminOnly: true }, + // User Management group — rendered separately with expand/collapse { to: '/admin/audit', icon: , labelKey: 'nav.audit', adminOnly: true }, ]; +const USER_MGMT_ITEMS: NavItem[] = [ + { to: '/admin/users', icon: , labelKey: 'nav.users', adminOnly: true }, + { to: '/admin/user-settings', icon: , labelKey: 'nav.mySettings' }, + { to: '/admin/iam', icon: , 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)} ))} + {/* User Management group */} + + {userMgmtOpen && USER_MGMT_ITEMS + .filter((item) => !item.adminOnly || user?.role === 'admin') + .map((item) => ( + linkStyle(isActive)} + > + {item.icon} + {t(item.labelKey)} + + ))} ) : ( NAV_ITEMS diff --git a/frontend-react/src/i18n/en.ts b/frontend-react/src/i18n/en.ts index a6980e3..5f95f32 100644 --- a/frontend-react/src/i18n/en.ts +++ b/frontend-react/src/i18n/en.ts @@ -20,7 +20,10 @@ const en: Record = { '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 = { '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; diff --git a/frontend-react/src/i18n/pt.ts b/frontend-react/src/i18n/pt.ts index 5b0dc97..dea4ba0 100644 --- a/frontend-react/src/i18n/pt.ts +++ b/frontend-react/src/i18n/pt.ts @@ -20,7 +20,10 @@ const pt: Record = { '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 = { '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; diff --git a/frontend-react/src/pages/admin/MySettingsPage.tsx b/frontend-react/src/pages/admin/MySettingsPage.tsx new file mode 100644 index 0000000..2832e8d --- /dev/null +++ b/frontend-react/src/pages/admin/MySettingsPage.tsx @@ -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([]); + const [tzSaving, setTzSaving] = useState(false); + + // MFA + const [mfaSecret, setMfaSecret] = useState(null); + const [mfaTotpUri, setMfaTotpUri] = useState(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 ( +
+ {msg && ( +
+ {msg.type === 'success' ? : } + {msg.text} +
+ )} + +
+
+ +
+
+

{t('settings.title') || 'Minhas Configurações'}

+
{currentUser?.username} — {currentUser?.role}
+
+
+ + {/* Timezone */} +
+
+
+ +
+

{t('settings.timezone') || 'Timezone'}

+
+

+ {t('settings.tzDesc') || 'Define o fuso horário para exibição de datas e horários.'} +

+ +
+ + {/* MFA */} +
+
+
+ {mfaOn ? : } +
+

{t('settings.mfa') || 'Autenticação Multi-Fator (MFA)'}

+
+ + {mfaOn ? t('usr.active') || 'Ativo' : t('usr.inactive') || 'Inativo'} + +
+ + {mfaOn ? ( +
+
+ +
+

{t('mfa.enabledDesc')}

+ +
+ ) : mfaSecret && mfaTotpUri ? ( +
+
+
+ QR Code +
+
+
+ SECRET + {mfaSecret} +
+

+ {t('mfa.scanQr') || 'Escaneie o QR code com seu app autenticador e insira o código de 6 dígitos.'} +

+
+ 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(); }} /> + +
+
+ ) : ( +
+
+ +
+

+ {t('mfa.generateHint')} +

+ +
+ )} +
+
+ ); +} diff --git a/frontend-react/src/pages/admin/OracleIamPage.tsx b/frontend-react/src/pages/admin/OracleIamPage.tsx new file mode 100644 index 0000000..c58b63c --- /dev/null +++ b/frontend-react/src/pages/admin/OracleIamPage.tsx @@ -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(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 ( +
+ +
+ ); + } + + return ( +
+ {msg && ( +
+ {msg.type === 'success' ? : } + {msg.text} +
+ )} + +
+
+ +
+
+

{t('iam.title') || 'Oracle IAM Identity Domains'}

+
{t('iam.subtitle') || 'Integração OIDC para autenticação corporativa'}
+
+
+ + {/* Auth Mode */} +
+
+
+ +
+

{t('iam.authMode') || 'Modo de Autenticação'}

+
+

+ {t('iam.authModeDesc') || 'Define como os usuários se autenticam no sistema.'} +

+
+ {(['local', 'oidc', 'both'] as const).map(mode => { + const selected = config.auth_mode === mode; + const labels: Record = { + 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 ( + + ); + })} +
+
+ + {/* OIDC Configuration */} + {config.auth_mode !== 'local' && ( + <> +
+
+
+ +
+

{t('iam.oidcConfig') || 'Configuração OIDC'}

+
+ +
+ +
+
+ + update('oidc_issuer', e.target.value)} + placeholder="https://idcs-xxx.identity.oraclecloud.com" style={{ fontFamily: 'var(--fm)' }} /> +
+
+ +
+
+ + update('oidc_client_id', e.target.value)} + placeholder="ea58b04047fd4d8182a8bad4e3d0aff5" style={{ fontFamily: 'var(--fm)' }} /> +
+
+ +
+ update('oidc_client_secret', e.target.value)} + placeholder="••••••••" className="flex-1" style={{ fontFamily: 'var(--fm)' }} /> + +
+
+
+ +
+
+ + update('oidc_redirect_uri', e.target.value)} + placeholder="https://yourapp.com/api/auth/oidc/callback" style={{ fontFamily: 'var(--fm)' }} /> +
+
+
+ + {/* Group Mapping */} +
+
+
+ +
+

{t('iam.groupMapping') || 'Mapeamento de Grupos'}

+
+

+ {t('iam.groupMappingDesc') || 'Mapeie os grupos do OCI Identity Domain para as roles do sistema.'} +

+ +
+
+ + update('oidc_group_admin', e.target.value)} + placeholder="CISAgent_Admins" /> +
+
+ + update('oidc_group_user', e.target.value)} + placeholder="CISAgent_Users" /> +
+
+ + update('oidc_group_viewer', e.target.value)} + placeholder="CISAgent_Viewers" /> +
+
+
+ + )} + + {/* Save */} +
+ +
+
+ ); +} diff --git a/frontend-react/src/pages/admin/UsersPage.tsx b/frontend-react/src/pages/admin/UsersPage.tsx index 758b8ab..b173e0d 100644 --- a/frontend-react/src/pages/admin/UsersPage.tsx +++ b/frontend-react/src/pages/admin/UsersPage.tsx @@ -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([]); - 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(null); const [mfaSecret, setMfaSecret] = useState(null); @@ -257,27 +233,6 @@ export default function UsersPage() {
)} - {/* Per-user Timezone */} -
-
-
- - {t('usr.myTimezone') || 'Meu Timezone'} -
- -
-
- {/* Header */}