Files
arcadia-admin/app/routes/activity.tsx
jules 20c592dfa7 admin: completeness + UI consistency pass
Arcadia wiring:
- home: real Overview dashboard (tenants/users/audit/health probe) replacing the inherited Vibespace welcome tiles; skeleton loaders, refresh button, registers admin context
- profile: split into Account (synced via getUser/updateUser of session user) and local Preferences; updateSessionUser keeps the appbar in sync after edits
- session: drop unused signIn mock, add updateSessionUser, refresh tests
- profile schema: drop redundant Profile.name/email (session is the source of truth)
- routes: delete orphaned resources route + lib

Auth flows that previously 404'd:
- /signup, /login/forgot, /login/reset, /login/2fa wired via @crema/arcadia-auth-ui
- shared AuthShell + AuthBrand wrapper

Assistant tools (admin-tools.ts):
- +10 tools: deactivate_tenant, set_user_status, delete_user, list_memberships, list_roles, revoke_api_key, create_user, update_user, assign_role, remove_role
- list_memberships gains user_id filter for "tenants this user belongs to" queries
- search_kb / read_chunk: new token resolution (window override → VITE_ARCADIA_SEARCH_TOKEN service token → operator session JWT → "dev"); on 401/403 emit a tailored hint based on which token was used

UI consistency:
- new PageHeader component
- AppShell.title was unrendered — dropped; first-child padding on #main-content keeps the floating actions pill from colliding with header content
- removed dead "Sign in required" fallback cards from 14 routes (AppShell already redirects)
- stripped p-6 from outer wrappers across 14 routes (was double-padding under AppShell's own p-6)
- migrated home + tenants to PageHeader

arcadia-search ergonomics:
- scripts/mint-search-token.mjs + `npm run mint:search-token` mints HS512 JWT with required tenant_id claim, upserts VITE_ARCADIA_SEARCH_TOKEN into .env.local
- README/.env document the new VITE_ARCADIA_SEARCH_URL / VITE_ARCADIA_SEARCH_TOKEN knobs
- .env.local now gitignored

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 15:37:31 +10:00

409 lines
13 KiB
TypeScript

import { useCallback, useEffect, useMemo, useState } from "react"
import { Activity, Eye, RefreshCw } from "lucide-react"
import { ArcadiaError, useArcadiaClient } from "@crema/arcadia-client"
import {
ActionsCell,
BadgeCell,
DataTable,
DateCell,
Pagination,
useTable,
type BadgeTone,
type Column,
} from "@crema/table-ui"
import { SearchInput } from "@crema/search-ui"
import { AlertBanner, EmptyState, LoadingOverlay } from "@crema/feedback-ui"
import { AppShell } from "~/components/layout/app-shell"
import { Button } from "~/components/ui/button"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "~/components/ui/card"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "~/components/ui/dialog"
import { Input } from "~/components/ui/input"
import { Label } from "~/components/ui/label"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "~/components/ui/select"
import {
listAuditLogs,
type AuditLog,
type AuditSeverity,
} from "~/lib/arcadia/audit-logs"
import { pageTitle } from "~/lib/page-meta"
import { useSession } from "~/lib/session"
import { useRegisterAdminContext } from "~/lib/admin-context"
export const meta = () => pageTitle("Audit log")
export default function ActivityRoute() {
const session = useSession()
const arcadia = useArcadiaClient()
const [logs, setLogs] = useState<AuditLog[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [search, setSearch] = useState("")
const [severityFilter, setSeverityFilter] = useState<"all" | AuditSeverity>("all")
const [resourceFilter, setResourceFilter] = useState("")
const [from, setFrom] = useState("")
const [to, setTo] = useState("")
const [detail, setDetail] = useState<AuditLog | null>(null)
const refresh = useCallback(async () => {
setError(null)
setLoading(true)
try {
const list = await listAuditLogs(arcadia, {
severity: severityFilter === "all" ? undefined : severityFilter,
resource_type: resourceFilter || undefined,
from: from ? new Date(from).toISOString() : undefined,
to: to ? new Date(to).toISOString() : undefined,
limit: 200,
})
setLogs(list)
} catch (err) {
setError(err instanceof ArcadiaError ? err.message : "Failed to load audit logs.")
} finally {
setLoading(false)
}
}, [arcadia, severityFilter, resourceFilter, from, to])
useEffect(() => {
if (session) refresh()
}, [session, refresh])
const columns = useMemo<Column<AuditLog>[]>(
() => [
{
id: "time",
header: "Time",
accessor: "inserted_at",
sortable: true,
cell: (l) => <DateCell value={l.inserted_at} format="datetime" />,
},
{
id: "user",
header: "User",
accessor: (l) => l.user?.email ?? "",
sortable: true,
cell: (l) => (
<span className="text-sm">
{l.user?.email ?? <span className="text-muted-foreground">system</span>}
</span>
),
},
{
id: "action",
header: "Action",
accessor: "action",
sortable: true,
cell: (l) => (
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs">{l.action}</code>
),
},
{
id: "resource",
header: "Resource",
accessor: "resource_type",
sortable: true,
cell: (l) => (
<span className="text-sm">
<span className="font-medium">{l.resource_type}</span>
{l.resource_id ? (
<span className="ml-1 font-mono text-xs text-muted-foreground">
{l.resource_id.slice(0, 8)}
</span>
) : null}
</span>
),
},
{
id: "severity",
header: "Severity",
accessor: "severity",
sortable: true,
cell: (l) => <BadgeCell label={l.severity} tone={severityTone(l.severity)} />,
},
{
id: "ip",
header: "IP",
accessor: "ip_address",
cell: (l) => (
<span className="font-mono text-xs text-muted-foreground">
{l.ip_address ?? "—"}
</span>
),
},
{
id: "actions",
header: "",
align: "right",
cell: (l) => (
<ActionsCell
items={[
{
id: "view",
label: "View details",
icon: <Eye className="size-4" />,
dataAction: `audit-${l.id}-view`,
onSelect: () => setDetail(l),
},
]}
triggerDataAction={`audit-${l.id}-actions`}
/>
),
},
],
[],
)
const summary = useMemo(
() => ({
total: logs.length,
bySeverity: countBy(logs, (l) => l.severity || "info"),
byResource: countBy(logs, (l) => l.resource_type),
latest: logs.slice(0, 5).map((l) => ({
time: l.inserted_at,
user: l.user?.email ?? "system",
action: l.action,
resource: `${l.resource_type}${l.resource_id ? `/${l.resource_id}` : ""}`,
})),
}),
[logs],
)
useRegisterAdminContext("audit_log", summary)
const table = useTable<AuditLog>({
data: logs,
columns,
getRowId: (l) => l.id,
initialPageSize: 50,
initialSearch: search,
})
useEffect(() => {
table.setSearch(search)
}, [search, table])
return (
<AppShell>
<div className="flex flex-col gap-4">
<header className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Audit log</h1>
<p className="text-sm text-muted-foreground">
Every authenticated action against the platform. Filter by date, severity, or
resource type.
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={refresh}
disabled={loading}
data-action="audit-refresh"
>
<RefreshCw className={`size-4 ${loading ? "animate-spin" : ""}`} />
Refresh
</Button>
</header>
{error ? (
<AlertBanner variant="error" dismissible onDismiss={() => setError(null)}>
{error}
</AlertBanner>
) : null}
<Card>
<CardHeader className="flex flex-col gap-3 lg:flex-row lg:flex-wrap lg:items-end">
<SearchInput
value={search}
onValueChange={setSearch}
placeholder="Search by action, resource, or user"
data-action="audit-search"
className="max-w-sm flex-1"
/>
<div className="flex flex-col gap-1.5">
<Label htmlFor="audit-severity" className="text-xs">
Severity
</Label>
<Select
value={severityFilter}
onValueChange={(v) => setSeverityFilter(v as typeof severityFilter)}
>
<SelectTrigger id="audit-severity" className="w-36" data-action="audit-severity-filter">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All</SelectItem>
<SelectItem value="info">Info</SelectItem>
<SelectItem value="warning">Warning</SelectItem>
<SelectItem value="error">Error</SelectItem>
<SelectItem value="critical">Critical</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="audit-resource" className="text-xs">
Resource type
</Label>
<Input
id="audit-resource"
value={resourceFilter}
onChange={(e) => setResourceFilter(e.target.value)}
placeholder="e.g. user"
className="w-40"
data-action="audit-resource-filter"
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="audit-from" className="text-xs">
From
</Label>
<Input
id="audit-from"
type="datetime-local"
value={from}
onChange={(e) => setFrom(e.target.value)}
className="w-44"
data-action="audit-from-filter"
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="audit-to" className="text-xs">
To
</Label>
<Input
id="audit-to"
type="datetime-local"
value={to}
onChange={(e) => setTo(e.target.value)}
className="w-44"
data-action="audit-to-filter"
/>
</div>
</CardHeader>
<CardContent className="relative p-0">
<LoadingOverlay active={loading && logs.length === 0} label="Loading audit log…" />
{table.total === 0 && !loading ? (
<EmptyState
icon={<Activity className="size-6" />}
title="No events match those filters."
description="Loosen the filter set or wait for new platform activity."
className="py-12"
/>
) : (
<>
<DataTable
columns={columns}
rows={table.pageRows}
getRowId={(l) => l.id}
sort={table.sort}
onSortToggle={table.toggleSort}
loading={loading && logs.length > 0}
stickyHeader
/>
<Pagination
page={table.page}
pageSize={table.pageSize}
total={table.total}
onPageChange={table.setPage}
onPageSizeChange={table.setPageSize}
/>
</>
)}
</CardContent>
</Card>
</div>
<Dialog open={detail !== null} onOpenChange={(o) => !o && setDetail(null)}>
<DialogContent className="sm:max-w-2xl max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Audit event</DialogTitle>
<DialogDescription>
{detail
? `${detail.action} on ${detail.resource_type} at ${new Date(
detail.inserted_at,
).toLocaleString()}`
: ""}
</DialogDescription>
</DialogHeader>
{detail ? <AuditDetailBody log={detail} /> : null}
</DialogContent>
</Dialog>
</AppShell>
)
}
function AuditDetailBody({ log }: { log: AuditLog }) {
const rows: { k: string; v: string }[] = [
{ k: "ID", v: log.id },
{ k: "Tenant", v: log.tenant_id },
{ k: "User", v: log.user?.email ?? log.user_id ?? "—" },
{ k: "Action", v: log.action },
{ k: "Resource", v: `${log.resource_type}${log.resource_id ? `/${log.resource_id}` : ""}` },
{ k: "Severity", v: log.severity },
{ k: "IP", v: log.ip_address ?? "—" },
{ k: "User agent", v: log.user_agent ?? "—" },
{ k: "Time", v: new Date(log.inserted_at).toISOString() },
]
return (
<div className="flex flex-col gap-4">
<dl className="grid grid-cols-[8rem_1fr] gap-y-1 text-sm">
{rows.map((r) => (
<div key={r.k} className="contents">
<dt className="text-xs uppercase tracking-wide text-muted-foreground">{r.k}</dt>
<dd className="break-all font-mono text-xs">{r.v}</dd>
</div>
))}
</dl>
{log.changes ? (
<div>
<h3 className="mb-1.5 text-sm font-semibold">Changes</h3>
<pre className="overflow-x-auto rounded-md border bg-muted/50 p-3 text-xs">
{JSON.stringify(log.changes, null, 2)}
</pre>
</div>
) : null}
{log.metadata && Object.keys(log.metadata).length > 0 ? (
<div>
<h3 className="mb-1.5 text-sm font-semibold">Metadata</h3>
<pre className="overflow-x-auto rounded-md border bg-muted/50 p-3 text-xs">
{JSON.stringify(log.metadata, null, 2)}
</pre>
</div>
) : null}
</div>
)
}
function severityTone(s: AuditSeverity): BadgeTone {
if (s === "critical" || s === "error") return "danger"
if (s === "warning") return "warning"
return "default"
}
function countBy<T>(arr: T[], key: (x: T) => string): Record<string, number> {
return arr.reduce<Record<string, number>>((acc, x) => {
const k = key(x)
acc[k] = (acc[k] ?? 0) + 1
return acc
}, {})
}