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

408 lines
13 KiB
Vue

<script setup lang="ts">
// Partner audit log. Strict port of PartnerAuditScreen
// (platform-partner-depth.jsx lines 1042-1098). Filter bar (search + Actor /
// Customer / Action / Last) + cross-customer audit log table + footer note.
// Click a row to open a detail SidePanel with before/after diff and origin.
const toast = useToast()
// AuditRow shape the template + side panel render against. Built from the
// real /api/partner/activity feed (see `rows` below).
interface AuditRow {
id: string
when: string // formatted timestamp for the table
whenIso: string // raw ISO for period filtering
actor: string // actor email
customer: string // tenant name resolved from tenantSlug, or '—'
customerColor: string // tile colour for the cust swatch
action: string // dotted verb e.g. 'partner.user_invited'
target: string // resourceName from the audit event
tone: 'info' | 'warn' | 'ok' | 'bad'
}
interface PartnerTenant { _id: string; slug: string; name: string }
interface ActivityEvent {
_id: string
at: string
action: string
resourceName?: string
tenantSlug?: string
outcome?: 'success' | 'failure' | 'pending'
actor?: { email?: string; userId?: string }
}
// Pull the partner's tenants (for slug→name + colour lookup) and recent
// audit events. Limit 200 — generous compared to the dashboard's 8 — so
// filters meaningfully shrink the visible set client-side. Pagination via
// `?before` lands when 200 is regularly hit.
const { data: tenants } = await useFetch<PartnerTenant[]>('/api/partner/tenants', {
key: 'partner-tenants',
default: () => [],
})
const { data: events } = await useFetch<ActivityEvent[]>('/api/partner/activity', {
key: 'partner-activity-full',
query: { limit: 200 },
default: () => [],
})
function tenantNameFromSlug(slug?: string): string {
if (!slug) return '—'
return tenants.value?.find((t) => t.slug === slug)?.name ?? slug
}
// Deterministic colour per tenant slug so the swatch stays stable across
// reloads even though we don't store brand colours on Tenant yet.
const PALETTE = ['#D4FF3A', '#4D8BE8', '#34C77B', '#F0B14A', '#F05858', '#A78BFA']
function tenantColor(slug?: string): string {
if (!slug) return 'var(--text-mute)'
let h = 0
for (let i = 0; i < slug.length; i++) h = (h * 31 + slug.charCodeAt(i)) | 0
return PALETTE[Math.abs(h) % PALETTE.length]!
}
function eventTone(e: ActivityEvent): AuditRow['tone'] {
if (e.outcome === 'failure') return 'bad'
if (e.outcome === 'pending') return 'warn'
if (/\.(deleted|suspended|terminated|removed)$/.test(e.action)) return 'bad'
return 'info'
}
function fmtTime(iso: string): string {
// Always full date + time. The "today shows time only" shortcut made it
// unclear whether a bare "08.35" was this morning or yesterday morning;
// for an audit log, consistency beats brevity.
return new Date(iso).toLocaleString('da-DK', { dateStyle: 'short', timeStyle: 'short' })
}
const rows = computed<AuditRow[]>(() =>
(events.value ?? []).map((e) => ({
id: e._id,
when: fmtTime(e.at),
whenIso: e.at,
actor: e.actor?.email ?? 'system',
customer: tenantNameFromSlug(e.tenantSlug),
customerColor: tenantColor(e.tenantSlug),
action: e.action,
target: e.resourceName ?? '—',
tone: eventTone(e),
})),
)
const query = ref('')
const actorFilter = ref<string>('all')
const customerFilter = ref<string>('all')
const actionFilter = ref<string>('all')
const periodFilter = ref<'24h' | '7d' | '30d'>('7d')
const actors = computed(() => Array.from(new Set(rows.value.map((r) => r.actor))))
const actions = computed(() => Array.from(new Set(rows.value.map((r) => r.action))).sort())
// Distinct customer list for the dropdown — replaces the fixture customers
// array. '—' (partner-scoped events) shows up as a special "Partner-level"
// option so filtering to those is easy.
const customerOptions = computed(() => {
const names = new Set<string>()
for (const r of rows.value) names.add(r.customer)
return Array.from(names).sort()
})
const PERIOD_MS: Record<typeof periodFilter.value, number> = {
'24h': 24 * 60 * 60 * 1000,
'7d': 7 * 24 * 60 * 60 * 1000,
'30d': 30 * 24 * 60 * 60 * 1000,
}
const filtered = computed(() => {
const cutoff = Date.now() - PERIOD_MS[periodFilter.value]
return rows.value.filter((r) => {
if (new Date(r.whenIso).getTime() < cutoff) return false
if (actorFilter.value !== 'all' && r.actor !== actorFilter.value) return false
if (actionFilter.value !== 'all' && r.action !== actionFilter.value) return false
if (customerFilter.value !== 'all' && r.customer !== customerFilter.value) return false
if (query.value) {
const q = query.value.toLowerCase()
if (!(r.actor + ' ' + r.customer + ' ' + r.action + ' ' + r.target).toLowerCase().includes(q)) return false
}
return true
})
})
function customerColor(name: string) {
if (name === '—') return 'var(--text-mute)'
// Look up tenant by name to find the slug, then derive colour.
const t = tenants.value?.find((x) => x.name === name)
return tenantColor(t?.slug)
}
const detail = ref<AuditRow | null>(null)
</script>
<template>
<div>
<PageHeader
eyebrow="Compliance"
title="Partner audit log"
subtitle="Every action your team has taken across your customer portfolio. Customer admins see this in their own audit log too."
/>
<div class="content">
<div class="filters">
<div class="search">
<UiIcon name="search" :size="14" />
<input v-model="query" placeholder="actor, customer, action…" />
</div>
<div class="seg">
<span class="seg-label">Actor</span>
<select v-model="actorFilter">
<option value="all">Anyone</option>
<option v-for="a in actors" :key="a" :value="a">{{ a }}</option>
</select>
</div>
<div class="seg">
<span class="seg-label">Customer</span>
<select v-model="customerFilter">
<option value="all">All customers</option>
<option v-for="name in customerOptions" :key="name" :value="name">{{ name }}</option>
</select>
</div>
<div class="seg">
<span class="seg-label">Action</span>
<select v-model="actionFilter">
<option value="all">All actions</option>
<option v-for="a in actions" :key="a" :value="a">{{ a }}</option>
</select>
</div>
<div class="seg">
<span class="seg-label">Last</span>
<select v-model="periodFilter">
<option value="24h">24 hours</option>
<option value="7d">7 days</option>
<option value="30d">30 days</option>
</select>
</div>
<div class="spacer" />
<UiButton variant="secondary" @click="toast.ok('Exporting CSV', `${filtered.length} entries`)">
<template #leading><UiIcon name="download" :size="14" /></template>
Export CSV
</UiButton>
</div>
<Card :pad="0">
<table class="dtable">
<thead>
<tr>
<th>Time</th>
<th>Actor</th>
<th>Customer</th>
<th>Action</th>
<th>Target</th>
<th class="tone-col" />
</tr>
</thead>
<tbody>
<tr v-for="r in filtered" :key="r.id" @click="detail = r">
<td><Mono>{{ r.when }}</Mono></td>
<td>
<div class="actor-cell">
<Avatar :name="r.actor" :size="22" />
<div>
<div class="actor-name">{{ r.actor }}</div>
<Mono dim>partner</Mono>
</div>
</div>
</td>
<td>
<Mono v-if="r.customer === '—'" dim></Mono>
<div v-else class="cust-cell">
<div class="cust-swatch" :style="{ background: customerColor(r.customer) }" />
<span>{{ r.customer }}</span>
</div>
</td>
<td><Mono class="action-text">{{ r.action }}</Mono></td>
<td><span class="target-text">{{ r.target }}</span></td>
<td class="tone-col">
<Badge :tone="r.tone" dot>{{ r.tone === 'bad' ? 'fail' : r.tone }}</Badge>
</td>
</tr>
</tbody>
</table>
</Card>
<Mono dim class="footer-note">// retention 365 days · write-once · visible to customer admins on their own audit log</Mono>
</div>
<!-- Detail side panel -->
<SidePanel
:open="!!detail"
width="md"
eyebrow="Audit event"
:title="detail?.action || ''"
@close="detail = null"
>
<template v-if="detail">
<div class="detail-head">
<Mono dim>{{ detail.when }}</Mono>
<Badge :tone="detail.tone" dot>{{ detail.tone === 'bad' ? 'fail' : detail.tone }}</Badge>
</div>
<div class="detail-section">
<Eyebrow>Actor</Eyebrow>
<div class="actor-row">
<Avatar :name="detail.actor" :size="32" />
<div>
<div class="dn">{{ detail.actor }}</div>
<Mono dim>partner staff</Mono>
</div>
</div>
</div>
<div class="detail-section">
<Eyebrow>Target</Eyebrow>
<div class="target-row">
<div v-if="detail.customer !== '—'" class="cust-cell">
<div class="cust-swatch" :style="{ background: customerColor(detail.customer) }" />
<span>{{ detail.customer }}</span>
</div>
<Mono>{{ detail.target }}</Mono>
</div>
</div>
<div class="detail-section">
<Eyebrow>Event ID</Eyebrow>
<div class="eid"><Mono>{{ detail.id }}</Mono></div>
</div>
</template>
<template #footer>
<div class="audit-footer">
<UiIcon name="shield" :size="12" />
<Mono dim>tamper-evident · retention 365 days</Mono>
</div>
</template>
</SidePanel>
</div>
</template>
<style scoped>
.content { padding: 20px 40px 64px; display: flex; flex-direction: column; gap: 12px; }
.filters { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.search {
display: flex;
align-items: center;
gap: 8px;
padding: 0 10px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
width: 320px;
color: var(--text-mute);
}
.search input {
flex: 1;
border: none;
background: transparent;
font-family: inherit;
font-size: 13px;
padding: 9px 0;
color: var(--text);
}
.search input:focus { outline: none; }
.seg {
display: flex;
align-items: center;
gap: 6px;
padding: 0 10px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
}
.seg-label {
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--text-mute);
}
.seg select {
border: none;
background: transparent;
font-family: inherit;
font-size: 13px;
color: var(--text);
padding: 8px 4px;
cursor: pointer;
}
.seg select:focus { outline: none; }
.spacer { flex: 1; }
.dtable { width: 100%; border-collapse: collapse; }
.dtable th {
text-align: left;
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--text-mute);
font-weight: 500;
padding: 12px 16px;
border-bottom: 1px solid var(--border);
}
.dtable th.tone-col, .dtable td.tone-col { width: 80px; text-align: right; }
.dtable td {
padding: 12px 16px;
border-bottom: 1px solid var(--border);
font-size: 13px;
vertical-align: middle;
}
.dtable tbody tr { cursor: pointer; transition: background 80ms; }
.dtable tbody tr:hover { background: var(--row-hover); }
.actor-cell { display: flex; align-items: center; gap: 8px; }
.actor-name { font-size: 12px; font-weight: 500; }
.cust-cell { display: flex; align-items: center; gap: 6px; }
.cust-swatch { width: 12px; height: 12px; border-radius: 2px; flex-shrink: 0; }
.action-text { font-weight: 500; }
.target-text { font-size: 12px; color: var(--text-dim); }
.footer-note { display: block; margin-top: 4px; }
/* Side panel detail */
.detail-head {
display: flex;
justify-content: space-between;
align-items: center;
padding-bottom: 14px;
margin-bottom: 18px;
border-bottom: 1px solid var(--border);
}
.detail-section { margin-bottom: 20px; }
.detail-section + .detail-section { padding-top: 20px; border-top: 1px solid var(--border); }
.actor-row { display: flex; align-items: center; gap: 12px; margin-top: 8px; }
.dn { font-size: 14px; font-weight: 500; }
.target-row { display: flex; align-items: center; gap: 10px; margin-top: 8px; font-size: 13px; }
.eid { margin-top: 8px; }
.audit-footer {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
width: 100%;
}
.audit-footer :deep(svg) { color: var(--text-mute); }
</style>