From 605c8ad2702f5ef664a10038cf82616f3c599222 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Thu, 9 Apr 2026 18:43:46 +0200 Subject: [PATCH] feat: add CSV export to audit log --- ui/src/pages/Admin/AppConfigDetailPage.tsx | 2 +- ui/src/pages/Admin/AuditLogPage.tsx | 21 ++- ui/src/pages/Admin/EnvironmentsPage.tsx | 4 +- ui/src/pages/Admin/GroupsTab.tsx | 3 +- ui/src/pages/Admin/OidcConfigPage.tsx | 158 +++++++++++++-------- ui/src/pages/Admin/RolesTab.tsx | 3 +- ui/src/pages/Admin/UsersTab.tsx | 4 +- ui/src/pages/AppsTab/AppsTab.tsx | 12 +- 8 files changed, 130 insertions(+), 77 deletions(-) diff --git a/ui/src/pages/Admin/AppConfigDetailPage.tsx b/ui/src/pages/Admin/AppConfigDetailPage.tsx index 0c4d69af..3e4463d4 100644 --- a/ui/src/pages/Admin/AppConfigDetailPage.tsx +++ b/ui/src/pages/Admin/AppConfigDetailPage.tsx @@ -160,7 +160,7 @@ export default function AppConfigDetailPage() { } }, onError: () => { - toast({ title: 'Save failed', description: 'Could not update configuration', variant: 'error', duration: 86_400_000 }); + toast({ title: 'Failed to save configuration', description: 'Could not update configuration', variant: 'error', duration: 86_400_000 }); }, }); } diff --git a/ui/src/pages/Admin/AuditLogPage.tsx b/ui/src/pages/Admin/AuditLogPage.tsx index 2c1ca4f5..1a9aeb2d 100644 --- a/ui/src/pages/Admin/AuditLogPage.tsx +++ b/ui/src/pages/Admin/AuditLogPage.tsx @@ -1,8 +1,9 @@ import { useState, useMemo, useCallback } from 'react'; import { - Badge, DateRangePicker, Input, Select, MonoText, CodeBlock, DataTable, + Badge, Button, DateRangePicker, Input, Select, MonoText, CodeBlock, DataTable, } from '@cameleer/design-system'; import type { Column } from '@cameleer/design-system'; +import { Download } from 'lucide-react'; import { useAuditLog, type AuditEvent } from '../../api/queries/admin/audit'; import styles from './AuditLogPage.module.css'; import tableStyles from '../../styles/table-section.module.css'; @@ -17,6 +18,21 @@ const CATEGORIES = [ { value: 'AGENT', label: 'AGENT' }, ]; +function exportCsv(events: AuditEvent[]) { + const headers = ['Timestamp', 'User', 'Category', 'Action', 'Target', 'Result', 'Details']; + const rows = events.map(e => [ + e.timestamp, e.username, e.category, e.action, e.target, e.result, e.details ?? '', + ]); + const csv = [headers, ...rows].map(r => r.map(c => `"${String(c).replace(/"/g, '""')}"`).join(',')).join('\n'); + const blob = new Blob([csv], { type: 'text/csv' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `cameleer-audit-${new Date().toISOString().slice(0, 16).replace(':', '-')}.csv`; + a.click(); + URL.revokeObjectURL(url); +} + function formatTimestamp(iso: string): string { return new Date(iso).toLocaleString('en-GB', { year: 'numeric', month: '2-digit', day: '2-digit', @@ -126,6 +142,9 @@ export default function AuditLogPage() { {totalCount} events + ; + if (isLoading) return ; return ( <> diff --git a/ui/src/pages/Admin/GroupsTab.tsx b/ui/src/pages/Admin/GroupsTab.tsx index e6a8967d..6a2b9c06 100644 --- a/ui/src/pages/Admin/GroupsTab.tsx +++ b/ui/src/pages/Admin/GroupsTab.tsx @@ -31,6 +31,7 @@ import { useRoles, } from '../../api/queries/admin/rbac'; import type { GroupDetail } from '../../api/queries/admin/rbac'; +import { PageLoader } from '../../components/PageLoader'; import styles from './UserManagement.module.css'; import sectionStyles from '../../styles/section-card.module.css'; @@ -225,7 +226,7 @@ export default function GroupsTab({ highlightId, onHighlightConsumed }: { highli } } - if (groupsLoading) return ; + if (groupsLoading) return ; return ( <> diff --git a/ui/src/pages/Admin/OidcConfigPage.tsx b/ui/src/pages/Admin/OidcConfigPage.tsx index cdfad6d1..1dd316c2 100644 --- a/ui/src/pages/Admin/OidcConfigPage.tsx +++ b/ui/src/pages/Admin/OidcConfigPage.tsx @@ -1,8 +1,9 @@ import { useEffect, useState } from 'react'; import { - Button, Input, Toggle, FormField, SectionHeader, Tag, ConfirmDialog, Alert, + Button, Input, Toggle, FormField, SectionHeader, Tag, ConfirmDialog, } from '@cameleer/design-system'; import { useToast } from '@cameleer/design-system'; +import { PageLoader } from '../../components/PageLoader'; import { adminFetch } from '../../api/queries/admin/admin-api'; import styles from './OidcConfigPage.module.css'; import sectionStyles from '../../styles/section-card.module.css'; @@ -37,11 +38,12 @@ const EMPTY_CONFIG: OidcFormData = { export default function OidcConfigPage() { const [form, setForm] = useState(null); + const [editing, setEditing] = useState(false); + const [formDraft, setFormDraft] = useState(null); const [newRole, setNewRole] = useState(''); const [deleteOpen, setDeleteOpen] = useState(false); const [saving, setSaving] = useState(false); const [testing, setTesting] = useState(false); - const [error, setError] = useState(null); const { toast } = useToast(); useEffect(() => { @@ -62,34 +64,49 @@ export default function OidcConfigPage() { .catch(() => setForm(EMPTY_CONFIG)); }, []); - function update(key: K, value: OidcFormData[K]) { - setForm((prev) => prev ? { ...prev, [key]: value } : prev); + // The display values come from formDraft when editing, form otherwise + const current = editing ? formDraft : form; + + function startEditing() { + setFormDraft(form ? { ...form } : null); + setEditing(true); + } + + function cancelEditing() { + setFormDraft(null); + setEditing(false); + setNewRole(''); + } + + function updateDraft(key: K, value: OidcFormData[K]) { + setFormDraft((prev) => prev ? { ...prev, [key]: value } : prev); } function addRole() { - if (!form) return; + if (!current) return; const role = newRole.trim().toUpperCase(); - if (role && !(form.defaultRoles || []).includes(role)) { - update('defaultRoles', [...(form.defaultRoles || []), role]); + if (role && !(current.defaultRoles || []).includes(role)) { + updateDraft('defaultRoles', [...(current.defaultRoles || []), role]); setNewRole(''); } } function removeRole(role: string) { - if (!form) return; - update('defaultRoles', (form.defaultRoles || []).filter((r) => r !== role)); + if (!current) return; + updateDraft('defaultRoles', (current.defaultRoles || []).filter((r) => r !== role)); } async function handleSave() { - if (!form) return; + if (!formDraft) return; setSaving(true); - setError(null); try { - await adminFetch('/oidc', { method: 'PUT', body: JSON.stringify(form) }); + await adminFetch('/oidc', { method: 'PUT', body: JSON.stringify(formDraft) }); + setForm({ ...formDraft }); + setFormDraft(null); + setEditing(false); toast({ title: 'Settings saved', description: 'OIDC configuration updated successfully.', variant: 'success' }); } catch (e: any) { - setError(e.message); - toast({ title: 'Save failed', description: e.message, variant: 'error', duration: 86_400_000 }); + toast({ title: 'Failed to save OIDC configuration', description: e.message, variant: 'error', duration: 86_400_000 }); } finally { setSaving(false); } @@ -98,12 +115,10 @@ export default function OidcConfigPage() { async function handleTest() { if (!form) return; setTesting(true); - setError(null); try { const result = await adminFetch<{ status: string; authorizationEndpoint?: string }>('/oidc/test', { method: 'POST' }); toast({ title: 'Connection test', description: `OIDC provider responded: ${result.status}`, variant: 'success' }); } catch (e: any) { - setError(e.message); toast({ title: 'Connection test failed', description: e.message, variant: 'error', duration: 86_400_000 }); } finally { setTesting(false); @@ -112,46 +127,53 @@ export default function OidcConfigPage() { async function handleDelete() { setDeleteOpen(false); - setError(null); try { await adminFetch('/oidc', { method: 'DELETE' }); setForm(EMPTY_CONFIG); + setFormDraft(null); + setEditing(false); toast({ title: 'Configuration deleted', description: 'OIDC configuration has been removed.', variant: 'warning' }); } catch (e: any) { - setError(e.message); - toast({ title: 'Delete failed', description: e.message, variant: 'error', duration: 86_400_000 }); + toast({ title: 'Failed to delete OIDC configuration', description: e.message, variant: 'error', duration: 86_400_000 }); } } - if (!form) return null; + if (!form) return ; return (
- - + {editing ? ( + <> + + + + ) : ( + <> + + + + )}
- {error &&
{error}
} -
Behavior
update('enabled', e.target.checked)} + checked={current?.enabled ?? false} + onChange={(e) => updateDraft('enabled', e.target.checked)} + disabled={!editing} />
update('autoSignup', e.target.checked)} + checked={current?.autoSignup ?? true} + onChange={(e) => updateDraft('autoSignup', e.target.checked)} + disabled={!editing} /> Automatically create accounts for new OIDC users
@@ -164,39 +186,44 @@ export default function OidcConfigPage() { id="issuer" type="url" placeholder="https://idp.example.com/realms/my-realm" - value={form.issuerUri} - onChange={(e) => update('issuerUri', e.target.value)} + value={current?.issuerUri ?? ''} + onChange={(e) => updateDraft('issuerUri', e.target.value)} + disabled={!editing} /> update('clientId', e.target.value)} + value={current?.clientId ?? ''} + onChange={(e) => updateDraft('clientId', e.target.value)} + disabled={!editing} /> update('clientSecret', e.target.value)} + value={current?.clientSecret ?? ''} + onChange={(e) => updateDraft('clientSecret', e.target.value)} + disabled={!editing} /> update('audience', e.target.value)} + value={current?.audience ?? ''} + onChange={(e) => updateDraft('audience', e.target.value)} + disabled={!editing} /> update('additionalScopes', e.target.value.split(',').map(s => s.trim()).filter(Boolean))} + value={(current?.additionalScopes || []).join(', ')} + onChange={(e) => updateDraft('additionalScopes', e.target.value.split(',').map(s => s.trim()).filter(Boolean))} + disabled={!editing} />
@@ -206,22 +233,25 @@ export default function OidcConfigPage() { update('rolesClaim', e.target.value)} + value={current?.rolesClaim ?? ''} + onChange={(e) => updateDraft('rolesClaim', e.target.value)} + disabled={!editing} /> update('userIdClaim', e.target.value)} + value={current?.userIdClaim ?? ''} + onChange={(e) => updateDraft('userIdClaim', e.target.value)} + disabled={!editing} /> update('displayNameClaim', e.target.value)} + value={current?.displayNameClaim ?? ''} + onChange={(e) => updateDraft('displayNameClaim', e.target.value)} + disabled={!editing} /> @@ -229,25 +259,27 @@ export default function OidcConfigPage() {
Default Roles
- {(form.defaultRoles || []).map((role) => ( - removeRole(role)} /> + {(current?.defaultRoles || []).map((role) => ( + removeRole(role) : undefined} /> ))} - {(form.defaultRoles || []).length === 0 && ( + {(current?.defaultRoles || []).length === 0 && ( No default roles configured )}
-
- setNewRole(e.target.value)} - onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); addRole(); } }} - className={styles.roleInput} - /> - -
+ {editing && ( +
+ setNewRole(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); addRole(); } }} + className={styles.roleInput} + /> + +
+ )}
diff --git a/ui/src/pages/Admin/RolesTab.tsx b/ui/src/pages/Admin/RolesTab.tsx index e264598d..514551fb 100644 --- a/ui/src/pages/Admin/RolesTab.tsx +++ b/ui/src/pages/Admin/RolesTab.tsx @@ -20,6 +20,7 @@ import { useDeleteRole, } from '../../api/queries/admin/rbac'; import type { RoleDetail } from '../../api/queries/admin/rbac'; +import { PageLoader } from '../../components/PageLoader'; import styles from './UserManagement.module.css'; export default function RolesTab({ highlightId, onHighlightConsumed }: { highlightId?: string | null; onHighlightConsumed?: () => void }) { @@ -115,7 +116,7 @@ export default function RolesTab({ highlightId, onHighlightConsumed }: { highlig ); } - if (isLoading) return ; + if (isLoading) return ; return ( <> diff --git a/ui/src/pages/Admin/UsersTab.tsx b/ui/src/pages/Admin/UsersTab.tsx index 5d4e5f2c..34dbc278 100644 --- a/ui/src/pages/Admin/UsersTab.tsx +++ b/ui/src/pages/Admin/UsersTab.tsx @@ -16,7 +16,6 @@ import { AlertDialog, SplitPane, EntityList, - Spinner, useToast, } from '@cameleer/design-system'; import { @@ -34,6 +33,7 @@ import { } from '../../api/queries/admin/rbac'; import type { UserDetail } from '../../api/queries/admin/rbac'; import { useAuthStore } from '../../auth/auth-store'; +import { PageLoader } from '../../components/PageLoader'; import styles from './UserManagement.module.css'; import sectionStyles from '../../styles/section-card.module.css'; @@ -200,7 +200,7 @@ export default function UsersTab({ highlightId, onHighlightConsumed }: { highlig return user.directGroups.map((g) => g.name).join(', '); } - if (isLoading) return ; + if (isLoading) return ; return ( <> diff --git a/ui/src/pages/AppsTab/AppsTab.tsx b/ui/src/pages/AppsTab/AppsTab.tsx index e03df4be..2b592f08 100644 --- a/ui/src/pages/AppsTab/AppsTab.tsx +++ b/ui/src/pages/AppsTab/AppsTab.tsx @@ -10,7 +10,6 @@ import { MonoText, SectionHeader, Select, - Spinner, StatusDot, Tabs, Toggle, @@ -40,6 +39,7 @@ import type { CatalogApp, CatalogRoute } from '../../api/queries/catalog'; import { DeploymentProgress } from '../../components/DeploymentProgress'; import { timeAgo } from '../../utils/format-utils'; import { applyTracedProcessorUpdate, applyRouteRecordingUpdate } from '../../utils/config-draft-utils'; +import { PageLoader } from '../../components/PageLoader'; import styles from './AppsTab.module.css'; import sectionStyles from '../../styles/section-card.module.css'; import tableStyles from '../../styles/table-section.module.css'; @@ -117,7 +117,7 @@ function AppListView({ selectedEnv, environments }: { selectedEnv: string | unde }, ], [selectedEnv]); - if (isLoading) return ; + if (isLoading) return ; return (
@@ -512,7 +512,7 @@ function AppDetailView({ appId: appSlug, environments, selectedEnv }: { appId: s const envMap = useMemo(() => new Map(environments.map((e) => [e.id, e])), [environments]); const sortedVersions = useMemo(() => [...versions].sort((a, b) => b.version - a.version), [versions]); - if (!app) return ; + if (!app) return ; const env = envMap.get(app.environmentId); @@ -522,7 +522,7 @@ function AppDetailView({ appId: appSlug, environments, selectedEnv }: { appId: s try { const v = await uploadJar.mutateAsync({ appId: appSlug, file }); toast({ title: `Version ${v.version} uploaded`, description: file.name, variant: 'success' }); - } catch { toast({ title: 'Upload failed', variant: 'error', duration: 86_400_000 }); } + } catch { toast({ title: 'Failed to upload JAR', variant: 'error', duration: 86_400_000 }); } if (fileInputRef.current) fileInputRef.current.value = ''; } @@ -530,7 +530,7 @@ function AppDetailView({ appId: appSlug, environments, selectedEnv }: { appId: s try { await createDeployment.mutateAsync({ appId: appSlug, appVersionId: versionId, environmentId }); toast({ title: 'Deployment started', variant: 'success' }); - } catch { toast({ title: 'Deploy failed', variant: 'error', duration: 86_400_000 }); } + } catch { toast({ title: 'Failed to deploy application', variant: 'error', duration: 86_400_000 }); } } function handleStop(deploymentId: string) { @@ -542,7 +542,7 @@ function AppDetailView({ appId: appSlug, environments, selectedEnv }: { appId: s try { await stopDeployment.mutateAsync({ appId: appSlug, deploymentId: stopTarget.id }); toast({ title: 'Deployment stopped', variant: 'warning' }); - } catch { toast({ title: 'Stop failed', variant: 'error', duration: 86_400_000 }); } + } catch { toast({ title: 'Failed to stop deployment', variant: 'error', duration: 86_400_000 }); } setStopTarget(null); }