0bd4e5498e
- portal: new admin/ and partner/ surfaces with full component library (AppLauncher, Avatar, Badge, Card, Modal, Tabs, etc.), composables, layouts, partner-routing middleware, and supporting server APIs - pricing: Price schema/module with operator CRUD, pricing.vue catalog UI, Subscription extended with cycle/currency/perSeatAmount/seats snapshots for stable MRR aggregation - partner staff: User.partnerId, invite-partner-user DTO and flow, /partners/:slug/users endpoints, InvitePartnerUserModal, shared dezky-partner-staff Authentik group - /me: partner-aware endpoint returning user + partner context so portal can route between end-user and partner-admin surfaces - tenant: seats field for portfolio displays and future MRR calculations - operator: pricing page, signed-out page, useMe/useToast composables, ToastStack
408 lines
13 KiB
Vue
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>
|