feat: portal redesign, pricing catalog, partner-staff invites
- 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
This commit is contained in:
@@ -0,0 +1,407 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user