feat: add audit log viewing for vendor and tenant personas
All checks were successful
CI / build (push) Successful in 52s
CI / docker (push) Successful in 40s

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:
hsiegeln
2026-04-10 13:07:18 +02:00
parent 1750fe64a2
commit 8b94937d38
13 changed files with 557 additions and 3 deletions

View File

@@ -1,6 +1,6 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { api } from './client';
import type { DashboardData, TenantLicenseData, TenantSettings } from '../types/api';
import type { DashboardData, TenantLicenseData, TenantSettings, AuditLogPage, AuditLogFilters } from '../types/api';
export function useTenantDashboard() {
return useQuery<DashboardData>({
@@ -61,3 +61,19 @@ export function useTenantSettings() {
queryFn: () => api.get('/tenant/settings'),
});
}
export function useTenantAuditLog(filters: Omit<AuditLogFilters, 'tenantId'>) {
const params = new URLSearchParams();
if (filters.action) params.set('action', filters.action);
if (filters.result) params.set('result', filters.result);
if (filters.search) params.set('search', filters.search);
if (filters.from) params.set('from', filters.from);
if (filters.to) params.set('to', filters.to);
params.set('page', String(filters.page ?? 0));
params.set('size', String(filters.size ?? 25));
return useQuery<AuditLogPage>({
queryKey: ['tenant', 'audit', filters],
queryFn: () => api.get(`/tenant/audit?${params.toString()}`),
});
}

View File

@@ -1,6 +1,6 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { api } from './client';
import type { VendorTenantSummary, VendorTenantDetail, CreateTenantRequest, TenantResponse, LicenseResponse } from '../types/api';
import type { VendorTenantSummary, VendorTenantDetail, CreateTenantRequest, TenantResponse, LicenseResponse, AuditLogPage, AuditLogFilters } from '../types/api';
export function useVendorTenants() {
return useQuery<VendorTenantSummary[]>({
@@ -57,3 +57,20 @@ export function useRenewLicense() {
onSuccess: (_, tenantId) => qc.invalidateQueries({ queryKey: ['vendor', 'tenants', tenantId] }),
});
}
export function useVendorAuditLog(filters: AuditLogFilters) {
const params = new URLSearchParams();
if (filters.tenantId) params.set('tenantId', filters.tenantId);
if (filters.action) params.set('action', filters.action);
if (filters.result) params.set('result', filters.result);
if (filters.search) params.set('search', filters.search);
if (filters.from) params.set('from', filters.from);
if (filters.to) params.set('to', filters.to);
params.set('page', String(filters.page ?? 0));
params.set('size', String(filters.size ?? 25));
return useQuery<AuditLogPage>({
queryKey: ['vendor', 'audit', filters],
queryFn: () => api.get(`/vendor/audit?${params.toString()}`),
});
}

View 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>
);
}

View File

@@ -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"

View File

@@ -0,0 +1,23 @@
import { useState } from 'react';
import { useTenantAuditLog } from '../../api/tenant-hooks';
import { AuditLogTable } from '../../components/AuditLogTable';
import type { AuditLogFilters } from '../../types/api';
export function TenantAuditPage() {
const [filters, setFilters] = useState<AuditLogFilters>({ page: 0, size: 25 });
const { data, isLoading } = useTenantAuditLog(filters);
return (
<div style={{ padding: 24, display: 'flex', flexDirection: 'column', gap: 20 }}>
<h1 style={{ margin: 0, fontSize: '1.25rem', fontWeight: 600 }}>Audit Log</h1>
<AuditLogTable
data={data}
isLoading={isLoading}
filters={filters}
onFiltersChange={setFilters}
showTenantColumn={false}
showTenantFilter={false}
/>
</div>
);
}

25
ui/src/pages/vendor/VendorAuditPage.tsx vendored Normal file
View File

@@ -0,0 +1,25 @@
import { useState } from 'react';
import { useVendorAuditLog, useVendorTenants } from '../../api/vendor-hooks';
import { AuditLogTable } from '../../components/AuditLogTable';
import type { AuditLogFilters } from '../../types/api';
export function VendorAuditPage() {
const [filters, setFilters] = useState<AuditLogFilters>({ page: 0, size: 25 });
const { data, isLoading } = useVendorAuditLog(filters);
const { data: tenants } = useVendorTenants();
return (
<div style={{ padding: 24, display: 'flex', flexDirection: 'column', gap: 20 }}>
<h1 style={{ margin: 0, fontSize: '1.25rem', fontWeight: 600 }}>Audit Log</h1>
<AuditLogTable
data={data}
isLoading={isLoading}
filters={filters}
onFiltersChange={setFilters}
showTenantColumn
showTenantFilter
tenants={tenants}
/>
</div>
);
}

View File

@@ -11,11 +11,13 @@ import { useOrgStore } from './auth/useOrganization';
import { VendorTenantsPage } from './pages/vendor/VendorTenantsPage';
import { CreateTenantPage } from './pages/vendor/CreateTenantPage';
import { TenantDetailPage } from './pages/vendor/TenantDetailPage';
import { VendorAuditPage } from './pages/vendor/VendorAuditPage';
import { TenantDashboardPage } from './pages/tenant/TenantDashboardPage';
import { TenantLicensePage } from './pages/tenant/TenantLicensePage';
import { OidcConfigPage } from './pages/tenant/OidcConfigPage';
import { TeamPage } from './pages/tenant/TeamPage';
import { SettingsPage } from './pages/tenant/SettingsPage';
import { TenantAuditPage } from './pages/tenant/TenantAuditPage';
function LandingRedirect() {
const scopes = useScopes();
@@ -66,12 +68,18 @@ export function AppRouter() {
<TenantDetailPage />
</RequireScope>
} />
<Route path="/vendor/audit" element={
<RequireScope scope="platform:admin" fallback={<Navigate to="/tenant" replace />}>
<VendorAuditPage />
</RequireScope>
} />
{/* Tenant portal */}
<Route path="/tenant" element={<TenantDashboardPage />} />
<Route path="/tenant/license" element={<TenantLicensePage />} />
<Route path="/tenant/oidc" element={<OidcConfigPage />} />
<Route path="/tenant/team" element={<TeamPage />} />
<Route path="/tenant/audit" element={<TenantAuditPage />} />
<Route path="/tenant/settings" element={<SettingsPage />} />
{/* Default redirect — vendor goes to /vendor/tenants, customer to /tenant */}

View File

@@ -93,3 +93,35 @@ export interface TenantSettings {
serverEndpoint: string | null;
createdAt: string;
}
// Audit log types
export interface AuditLogEntry {
id: string;
actorEmail: string | null;
tenantId: string | null;
action: string;
resource: string | null;
environment: string | null;
result: string;
sourceIp: string | null;
createdAt: string;
}
export interface AuditLogPage {
content: AuditLogEntry[];
page: number;
size: number;
totalElements: number;
totalPages: number;
}
export interface AuditLogFilters {
action?: string;
result?: string;
search?: string;
from?: string;
to?: string;
tenantId?: string;
page?: number;
size?: number;
}