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:
@@ -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 />} />
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
216
frontend-react/src/pages/admin/MySettingsPage.tsx
Normal file
216
frontend-react/src/pages/admin/MySettingsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
267
frontend-react/src/pages/admin/OracleIamPage.tsx
Normal file
267
frontend-react/src/pages/admin/OracleIamPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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)' }}>
|
||||
|
||||
Reference in New Issue
Block a user