Files
Ronni Baslund 17ffd95a70 chore(portal,operator): upgrade to Nuxt 4
Upgrade both Nuxt apps to Nuxt 4.4.6 (vue-tsc 3, TypeScript 5.6, undici 7) and add a root tsconfig.json to each app. Fix the strict-null / noUncheckedIndexedAccess errors surfaced by Nuxt 4's stricter generated tsconfig and vue-tsc 3. Drop the nuxt-oidc-auth pnpm patch (Nuxt 4 fixes the prepare:types crash natively).
2026-05-30 08:02:43 +02:00

617 lines
23 KiB
Vue
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import type { AuditEvent } from '~/types/audit'
// Local UI state for filters. Drives the params we pass to /api/audit.
const search = ref('')
const actionFilter = ref('') // exact or prefix (e.g. 'tenant')
const outcomeFilter = ref<'' | 'success' | 'failure'>('')
const tenantFilter = ref('')
const params = computed<Record<string, string>>(() => {
const p: Record<string, string> = {}
if (search.value.trim()) p.q = search.value.trim()
if (actionFilter.value) p.action = actionFilter.value
if (outcomeFilter.value) p.outcome = outcomeFilter.value
if (tenantFilter.value.trim()) p.tenantSlug = tenantFilter.value.trim()
return p
})
const { data: events, pending, refresh } = await useFetch<AuditEvent[]>('/api/audit', {
default: () => [],
query: params,
})
// Pagination via the `before` cursor — request the next page using the
// timestamp of the last event we have.
const olderPages = ref<AuditEvent[]>([])
const reachedEnd = ref(false)
const allEvents = computed(() => [...(events.value ?? []), ...olderPages.value])
// Reset older-page state whenever the live filter set changes; otherwise
// older results from a previous filter combination linger.
watch(params, () => {
olderPages.value = []
reachedEnd.value = false
})
async function loadMore() {
const last = allEvents.value[allEvents.value.length - 1]
if (!last) return
const next = await $fetch<AuditEvent[]>('/api/audit', {
query: { ...params.value, before: last.at },
})
if (!next.length) {
reachedEnd.value = true
return
}
olderPages.value.push(...next)
}
// Quick-pick filter chips that map to common queries.
const QUICK_ACTIONS = [
{ label: 'All actions', value: '' },
{ label: 'Tenants', value: 'tenant' },
{ label: 'Partners', value: 'partner' },
{ label: 'Flags', value: 'flag' },
{ label: 'Users', value: 'user' },
]
function fmtAbs(iso: string) {
const d = new Date(iso)
return d.toLocaleString('da-DK', { dateStyle: 'short', timeStyle: 'medium' })
}
function tone(e: AuditEvent): 'info' | 'warn' | 'bad' | 'ok' {
if (e.outcome === 'failure') return 'bad'
if (e.action.includes('suspended') || e.action.includes('killed') || e.action.includes('deleted') || e.action.includes('terminated')) return 'warn'
return 'info'
}
function shortIp(ip?: string) {
if (!ip) return '—'
// Strip v4-in-v6 prefix that node sockets sometimes report (::ffff:1.2.3.4 → 1.2.3.4)
return ip.replace(/^::ffff:/, '')
}
// Row expansion — reveals metadata.changes + any other metadata keys for the
// row. Kept as a plain Set so toggling stays O(1) and the operator can have
// multiple rows open at once.
const expanded = ref(new Set<string>())
function toggleRow(id: string) {
if (expanded.value.has(id)) expanded.value.delete(id)
else expanded.value.add(id)
// Force reactivity — Set mutations aren't tracked by Vue directly.
expanded.value = new Set(expanded.value)
}
function hasDetails(e: AuditEvent): boolean {
return !!e.metadata && Object.keys(e.metadata).length > 0
}
function formatMetaValue(v: unknown): string {
if (v === null || v === undefined) return '—'
if (Array.isArray(v)) return v.join(', ')
if (typeof v === 'object') return JSON.stringify(v)
return String(v)
}
// Compact human-readable rendering of a single side of a diff. Objects collapse
// to JSON; null / undefined / empty string render as a dim em-dash so the
// "before" side of a freshly populated field reads as clearly empty.
function formatDiffValue(v: unknown): string {
if (v === null || v === undefined || v === '') return '—'
if (typeof v === 'object') return JSON.stringify(v)
return String(v)
}
// `metadata.diff` shape for partner.updated (and future *.updated actions):
// { fieldName: { from, to } }. Type-narrow safely; old audit rows that only
// carried `metadata.changes: string[]` are still rendered as plain chips by
// the fallback branch in the template.
interface DiffEntry { from: unknown; to: unknown }
function diffEntries(meta: Record<string, unknown> | undefined): [string, DiffEntry][] {
const d = meta?.diff
if (!d || typeof d !== 'object') return []
return Object.entries(d as Record<string, DiffEntry>)
}
// ── Tamper-evidence (Phase 3) + cold-storage archives (Phase 4) ────────
interface VerifyReport {
ok: boolean
totalEventsVerified: number
checkpointsChecked: number
latestCheckpointAt: string | null
latestVerifiedSeq: number | null
// Phase 4 additions — included in /audit/verify response.
oldestHotSeq: number | null
highestArchivedSeq: number | null
break?:
| { kind: 'event-hash-mismatch'; seq: number; expected: string; actual: string }
| { kind: 'event-prev-hash-mismatch'; seq: number; expected: string; actual: string }
| { kind: 'checkpoint-signature-mismatch'; headSeq: number }
}
interface CheckpointSummary {
at: string | null
headSeq: number | null
headHash: string | null
reason?: string
}
interface ArchiveBatch {
_id: string
archivedAt: string
startSeq: number
endSeq: number
eventCount: number
manifestSha256: string
jsonlKey: string
manifestKey: string
bytesUncompressed: number
}
const { data: latestCp, refresh: refreshCp } = useLazyFetch<CheckpointSummary>(
'/api/audit/checkpoint/latest',
{ default: () => ({ at: null, headSeq: null, headHash: null }), server: false },
)
const { data: archives, refresh: refreshArchives } = useLazyFetch<ArchiveBatch[]>(
'/api/audit/archives',
{ default: () => [], server: false },
)
const verifyReport = ref<VerifyReport | null>(null)
const verifying = ref(false)
const archiving = ref(false)
const archiveResult = ref<{ ok: boolean; reason?: string; eventCount?: number; startSeq?: number; endSeq?: number } | null>(null)
async function runVerify() {
verifying.value = true
try {
verifyReport.value = await $fetch<VerifyReport>('/api/audit/verify')
await refreshCp()
} finally {
verifying.value = false
}
}
async function forceCheckpoint() {
await $fetch('/api/audit/checkpoint', { method: 'POST' })
await refreshCp()
}
async function forceArchive() {
archiving.value = true
archiveResult.value = null
try {
archiveResult.value = await $fetch('/api/audit/archive/run', { method: 'POST' })
await Promise.all([refreshArchives(), refresh()])
} finally {
archiving.value = false
}
}
function fmtBytes(n: number): string {
if (n < 1024) return `${n} B`
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`
return `${(n / 1024 / 1024).toFixed(1)} MB`
}
function fmtRelative(iso: string | null | undefined): string {
if (!iso) return 'never'
const ms = Date.now() - new Date(iso).getTime()
if (ms < 60_000) return `${Math.floor(ms / 1000)}s ago`
if (ms < 3_600_000) return `${Math.floor(ms / 60_000)}m ago`
if (ms < 86_400_000) return `${Math.floor(ms / 3_600_000)}h ago`
return new Date(iso).toLocaleDateString('da-DK')
}
</script>
<template>
<div>
<PageHeader
eyebrow="Compliance"
title="Global audit log"
:subtitle="`${allEvents.length} event${allEvents.length === 1 ? '' : 's'} · every privileged action recorded by platform-api`"
>
<template #actions>
<UiButton variant="secondary" :disabled="pending" @click="refresh()">
<template #leading><UiIcon name="refresh" :size="13" /></template>
Refresh
</UiButton>
</template>
</PageHeader>
<div class="stage">
<div class="toolbar">
<div class="search">
<UiIcon name="search" :size="13" />
<input v-model="search" placeholder="action, actor, target, tenant…" type="text" />
</div>
<div class="chips">
<button
v-for="q in QUICK_ACTIONS"
:key="q.label"
:class="['chip', { on: actionFilter === q.value }]"
type="button"
@click="actionFilter = q.value"
>{{ q.label }}</button>
</div>
<div class="chips">
<button :class="['chip', { on: outcomeFilter === '' }]" type="button" @click="outcomeFilter = ''">Any</button>
<button :class="['chip', { on: outcomeFilter === 'success' }]" type="button" @click="outcomeFilter = 'success'">Success</button>
<button :class="['chip', { on: outcomeFilter === 'failure' }]" type="button" @click="outcomeFilter = 'failure'">Failure</button>
</div>
<Mono dim class="streaming">
<StatusDot color="var(--ok)" :size="6" :glow="false" />
live · backed by Mongo
</Mono>
</div>
<Card :pad="0">
<table v-if="allEvents.length">
<thead>
<tr>
<th class="caret-col"></th>
<th>Time</th>
<th>Actor</th>
<th>Action</th>
<th>Target</th>
<th>Tenant</th>
<th>IP</th>
<th class="r">Result</th>
</tr>
</thead>
<tbody>
<template v-for="e in allEvents" :key="e._id">
<tr
:class="['row', { expandable: hasDetails(e), open: expanded.has(e._id) }]"
@click="hasDetails(e) && toggleRow(e._id)"
>
<td class="caret-col">
<UiIcon
v-if="hasDetails(e)"
:name="expanded.has(e._id) ? 'chevDown' : 'chevRight'"
:size="12"
/>
</td>
<td><Mono>{{ fmtAbs(e.at) }}</Mono></td>
<td class="actor">
<div v-if="e.actorType === 'system'" class="sys">sys</div>
<Avatar v-else :name="e.actorEmail || '?'" :size="22" />
<div>
<div class="name">{{ e.actorEmail || 'system' }}</div>
<Mono dim>{{ e.source }}</Mono>
</div>
</td>
<td><Mono class="action">{{ e.action }}</Mono></td>
<td><span class="target">{{ e.resourceName || e.resourceId || '—' }}</span></td>
<td>
<Mono v-if="e.tenantSlug">{{ e.tenantSlug }}</Mono>
<Mono v-else dim></Mono>
</td>
<td><Mono dim>{{ shortIp(e.actorIp) }}</Mono></td>
<td class="r">
<Badge :tone="tone(e)" dot>
{{ e.outcome === 'failure' ? 'fail' : e.outcome === 'success' ? 'ok' : '—' }}
</Badge>
</td>
</tr>
<tr v-if="expanded.has(e._id) && hasDetails(e)" class="detail-row">
<td></td>
<td colspan="7">
<!-- New format: { diff: { field: {from, to} } } render as a
table of before after rows so the operator can read the
change inline. -->
<table v-if="diffEntries(e.metadata).length" class="diff-table">
<tbody>
<tr v-for="[field, entry] in diffEntries(e.metadata)" :key="field">
<td class="diff-key"><Mono>{{ field }}</Mono></td>
<td class="diff-from"><Mono dim>{{ formatDiffValue(entry.from) }}</Mono></td>
<td class="diff-arrow"><Mono dim></Mono></td>
<td class="diff-to"><Mono>{{ formatDiffValue(entry.to) }}</Mono></td>
</tr>
</tbody>
</table>
<!-- Generic metadata renderer covers older events that only
have metadata.changes (string[]) plus all non-update
actions whose metadata is free-form key/values. -->
<dl v-else class="meta">
<template v-for="(value, key) in e.metadata" :key="key">
<dt><Mono dim>{{ key }}</Mono></dt>
<dd>
<span v-if="key === 'changes' && Array.isArray(value)" class="changes">
<Mono v-for="field in value as string[]" :key="field" class="change-chip">
{{ field }}
</Mono>
</span>
<Mono v-else>{{ formatMetaValue(value) }}</Mono>
</dd>
</template>
</dl>
</td>
</tr>
</template>
</tbody>
</table>
<div v-else class="empty"><Mono dim>// no events match the current filters</Mono></div>
</Card>
<div v-if="allEvents.length" class="footer">
<UiButton v-if="!reachedEnd" variant="secondary" :disabled="pending" @click="loadMore">
Load older events
</UiButton>
<Mono v-else dim>// reached the start of the log</Mono>
</div>
<!-- Tamper-evidence panel Phase 3 -->
<Card :pad="0" class="verify-card">
<div class="verify-head">
<div>
<Eyebrow>Tamper evidence</Eyebrow>
<div class="cap">
Hash chain · {{ latestCp?.headSeq != null ? `signed through seq ${latestCp.headSeq}` : 'no checkpoints yet' }}
</div>
</div>
<div class="verify-actions">
<UiButton variant="secondary" :disabled="verifying" @click="forceCheckpoint">
Force checkpoint
</UiButton>
<UiButton variant="primary" :disabled="verifying" @click="runVerify">
{{ verifying ? 'Verifying' : 'Verify chain' }}
</UiButton>
</div>
</div>
<div class="verify-meta">
<div class="kv"><Eyebrow>Last signed checkpoint</Eyebrow><Mono>{{ fmtRelative(latestCp?.at ?? null) }}</Mono></div>
<div v-if="latestCp?.headHash" class="kv"><Eyebrow>Head hash</Eyebrow><Mono dim>{{ latestCp.headHash.slice(0, 16) }}</Mono></div>
<div v-if="latestCp?.reason" class="kv"><Eyebrow>Reason</Eyebrow><Mono dim>{{ latestCp.reason }}</Mono></div>
</div>
<!-- Verify result -->
<div v-if="verifyReport" class="verify-result" :data-ok="verifyReport.ok">
<div v-if="verifyReport.ok" class="result-line">
<Badge tone="ok" dot>verified</Badge>
<Mono>{{ verifyReport.totalEventsVerified }} event(s) · {{ verifyReport.checkpointsChecked }} checkpoint(s) · last seq {{ verifyReport.latestVerifiedSeq ?? '—' }}</Mono>
</div>
<div v-else class="result-line">
<Badge tone="bad" dot>BROKEN</Badge>
<Mono v-if="verifyReport.break?.kind === 'event-hash-mismatch'">
event hash mismatch at seq {{ verifyReport.break.seq }} · stored {{ verifyReport.break.actual.slice(0, 16) }} expected {{ verifyReport.break.expected.slice(0, 16) }}
</Mono>
<Mono v-else-if="verifyReport.break?.kind === 'event-prev-hash-mismatch'">
chain link broken at seq {{ verifyReport.break.seq }} · prevHash mismatch
</Mono>
<Mono v-else-if="verifyReport.break?.kind === 'checkpoint-signature-mismatch'">
checkpoint signature mismatch at head seq {{ verifyReport.break.headSeq }}
</Mono>
</div>
</div>
</Card>
<!-- Cold-storage archives Phase 4 -->
<Card :pad="0" class="archive-card">
<div class="archive-head">
<div>
<Eyebrow>Cold storage</Eyebrow>
<div class="cap">
{{
archives?.length
? `archived through seq ${archives[0]!.endSeq} · ${archives.length} batch${archives.length === 1 ? '' : 'es'}`
: 'no archives yet · 90-day hot retention'
}}
</div>
</div>
<UiButton variant="secondary" :disabled="archiving" @click="forceArchive">
{{ archiving ? 'Archiving' : 'Run archive now' }}
</UiButton>
</div>
<div v-if="archiveResult" class="archive-result" :data-ok="archiveResult.ok">
<Badge v-if="archiveResult.ok && archiveResult.eventCount" tone="ok" dot>archived</Badge>
<Badge v-else-if="archiveResult.ok" tone="info" dot>no-op</Badge>
<Badge v-else tone="bad" dot>failed</Badge>
<Mono v-if="archiveResult.ok && archiveResult.eventCount">
{{ archiveResult.eventCount }} event(s) · seq {{ archiveResult.startSeq }}{{ archiveResult.endSeq }}
</Mono>
<Mono v-else dim>{{ archiveResult.reason || '—' }}</Mono>
</div>
<table v-if="archives?.length">
<thead>
<tr>
<th>Archived</th>
<th>Seq range</th>
<th>Events</th>
<th>Size</th>
<th>Manifest sha256</th>
</tr>
</thead>
<tbody>
<tr v-for="b in archives" :key="b._id">
<td><Mono>{{ fmtAbs(b.archivedAt) }}</Mono></td>
<td><Mono>{{ b.startSeq }}{{ b.endSeq }}</Mono></td>
<td><Mono>{{ b.eventCount }}</Mono></td>
<td><Mono dim>{{ fmtBytes(b.bytesUncompressed) }}</Mono></td>
<td><Mono dim>{{ b.manifestSha256.slice(0, 16) }}</Mono></td>
</tr>
</tbody>
</table>
<div v-else class="empty"><Mono dim>// no archive batches yet — events stay in hot Mongo for {{ '90' }} days, then move to S3 (MinIO in dev / Hetzner in prod)</Mono></div>
</Card>
<Mono dim class="note">
// hot tier: Mongo · cold tier: S3-compatible object storage ·
sha256 hash-chain with HMAC-signed checkpoints + signed archive
manifests · retention 90 days hot, indefinite cold · production
encryption at rest is SSE-S3
</Mono>
</div>
</div>
</template>
<style scoped>
.stage { padding: 24px 40px 64px 40px; display: flex; flex-direction: column; gap: 16px; }
.toolbar { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
.search {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text-mute);
min-width: 320px;
}
.search input {
background: transparent;
border: 0;
outline: 0;
flex: 1;
font-family: inherit;
font-size: 12px;
color: var(--text);
}
.chips { display: flex; gap: 6px; flex-wrap: wrap; }
.chip {
appearance: none;
padding: 6px 10px;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text-dim);
border-radius: 999px;
font-family: inherit;
font-size: 12px;
cursor: pointer;
}
.chip:hover { color: var(--text); }
.chip.on { background: var(--text); color: var(--bg); border-color: var(--text); }
.streaming { display: inline-flex; align-items: center; gap: 8px; margin-left: auto; }
table { width: 100%; border-collapse: collapse; }
th {
text-align: left;
font-family: var(--font-mono);
font-size: 9px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--text-mute);
padding: 12px 16px;
font-weight: 500;
border-bottom: 1px solid var(--border);
}
th.r, td.r { text-align: right; }
td { padding: 10px 16px; font-size: 12px; border-top: 1px solid var(--border); vertical-align: middle; }
td.actor { display: flex; align-items: center; gap: 10px; }
/* Expandable row: rows with metadata show a caret + become clickable. */
.caret-col { width: 28px; padding-left: 12px; padding-right: 0; color: var(--text-mute); }
tr.row.expandable { cursor: pointer; }
tr.row.expandable:hover { background: var(--surface); }
tr.row.open .caret-col { color: var(--text); }
tr.detail-row td {
background: var(--surface);
border-top: 0;
padding: 10px 16px 14px 16px;
}
.meta {
margin: 0;
display: grid;
grid-template-columns: max-content 1fr;
gap: 6px 14px;
align-items: baseline;
}
.meta dt { font-size: 10px; letter-spacing: 0.06em; text-transform: uppercase; }
.meta dd { margin: 0; font-size: 12px; color: var(--text-dim); }
.changes { display: inline-flex; flex-wrap: wrap; gap: 6px; }
.change-chip {
padding: 2px 8px;
border: 1px solid var(--border);
border-radius: 999px;
background: var(--bg);
font-size: 11px;
color: var(--text);
}
/* before → after diff table inside the expanded row */
.diff-table {
width: auto;
border-collapse: collapse;
margin: 0;
}
.diff-table td {
padding: 4px 14px 4px 0;
border: 0;
font-size: 12px;
vertical-align: top;
}
.diff-key { font-weight: 500; min-width: 110px; }
.diff-from { color: var(--text-mute); max-width: 360px; word-break: break-word; }
.diff-arrow { width: 18px; text-align: center; }
.diff-to { max-width: 360px; word-break: break-word; }
.name { font-size: 12px; font-weight: 500; }
.action { font-weight: 500; }
.target { font-size: 12px; color: var(--text-dim); }
.sys {
width: 22px; height: 22px;
border-radius: 4px;
background: var(--text);
color: var(--bg);
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 9px;
font-family: var(--font-mono);
}
.empty { padding: 40px 20px; text-align: center; }
.footer { display: flex; justify-content: center; padding: 4px 0; }
.note { display: block; padding: 4px 4px 0 4px; }
/* Tamper-evidence panel */
.verify-card { margin-top: 8px; }
.verify-head {
padding: 14px 18px;
display: flex; justify-content: space-between; align-items: center;
gap: 16px;
border-bottom: 1px solid var(--border);
}
.verify-head .cap { font-family: var(--font-display); font-weight: 600; font-size: 15px; margin-top: 2px; }
.verify-actions { display: flex; gap: 8px; flex-shrink: 0; }
.verify-meta {
padding: 12px 18px;
display: flex; gap: 24px; flex-wrap: wrap;
}
.verify-meta .kv { display: flex; flex-direction: column; gap: 4px; }
.verify-result {
padding: 12px 18px;
border-top: 1px solid var(--border);
}
.verify-result[data-ok="true"] { background: rgba(31, 138, 91, 0.05); }
.verify-result[data-ok="false"] { background: rgba(240, 88, 88, 0.06); }
.result-line { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
/* Cold-storage archives panel */
.archive-card { margin-top: 8px; }
.archive-head {
padding: 14px 18px;
display: flex; justify-content: space-between; align-items: center;
gap: 16px;
border-bottom: 1px solid var(--border);
}
.archive-head .cap { font-family: var(--font-display); font-weight: 600; font-size: 15px; margin-top: 2px; }
.archive-result {
padding: 10px 18px;
display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
border-bottom: 1px solid var(--border);
}
.archive-result[data-ok="true"] { background: rgba(31, 138, 91, 0.05); }
.archive-result[data-ok="false"] { background: rgba(240, 88, 88, 0.06); }
.archive-card table { width: 100%; border-collapse: collapse; }
.archive-card th, .archive-card td { padding: 10px 18px; font-size: 12px; text-align: left; }
.archive-card th { font-family: var(--font-mono); font-size: 9px; letter-spacing: 0.12em; text-transform: uppercase; color: var(--text-mute); font-weight: 500; border-bottom: 1px solid var(--border); }
.archive-card td { border-top: 1px solid var(--border); }
.archive-card .empty { padding: 16px 18px; }
</style>