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:
@@ -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()}`),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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()}`),
|
||||
});
|
||||
}
|
||||
|
||||
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"
|
||||
|
||||
23
ui/src/pages/tenant/TenantAuditPage.tsx
Normal file
23
ui/src/pages/tenant/TenantAuditPage.tsx
Normal 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
25
ui/src/pages/vendor/VendorAuditPage.tsx
vendored
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user