feat: add audit log viewing for vendor and tenant personas
Vendor sees all audit events with tenant filter at /vendor/audit. Tenant admin sees only their own events at /tenant/audit. Both support pagination, action/result filters, and text search. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
242
ui/src/components/AuditLogTable.tsx
Normal file
242
ui/src/components/AuditLogTable.tsx
Normal file
@@ -0,0 +1,242 @@
|
||||
import { useState } from 'react';
|
||||
import { Badge, Button, DataTable, EmptyState, Input, Spinner } from '@cameleer/design-system';
|
||||
import type { Column } from '@cameleer/design-system';
|
||||
import { ScrollText, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import type { AuditLogEntry, AuditLogPage, AuditLogFilters, VendorTenantSummary } from '../types/api';
|
||||
|
||||
const AUDIT_ACTIONS = [
|
||||
'AUTH_REGISTER', 'AUTH_LOGIN', 'AUTH_LOGIN_FAILED', 'AUTH_LOGOUT',
|
||||
'TENANT_CREATE', 'TENANT_UPDATE', 'TENANT_SUSPEND', 'TENANT_REACTIVATE', 'TENANT_DELETE',
|
||||
'ENVIRONMENT_CREATE', 'ENVIRONMENT_UPDATE', 'ENVIRONMENT_DELETE',
|
||||
'APP_CREATE', 'APP_DEPLOY', 'APP_PROMOTE', 'APP_ROLLBACK', 'APP_SCALE', 'APP_STOP', 'APP_DELETE',
|
||||
'SECRET_CREATE', 'SECRET_READ', 'SECRET_UPDATE', 'SECRET_DELETE', 'SECRET_ROTATE',
|
||||
'CONFIG_UPDATE',
|
||||
'TEAM_INVITE', 'TEAM_REMOVE', 'TEAM_ROLE_CHANGE',
|
||||
'LICENSE_GENERATE', 'LICENSE_REVOKE',
|
||||
];
|
||||
|
||||
function actionColor(action: string): 'success' | 'error' | 'warning' | 'auto' | 'primary' {
|
||||
if (action.startsWith('AUTH_')) return 'auto';
|
||||
if (action.startsWith('TENANT_')) return 'primary';
|
||||
if (action.startsWith('TEAM_')) return 'warning';
|
||||
if (action.startsWith('LICENSE_')) return 'success';
|
||||
return 'auto';
|
||||
}
|
||||
|
||||
function formatTime(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleString(undefined, {
|
||||
month: 'short', day: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit', second: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
const selectStyle: React.CSSProperties = {
|
||||
padding: '6px 10px',
|
||||
borderRadius: 6,
|
||||
border: '1px solid var(--border)',
|
||||
background: 'var(--bg-surface)',
|
||||
color: 'var(--text-primary)',
|
||||
fontSize: '0.8125rem',
|
||||
};
|
||||
|
||||
interface AuditLogTableProps {
|
||||
data: AuditLogPage | undefined;
|
||||
isLoading: boolean;
|
||||
filters: AuditLogFilters;
|
||||
onFiltersChange: (filters: AuditLogFilters) => void;
|
||||
showTenantColumn: boolean;
|
||||
showTenantFilter: boolean;
|
||||
tenants?: VendorTenantSummary[];
|
||||
}
|
||||
|
||||
export function AuditLogTable({
|
||||
data, isLoading, filters, onFiltersChange,
|
||||
showTenantColumn, showTenantFilter, tenants,
|
||||
}: AuditLogTableProps) {
|
||||
|
||||
const [searchInput, setSearchInput] = useState(filters.search ?? '');
|
||||
|
||||
const tenantMap = new Map<string, string>();
|
||||
if (tenants) {
|
||||
for (const t of tenants) {
|
||||
tenantMap.set(t.id, t.name);
|
||||
}
|
||||
}
|
||||
|
||||
function setFilter(patch: Partial<AuditLogFilters>) {
|
||||
onFiltersChange({ ...filters, page: 0, ...patch });
|
||||
}
|
||||
|
||||
function handleSearchSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setFilter({ search: searchInput.trim() || undefined });
|
||||
}
|
||||
|
||||
const columns: Column<AuditLogEntry>[] = [
|
||||
{
|
||||
key: 'createdAt',
|
||||
header: 'Time',
|
||||
render: (_v, row) => (
|
||||
<span style={{ fontSize: '0.8125rem', whiteSpace: 'nowrap' }}>
|
||||
{formatTime(row.createdAt)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'actorEmail',
|
||||
header: 'Actor',
|
||||
render: (_v, row) => (
|
||||
<span style={{ fontFamily: 'monospace', fontSize: '0.8125rem' }}>
|
||||
{row.actorEmail ?? '--'}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
...(showTenantColumn ? [{
|
||||
key: 'tenantId' as keyof AuditLogEntry,
|
||||
header: 'Tenant',
|
||||
render: (_v: unknown, row: AuditLogEntry) => (
|
||||
<span style={{ fontSize: '0.8125rem' }}>
|
||||
{row.tenantId ? (tenantMap.get(row.tenantId) ?? row.tenantId.slice(0, 8)) : '--'}
|
||||
</span>
|
||||
),
|
||||
}] : []),
|
||||
{
|
||||
key: 'action',
|
||||
header: 'Action',
|
||||
render: (_v, row) => <Badge label={row.action} color={actionColor(row.action)} />,
|
||||
},
|
||||
{
|
||||
key: 'resource',
|
||||
header: 'Resource',
|
||||
render: (_v, row) => (
|
||||
<span style={{ fontFamily: 'monospace', fontSize: '0.8125rem', maxWidth: 200, overflow: 'hidden', textOverflow: 'ellipsis', display: 'inline-block' }}>
|
||||
{row.resource ?? '--'}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'result',
|
||||
header: 'Result',
|
||||
render: (_v, row) => (
|
||||
<Badge
|
||||
label={row.result}
|
||||
color={row.result === 'SUCCESS' ? 'success' : 'error'}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'sourceIp',
|
||||
header: 'IP',
|
||||
render: (_v, row) => (
|
||||
<span style={{ fontFamily: 'monospace', fontSize: '0.8125rem' }}>
|
||||
{row.sourceIp ?? '--'}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const currentPage = data?.page ?? 0;
|
||||
const totalPages = data?.totalPages ?? 0;
|
||||
const totalElements = data?.totalElements ?? 0;
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
{/* Filter bar */}
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
{showTenantFilter && tenants && (
|
||||
<select
|
||||
value={filters.tenantId ?? ''}
|
||||
onChange={(e) => setFilter({ tenantId: e.target.value || undefined })}
|
||||
style={selectStyle}
|
||||
>
|
||||
<option value="">All Tenants</option>
|
||||
{tenants.map((t) => (
|
||||
<option key={t.id} value={t.id}>{t.name}</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
|
||||
<select
|
||||
value={filters.action ?? ''}
|
||||
onChange={(e) => setFilter({ action: e.target.value || undefined })}
|
||||
style={selectStyle}
|
||||
>
|
||||
<option value="">All Actions</option>
|
||||
{AUDIT_ACTIONS.map((a) => (
|
||||
<option key={a} value={a}>{a}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={filters.result ?? ''}
|
||||
onChange={(e) => setFilter({ result: e.target.value || undefined })}
|
||||
style={selectStyle}
|
||||
>
|
||||
<option value="">All Results</option>
|
||||
<option value="SUCCESS">SUCCESS</option>
|
||||
<option value="FAILURE">FAILURE</option>
|
||||
</select>
|
||||
|
||||
<form onSubmit={handleSearchSubmit} style={{ display: 'flex', gap: 4 }}>
|
||||
<Input
|
||||
placeholder="Search actor or resource..."
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
style={{ width: 200, fontSize: '0.8125rem' }}
|
||||
/>
|
||||
<Button type="submit" variant="secondary">Search</Button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
{isLoading && (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: 48 }}>
|
||||
<Spinner />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && (!data || data.content.length === 0) && (
|
||||
<EmptyState
|
||||
icon={<ScrollText size={32} />}
|
||||
title="No audit events"
|
||||
description="No events match the current filters."
|
||||
/>
|
||||
)}
|
||||
|
||||
{!isLoading && data && data.content.length > 0 && (
|
||||
<>
|
||||
<DataTable columns={columns} data={data.content} />
|
||||
|
||||
{/* Pagination */}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', fontSize: '0.875rem' }}>
|
||||
<span style={{ color: 'var(--text-muted)' }}>
|
||||
{totalElements} event{totalElements !== 1 ? 's' : ''}
|
||||
{totalPages > 1 && ` \u00b7 Page ${currentPage + 1} of ${totalPages}`}
|
||||
</span>
|
||||
{totalPages > 1 && (
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={currentPage === 0}
|
||||
onClick={() => onFiltersChange({ ...filters, page: currentPage - 1 })}
|
||||
>
|
||||
<ChevronLeft size={14} />
|
||||
Prev
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={currentPage >= totalPages - 1}
|
||||
onClick={() => onFiltersChange({ ...filters, page: currentPage + 1 })}
|
||||
>
|
||||
Next
|
||||
<ChevronRight size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
Sidebar,
|
||||
TopBar,
|
||||
} from '@cameleer/design-system';
|
||||
import { LayoutDashboard, ShieldCheck, Server, Users, Settings, KeyRound, Building, Fingerprint } from 'lucide-react';
|
||||
import { LayoutDashboard, ShieldCheck, Server, Users, Settings, KeyRound, Building, Fingerprint, ScrollText } from 'lucide-react';
|
||||
import { useAuth } from '../auth/useAuth';
|
||||
import { useScopes } from '../auth/useScopes';
|
||||
import { useOrgStore } from '../auth/useOrganization';
|
||||
@@ -74,6 +74,13 @@ export function Layout() {
|
||||
>
|
||||
Tenants
|
||||
</div>
|
||||
<div
|
||||
style={{ padding: '6px 12px 6px 36px', fontSize: 13, cursor: 'pointer',
|
||||
color: isActive(location, '/vendor/audit') ? 'var(--text-primary)' : 'var(--text-muted)' }}
|
||||
onClick={() => navigate('/vendor/audit')}
|
||||
>
|
||||
Audit Log
|
||||
</div>
|
||||
<div
|
||||
style={{ padding: '6px 12px 6px 36px', fontSize: 13, cursor: 'pointer', color: 'var(--text-muted)' }}
|
||||
onClick={() => window.open(`${window.location.protocol}//${window.location.hostname}:3002`, '_blank', 'noopener')}
|
||||
@@ -127,6 +134,16 @@ export function Layout() {
|
||||
{null}
|
||||
</Sidebar.Section>
|
||||
|
||||
<Sidebar.Section
|
||||
icon={<ScrollText size={16} />}
|
||||
label="Audit Log"
|
||||
open={false}
|
||||
active={isActive(location, '/tenant/audit')}
|
||||
onToggle={() => navigate('/tenant/audit')}
|
||||
>
|
||||
{null}
|
||||
</Sidebar.Section>
|
||||
|
||||
<Sidebar.Section
|
||||
icon={<Settings size={16} />}
|
||||
label="Settings"
|
||||
|
||||
Reference in New Issue
Block a user