feat: add CSV export to audit log
This commit is contained in:
@@ -160,7 +160,7 @@ export default function AppConfigDetailPage() {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
onError: () => {
|
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 });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { useState, useMemo, useCallback } from 'react';
|
import { useState, useMemo, useCallback } from 'react';
|
||||||
import {
|
import {
|
||||||
Badge, DateRangePicker, Input, Select, MonoText, CodeBlock, DataTable,
|
Badge, Button, DateRangePicker, Input, Select, MonoText, CodeBlock, DataTable,
|
||||||
} from '@cameleer/design-system';
|
} from '@cameleer/design-system';
|
||||||
import type { Column } 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 { useAuditLog, type AuditEvent } from '../../api/queries/admin/audit';
|
||||||
import styles from './AuditLogPage.module.css';
|
import styles from './AuditLogPage.module.css';
|
||||||
import tableStyles from '../../styles/table-section.module.css';
|
import tableStyles from '../../styles/table-section.module.css';
|
||||||
@@ -17,6 +18,21 @@ const CATEGORIES = [
|
|||||||
{ value: 'AGENT', label: 'AGENT' },
|
{ 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 {
|
function formatTimestamp(iso: string): string {
|
||||||
return new Date(iso).toLocaleString('en-GB', {
|
return new Date(iso).toLocaleString('en-GB', {
|
||||||
year: 'numeric', month: '2-digit', day: '2-digit',
|
year: 'numeric', month: '2-digit', day: '2-digit',
|
||||||
@@ -126,6 +142,9 @@ export default function AuditLogPage() {
|
|||||||
{totalCount} events
|
{totalCount} events
|
||||||
</span>
|
</span>
|
||||||
<Badge label="AUTO" color="success" />
|
<Badge label="AUTO" color="success" />
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => exportCsv(data?.items ?? [])}>
|
||||||
|
<Download size={14} /> Export CSV
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<DataTable
|
<DataTable
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import {
|
|||||||
ConfirmDialog,
|
ConfirmDialog,
|
||||||
SplitPane,
|
SplitPane,
|
||||||
EntityList,
|
EntityList,
|
||||||
Spinner,
|
|
||||||
useToast,
|
useToast,
|
||||||
} from '@cameleer/design-system';
|
} from '@cameleer/design-system';
|
||||||
import {
|
import {
|
||||||
@@ -24,6 +23,7 @@ import {
|
|||||||
useUpdateJarRetention,
|
useUpdateJarRetention,
|
||||||
} from '../../api/queries/admin/environments';
|
} from '../../api/queries/admin/environments';
|
||||||
import type { Environment } from '../../api/queries/admin/environments';
|
import type { Environment } from '../../api/queries/admin/environments';
|
||||||
|
import { PageLoader } from '../../components/PageLoader';
|
||||||
import styles from './UserManagement.module.css';
|
import styles from './UserManagement.module.css';
|
||||||
import sectionStyles from '../../styles/section-card.module.css';
|
import sectionStyles from '../../styles/section-card.module.css';
|
||||||
|
|
||||||
@@ -157,7 +157,7 @@ export default function EnvironmentsPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isLoading) return <Spinner size="md" />;
|
if (isLoading) return <PageLoader />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import {
|
|||||||
useRoles,
|
useRoles,
|
||||||
} from '../../api/queries/admin/rbac';
|
} from '../../api/queries/admin/rbac';
|
||||||
import type { GroupDetail } 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 styles from './UserManagement.module.css';
|
||||||
import sectionStyles from '../../styles/section-card.module.css';
|
import sectionStyles from '../../styles/section-card.module.css';
|
||||||
|
|
||||||
@@ -225,7 +226,7 @@ export default function GroupsTab({ highlightId, onHighlightConsumed }: { highli
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (groupsLoading) return <Spinner size="md" />;
|
if (groupsLoading) return <PageLoader />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Button, Input, Toggle, FormField, SectionHeader, Tag, ConfirmDialog, Alert,
|
Button, Input, Toggle, FormField, SectionHeader, Tag, ConfirmDialog,
|
||||||
} from '@cameleer/design-system';
|
} from '@cameleer/design-system';
|
||||||
import { useToast } from '@cameleer/design-system';
|
import { useToast } from '@cameleer/design-system';
|
||||||
|
import { PageLoader } from '../../components/PageLoader';
|
||||||
import { adminFetch } from '../../api/queries/admin/admin-api';
|
import { adminFetch } from '../../api/queries/admin/admin-api';
|
||||||
import styles from './OidcConfigPage.module.css';
|
import styles from './OidcConfigPage.module.css';
|
||||||
import sectionStyles from '../../styles/section-card.module.css';
|
import sectionStyles from '../../styles/section-card.module.css';
|
||||||
@@ -37,11 +38,12 @@ const EMPTY_CONFIG: OidcFormData = {
|
|||||||
|
|
||||||
export default function OidcConfigPage() {
|
export default function OidcConfigPage() {
|
||||||
const [form, setForm] = useState<OidcFormData | null>(null);
|
const [form, setForm] = useState<OidcFormData | null>(null);
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
const [formDraft, setFormDraft] = useState<OidcFormData | null>(null);
|
||||||
const [newRole, setNewRole] = useState('');
|
const [newRole, setNewRole] = useState('');
|
||||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [testing, setTesting] = useState(false);
|
const [testing, setTesting] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -62,34 +64,49 @@ export default function OidcConfigPage() {
|
|||||||
.catch(() => setForm(EMPTY_CONFIG));
|
.catch(() => setForm(EMPTY_CONFIG));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
function update<K extends keyof OidcFormData>(key: K, value: OidcFormData[K]) {
|
// The display values come from formDraft when editing, form otherwise
|
||||||
setForm((prev) => prev ? { ...prev, [key]: value } : prev);
|
const current = editing ? formDraft : form;
|
||||||
|
|
||||||
|
function startEditing() {
|
||||||
|
setFormDraft(form ? { ...form } : null);
|
||||||
|
setEditing(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelEditing() {
|
||||||
|
setFormDraft(null);
|
||||||
|
setEditing(false);
|
||||||
|
setNewRole('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateDraft<K extends keyof OidcFormData>(key: K, value: OidcFormData[K]) {
|
||||||
|
setFormDraft((prev) => prev ? { ...prev, [key]: value } : prev);
|
||||||
}
|
}
|
||||||
|
|
||||||
function addRole() {
|
function addRole() {
|
||||||
if (!form) return;
|
if (!current) return;
|
||||||
const role = newRole.trim().toUpperCase();
|
const role = newRole.trim().toUpperCase();
|
||||||
if (role && !(form.defaultRoles || []).includes(role)) {
|
if (role && !(current.defaultRoles || []).includes(role)) {
|
||||||
update('defaultRoles', [...(form.defaultRoles || []), role]);
|
updateDraft('defaultRoles', [...(current.defaultRoles || []), role]);
|
||||||
setNewRole('');
|
setNewRole('');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeRole(role: string) {
|
function removeRole(role: string) {
|
||||||
if (!form) return;
|
if (!current) return;
|
||||||
update('defaultRoles', (form.defaultRoles || []).filter((r) => r !== role));
|
updateDraft('defaultRoles', (current.defaultRoles || []).filter((r) => r !== role));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSave() {
|
async function handleSave() {
|
||||||
if (!form) return;
|
if (!formDraft) return;
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
setError(null);
|
|
||||||
try {
|
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' });
|
toast({ title: 'Settings saved', description: 'OIDC configuration updated successfully.', variant: 'success' });
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setError(e.message);
|
toast({ title: 'Failed to save OIDC configuration', description: e.message, variant: 'error', duration: 86_400_000 });
|
||||||
toast({ title: 'Save failed', description: e.message, variant: 'error', duration: 86_400_000 });
|
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
@@ -98,12 +115,10 @@ export default function OidcConfigPage() {
|
|||||||
async function handleTest() {
|
async function handleTest() {
|
||||||
if (!form) return;
|
if (!form) return;
|
||||||
setTesting(true);
|
setTesting(true);
|
||||||
setError(null);
|
|
||||||
try {
|
try {
|
||||||
const result = await adminFetch<{ status: string; authorizationEndpoint?: string }>('/oidc/test', { method: 'POST' });
|
const result = await adminFetch<{ status: string; authorizationEndpoint?: string }>('/oidc/test', { method: 'POST' });
|
||||||
toast({ title: 'Connection test', description: `OIDC provider responded: ${result.status}`, variant: 'success' });
|
toast({ title: 'Connection test', description: `OIDC provider responded: ${result.status}`, variant: 'success' });
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setError(e.message);
|
|
||||||
toast({ title: 'Connection test failed', description: e.message, variant: 'error', duration: 86_400_000 });
|
toast({ title: 'Connection test failed', description: e.message, variant: 'error', duration: 86_400_000 });
|
||||||
} finally {
|
} finally {
|
||||||
setTesting(false);
|
setTesting(false);
|
||||||
@@ -112,46 +127,53 @@ export default function OidcConfigPage() {
|
|||||||
|
|
||||||
async function handleDelete() {
|
async function handleDelete() {
|
||||||
setDeleteOpen(false);
|
setDeleteOpen(false);
|
||||||
setError(null);
|
|
||||||
try {
|
try {
|
||||||
await adminFetch('/oidc', { method: 'DELETE' });
|
await adminFetch('/oidc', { method: 'DELETE' });
|
||||||
setForm(EMPTY_CONFIG);
|
setForm(EMPTY_CONFIG);
|
||||||
|
setFormDraft(null);
|
||||||
|
setEditing(false);
|
||||||
toast({ title: 'Configuration deleted', description: 'OIDC configuration has been removed.', variant: 'warning' });
|
toast({ title: 'Configuration deleted', description: 'OIDC configuration has been removed.', variant: 'warning' });
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setError(e.message);
|
toast({ title: 'Failed to delete OIDC configuration', description: e.message, variant: 'error', duration: 86_400_000 });
|
||||||
toast({ title: 'Delete failed', description: e.message, variant: 'error', duration: 86_400_000 });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!form) return null;
|
if (!form) return <PageLoader />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.page}>
|
<div className={styles.page}>
|
||||||
<div className={styles.toolbar}>
|
<div className={styles.toolbar}>
|
||||||
<Button size="sm" variant="secondary" onClick={handleTest} disabled={!form.issuerUri || testing}>
|
{editing ? (
|
||||||
{testing ? 'Testing...' : 'Test Connection'}
|
<>
|
||||||
</Button>
|
<Button size="sm" variant="ghost" onClick={cancelEditing}>Cancel</Button>
|
||||||
<Button size="sm" variant="primary" onClick={handleSave} disabled={saving}>
|
<Button size="sm" variant="primary" onClick={handleSave} loading={saving}>Save</Button>
|
||||||
{saving ? 'Saving...' : 'Save'}
|
</>
|
||||||
</Button>
|
) : (
|
||||||
|
<>
|
||||||
|
<Button size="sm" variant="secondary" onClick={handleTest} disabled={!form.issuerUri || testing}>
|
||||||
|
{testing ? 'Testing...' : 'Test Connection'}
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="secondary" onClick={startEditing}>Edit</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && <div style={{ marginBottom: 16 }}><Alert variant="error">{error}</Alert></div>}
|
|
||||||
|
|
||||||
<section className={sectionStyles.section}>
|
<section className={sectionStyles.section}>
|
||||||
<SectionHeader>Behavior</SectionHeader>
|
<SectionHeader>Behavior</SectionHeader>
|
||||||
<div className={styles.toggleRow}>
|
<div className={styles.toggleRow}>
|
||||||
<Toggle
|
<Toggle
|
||||||
label="Enabled"
|
label="Enabled"
|
||||||
checked={form.enabled}
|
checked={current?.enabled ?? false}
|
||||||
onChange={(e) => update('enabled', e.target.checked)}
|
onChange={(e) => updateDraft('enabled', e.target.checked)}
|
||||||
|
disabled={!editing}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.toggleRow}>
|
<div className={styles.toggleRow}>
|
||||||
<Toggle
|
<Toggle
|
||||||
label="Auto Sign-Up"
|
label="Auto Sign-Up"
|
||||||
checked={form.autoSignup}
|
checked={current?.autoSignup ?? true}
|
||||||
onChange={(e) => update('autoSignup', e.target.checked)}
|
onChange={(e) => updateDraft('autoSignup', e.target.checked)}
|
||||||
|
disabled={!editing}
|
||||||
/>
|
/>
|
||||||
<span className={styles.hint}>Automatically create accounts for new OIDC users</span>
|
<span className={styles.hint}>Automatically create accounts for new OIDC users</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -164,39 +186,44 @@ export default function OidcConfigPage() {
|
|||||||
id="issuer"
|
id="issuer"
|
||||||
type="url"
|
type="url"
|
||||||
placeholder="https://idp.example.com/realms/my-realm"
|
placeholder="https://idp.example.com/realms/my-realm"
|
||||||
value={form.issuerUri}
|
value={current?.issuerUri ?? ''}
|
||||||
onChange={(e) => update('issuerUri', e.target.value)}
|
onChange={(e) => updateDraft('issuerUri', e.target.value)}
|
||||||
|
disabled={!editing}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="Client ID" htmlFor="client-id">
|
<FormField label="Client ID" htmlFor="client-id">
|
||||||
<Input
|
<Input
|
||||||
id="client-id"
|
id="client-id"
|
||||||
value={form.clientId}
|
value={current?.clientId ?? ''}
|
||||||
onChange={(e) => update('clientId', e.target.value)}
|
onChange={(e) => updateDraft('clientId', e.target.value)}
|
||||||
|
disabled={!editing}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="Client Secret" htmlFor="client-secret">
|
<FormField label="Client Secret" htmlFor="client-secret">
|
||||||
<Input
|
<Input
|
||||||
id="client-secret"
|
id="client-secret"
|
||||||
type="password"
|
type="password"
|
||||||
value={form.clientSecret}
|
value={current?.clientSecret ?? ''}
|
||||||
onChange={(e) => update('clientSecret', e.target.value)}
|
onChange={(e) => updateDraft('clientSecret', e.target.value)}
|
||||||
|
disabled={!editing}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="Audience / API Resource" htmlFor="audience" hint="RFC 8707 resource indicator sent in the authorization request">
|
<FormField label="Audience / API Resource" htmlFor="audience" hint="RFC 8707 resource indicator sent in the authorization request">
|
||||||
<Input
|
<Input
|
||||||
id="audience"
|
id="audience"
|
||||||
placeholder="https://api.example.com"
|
placeholder="https://api.example.com"
|
||||||
value={form.audience}
|
value={current?.audience ?? ''}
|
||||||
onChange={(e) => update('audience', e.target.value)}
|
onChange={(e) => updateDraft('audience', e.target.value)}
|
||||||
|
disabled={!editing}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="Additional Scopes" htmlFor="additional-scopes" hint="Extra scopes to request beyond openid email profile (comma-separated)">
|
<FormField label="Additional Scopes" htmlFor="additional-scopes" hint="Extra scopes to request beyond openid email profile (comma-separated)">
|
||||||
<Input
|
<Input
|
||||||
id="additional-scopes"
|
id="additional-scopes"
|
||||||
placeholder="urn:scope:organizations, urn:scope:roles"
|
placeholder="urn:scope:organizations, urn:scope:roles"
|
||||||
value={(form.additionalScopes || []).join(', ')}
|
value={(current?.additionalScopes || []).join(', ')}
|
||||||
onChange={(e) => update('additionalScopes', e.target.value.split(',').map(s => s.trim()).filter(Boolean))}
|
onChange={(e) => updateDraft('additionalScopes', e.target.value.split(',').map(s => s.trim()).filter(Boolean))}
|
||||||
|
disabled={!editing}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
</section>
|
</section>
|
||||||
@@ -206,22 +233,25 @@ export default function OidcConfigPage() {
|
|||||||
<FormField label="Roles Claim" htmlFor="roles-claim" hint="JSON path to roles in the access token or ID token">
|
<FormField label="Roles Claim" htmlFor="roles-claim" hint="JSON path to roles in the access token or ID token">
|
||||||
<Input
|
<Input
|
||||||
id="roles-claim"
|
id="roles-claim"
|
||||||
value={form.rolesClaim}
|
value={current?.rolesClaim ?? ''}
|
||||||
onChange={(e) => update('rolesClaim', e.target.value)}
|
onChange={(e) => updateDraft('rolesClaim', e.target.value)}
|
||||||
|
disabled={!editing}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="User ID Claim" htmlFor="userid-claim" hint="Claim used as unique user identifier (default: sub)">
|
<FormField label="User ID Claim" htmlFor="userid-claim" hint="Claim used as unique user identifier (default: sub)">
|
||||||
<Input
|
<Input
|
||||||
id="userid-claim"
|
id="userid-claim"
|
||||||
value={form.userIdClaim}
|
value={current?.userIdClaim ?? ''}
|
||||||
onChange={(e) => update('userIdClaim', e.target.value)}
|
onChange={(e) => updateDraft('userIdClaim', e.target.value)}
|
||||||
|
disabled={!editing}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="Display Name Claim" htmlFor="name-claim" hint="Claim used for user display name">
|
<FormField label="Display Name Claim" htmlFor="name-claim" hint="Claim used for user display name">
|
||||||
<Input
|
<Input
|
||||||
id="name-claim"
|
id="name-claim"
|
||||||
value={form.displayNameClaim}
|
value={current?.displayNameClaim ?? ''}
|
||||||
onChange={(e) => update('displayNameClaim', e.target.value)}
|
onChange={(e) => updateDraft('displayNameClaim', e.target.value)}
|
||||||
|
disabled={!editing}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
</section>
|
</section>
|
||||||
@@ -229,25 +259,27 @@ export default function OidcConfigPage() {
|
|||||||
<section className={sectionStyles.section}>
|
<section className={sectionStyles.section}>
|
||||||
<SectionHeader>Default Roles</SectionHeader>
|
<SectionHeader>Default Roles</SectionHeader>
|
||||||
<div className={styles.tagList}>
|
<div className={styles.tagList}>
|
||||||
{(form.defaultRoles || []).map((role) => (
|
{(current?.defaultRoles || []).map((role) => (
|
||||||
<Tag key={role} label={role} color="primary" onRemove={() => removeRole(role)} />
|
<Tag key={role} label={role} color="primary" onRemove={editing ? () => removeRole(role) : undefined} />
|
||||||
))}
|
))}
|
||||||
{(form.defaultRoles || []).length === 0 && (
|
{(current?.defaultRoles || []).length === 0 && (
|
||||||
<span className={styles.noRoles}>No default roles configured</span>
|
<span className={styles.noRoles}>No default roles configured</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.addRoleRow}>
|
{editing && (
|
||||||
<Input
|
<div className={styles.addRoleRow}>
|
||||||
placeholder="Add role..."
|
<Input
|
||||||
value={newRole}
|
placeholder="Add role..."
|
||||||
onChange={(e) => setNewRole(e.target.value)}
|
value={newRole}
|
||||||
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); addRole(); } }}
|
onChange={(e) => setNewRole(e.target.value)}
|
||||||
className={styles.roleInput}
|
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); addRole(); } }}
|
||||||
/>
|
className={styles.roleInput}
|
||||||
<Button size="sm" variant="secondary" onClick={addRole} disabled={!newRole.trim()}>
|
/>
|
||||||
Add
|
<Button size="sm" variant="secondary" onClick={addRole} disabled={!newRole.trim()}>
|
||||||
</Button>
|
Add
|
||||||
</div>
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className={sectionStyles.section}>
|
<section className={sectionStyles.section}>
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
useDeleteRole,
|
useDeleteRole,
|
||||||
} from '../../api/queries/admin/rbac';
|
} from '../../api/queries/admin/rbac';
|
||||||
import type { RoleDetail } from '../../api/queries/admin/rbac';
|
import type { RoleDetail } from '../../api/queries/admin/rbac';
|
||||||
|
import { PageLoader } from '../../components/PageLoader';
|
||||||
import styles from './UserManagement.module.css';
|
import styles from './UserManagement.module.css';
|
||||||
|
|
||||||
export default function RolesTab({ highlightId, onHighlightConsumed }: { highlightId?: string | null; onHighlightConsumed?: () => void }) {
|
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 <Spinner size="md" />;
|
if (isLoading) return <PageLoader />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import {
|
|||||||
AlertDialog,
|
AlertDialog,
|
||||||
SplitPane,
|
SplitPane,
|
||||||
EntityList,
|
EntityList,
|
||||||
Spinner,
|
|
||||||
useToast,
|
useToast,
|
||||||
} from '@cameleer/design-system';
|
} from '@cameleer/design-system';
|
||||||
import {
|
import {
|
||||||
@@ -34,6 +33,7 @@ import {
|
|||||||
} from '../../api/queries/admin/rbac';
|
} from '../../api/queries/admin/rbac';
|
||||||
import type { UserDetail } from '../../api/queries/admin/rbac';
|
import type { UserDetail } from '../../api/queries/admin/rbac';
|
||||||
import { useAuthStore } from '../../auth/auth-store';
|
import { useAuthStore } from '../../auth/auth-store';
|
||||||
|
import { PageLoader } from '../../components/PageLoader';
|
||||||
import styles from './UserManagement.module.css';
|
import styles from './UserManagement.module.css';
|
||||||
import sectionStyles from '../../styles/section-card.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(', ');
|
return user.directGroups.map((g) => g.name).join(', ');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isLoading) return <Spinner size="md" />;
|
if (isLoading) return <PageLoader />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import {
|
|||||||
MonoText,
|
MonoText,
|
||||||
SectionHeader,
|
SectionHeader,
|
||||||
Select,
|
Select,
|
||||||
Spinner,
|
|
||||||
StatusDot,
|
StatusDot,
|
||||||
Tabs,
|
Tabs,
|
||||||
Toggle,
|
Toggle,
|
||||||
@@ -40,6 +39,7 @@ import type { CatalogApp, CatalogRoute } from '../../api/queries/catalog';
|
|||||||
import { DeploymentProgress } from '../../components/DeploymentProgress';
|
import { DeploymentProgress } from '../../components/DeploymentProgress';
|
||||||
import { timeAgo } from '../../utils/format-utils';
|
import { timeAgo } from '../../utils/format-utils';
|
||||||
import { applyTracedProcessorUpdate, applyRouteRecordingUpdate } from '../../utils/config-draft-utils';
|
import { applyTracedProcessorUpdate, applyRouteRecordingUpdate } from '../../utils/config-draft-utils';
|
||||||
|
import { PageLoader } from '../../components/PageLoader';
|
||||||
import styles from './AppsTab.module.css';
|
import styles from './AppsTab.module.css';
|
||||||
import sectionStyles from '../../styles/section-card.module.css';
|
import sectionStyles from '../../styles/section-card.module.css';
|
||||||
import tableStyles from '../../styles/table-section.module.css';
|
import tableStyles from '../../styles/table-section.module.css';
|
||||||
@@ -117,7 +117,7 @@ function AppListView({ selectedEnv, environments }: { selectedEnv: string | unde
|
|||||||
},
|
},
|
||||||
], [selectedEnv]);
|
], [selectedEnv]);
|
||||||
|
|
||||||
if (isLoading) return <Spinner size="md" />;
|
if (isLoading) return <PageLoader />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
@@ -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 envMap = useMemo(() => new Map(environments.map((e) => [e.id, e])), [environments]);
|
||||||
const sortedVersions = useMemo(() => [...versions].sort((a, b) => b.version - a.version), [versions]);
|
const sortedVersions = useMemo(() => [...versions].sort((a, b) => b.version - a.version), [versions]);
|
||||||
|
|
||||||
if (!app) return <Spinner size="md" />;
|
if (!app) return <PageLoader />;
|
||||||
|
|
||||||
const env = envMap.get(app.environmentId);
|
const env = envMap.get(app.environmentId);
|
||||||
|
|
||||||
@@ -522,7 +522,7 @@ function AppDetailView({ appId: appSlug, environments, selectedEnv }: { appId: s
|
|||||||
try {
|
try {
|
||||||
const v = await uploadJar.mutateAsync({ appId: appSlug, file });
|
const v = await uploadJar.mutateAsync({ appId: appSlug, file });
|
||||||
toast({ title: `Version ${v.version} uploaded`, description: file.name, variant: 'success' });
|
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 = '';
|
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -530,7 +530,7 @@ function AppDetailView({ appId: appSlug, environments, selectedEnv }: { appId: s
|
|||||||
try {
|
try {
|
||||||
await createDeployment.mutateAsync({ appId: appSlug, appVersionId: versionId, environmentId });
|
await createDeployment.mutateAsync({ appId: appSlug, appVersionId: versionId, environmentId });
|
||||||
toast({ title: 'Deployment started', variant: 'success' });
|
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) {
|
function handleStop(deploymentId: string) {
|
||||||
@@ -542,7 +542,7 @@ function AppDetailView({ appId: appSlug, environments, selectedEnv }: { appId: s
|
|||||||
try {
|
try {
|
||||||
await stopDeployment.mutateAsync({ appId: appSlug, deploymentId: stopTarget.id });
|
await stopDeployment.mutateAsync({ appId: appSlug, deploymentId: stopTarget.id });
|
||||||
toast({ title: 'Deployment stopped', variant: 'warning' });
|
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);
|
setStopTarget(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user