Files
dezky/apps/portal/pages/admin/index.vue
T
Ronni Baslund 0bd4e5498e 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
2026-05-28 20:00:33 +02:00

462 lines
19 KiB
Vue
Raw 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">
// Strict port of project/platform-screens.jsx `AdminDashboard` (lines 447-605).
// Keep spacing tokens (24px 40px 64px 40px content, 16 gaps), the 4-column
// stat strip in a single Card with per-column borders, the two-up
// 1.4fr / 1fr blocks (License + Recent admin events; Issues + Quick actions),
// the source's exact issue rows, audit slice, and quick-action buttons.
import type { IconName } from '~/components/UiIcon.vue'
import { sampleAudit } from '~/data/workspace'
const toast = useToast()
const router = useRouter()
const inviteOpen = ref(false)
const inviteStep = ref(1)
const seatsOpen = ref(false)
const seatsExtra = ref(5)
const stats = [
{ label: 'Seats used', value: '11 / 25', delta: '+2 this week', deltaTone: 'up' as const, hint: '' },
{ label: 'Storage', value: '1.4 TB', delta: '64% of 2.2 TB', hint: '' },
{ label: 'Mail flow', value: 'Healthy', hint: '99.98% · last 7d' },
{ label: 'Monthly spend', value: '1.940 DKK', hint: 'next invoice 01 Jun' },
] as const
const recent = sampleAudit.slice(0, 6)
const issues = [
{
tone: 'warn' as const,
title: 'DMARC record missing on baslund.dk',
body: 'Mail from this domain may fail Gmail / Outlook spam checks.',
action: 'Fix record',
onAction: () => router.push('/admin/domains'),
},
{
tone: 'bad' as const,
title: 'Failed login attempts from 203.0.113.4',
body: '3 attempts on oliver@dezky.com in the last hour. Consider IP blocklist.',
action: 'Review',
onAction: () => router.push('/admin/security'),
},
{
tone: 'info' as const,
title: '2 invitations pending',
body: 'Magnus Eriksen and Emma Skov havent accepted yet.',
action: 'Resend',
onAction: () => toast.ok('Invitation resent to Magnus and Emma'),
},
]
const quickActions: { icon: IconName; label: string; onClick: () => void }[] = [
{ icon: 'users', label: 'Invite user', onClick: () => { inviteOpen.value = true } },
{ icon: 'globe', label: 'Verify domain', onClick: () => router.push('/admin/domains') },
{ icon: 'card', label: 'Upgrade plan', onClick: () => router.push('/admin/billing') },
{ icon: 'shield', label: 'Enforce MFA', onClick: () => router.push('/admin/security') },
{ icon: 'brush', label: 'Edit branding', onClick: () => router.push('/admin/branding') },
{ icon: 'download', label: 'Export audit log', onClick: () => toast.ok('Audit log export queued · well email you when ready') },
]
function sendInvite() {
inviteOpen.value = false
inviteStep.value = 1
toast.ok('Invitation sent to magnus@dezky.com')
}
const pricePerSeat = 78
const daysUntilRenewal = 96
const monthly = computed(() => seatsExtra.value * pricePerSeat)
const prorated = computed(() => Math.round(monthly.value * (daysUntilRenewal / 30)))
</script>
<template>
<div>
<PageHeader
eyebrow="Acme Workspace · dezky.com"
title="Dashboard"
subtitle="Health, activity, and quick actions across your workspace."
>
<template #actions>
<UiButton variant="secondary" @click="inviteOpen = true">
<template #leading><UiIcon name="users" :size="14" /></template>
Invite user
</UiButton>
<UiButton variant="primary" @click="router.push('/admin/domains')">
<template #leading><UiIcon name="plus" :size="14" /></template>
Add domain
</UiButton>
</template>
</PageHeader>
<div class="content">
<!-- Stat strip single Card pad=0 with 4-col grid + inner right borders -->
<Card :pad="0" class="strip">
<div class="strip-grid">
<div v-for="(s, i) in stats" :key="s.label" class="strip-cell" :class="{ noborder: i === stats.length - 1 }">
<Stat
:label="s.label"
:value="s.value"
:delta="s.delta"
:delta-tone="s.deltaTone"
:hint="s.hint"
/>
</div>
</div>
</Card>
<!-- License usage + Recent admin events -->
<div class="row two-col-14">
<Card>
<div class="card-head card-head-inline">
<div>
<Eyebrow>Plan</Eyebrow>
<div class="card-title">Business · 25 seats</div>
<div class="card-sub">Renewing 28 August 2026 · 1.940 DKK / month</div>
</div>
<UiButton size="sm" variant="secondary" @click="router.push('/admin/billing')">Manage plan</UiButton>
</div>
<div class="progress-block">
<div class="progress-bar"><span style="width: 44%" /></div>
<div class="progress-legend">
<span>11 active</span>
<span>14 available</span>
</div>
</div>
<div class="seats-cta">
<div class="seats-cta-text">
Approaching limit? You can add seats in single increments billed prorated.
</div>
<UiButton size="sm" variant="dark" @click="seatsOpen = true">
<template #leading><UiIcon name="plus" :size="13" /></template>
Add seats
</UiButton>
</div>
</Card>
<Card :pad="0">
<div class="card-block-head">
<Eyebrow>Activity</Eyebrow>
<div class="card-title">Recent admin events</div>
</div>
<div class="audit-list">
<div v-for="a in recent" :key="a.id" class="audit-row">
<StatusDot :color="`var(--${a.tone})`" :size="7" :glow="false" />
<div class="audit-content">
<div class="audit-line">
<span class="audit-actor">{{ a.actor }}</span>
<Mono dim>{{ a.action }}</Mono>
</div>
<Mono dim>{{ a.target }}</Mono>
</div>
<Mono dim>{{ a.when }}</Mono>
</div>
</div>
</Card>
</div>
<!-- Open issues + Quick actions -->
<div class="row two-col-11">
<Card>
<div class="card-head card-head-inline">
<div>
<Eyebrow>Health</Eyebrow>
<div class="card-title">Open issues</div>
</div>
<Badge tone="warn">2 to review</Badge>
</div>
<div class="issues">
<div v-for="it in issues" :key="it.title" class="issue" :data-tone="it.tone">
<div class="issue-body">
<div class="issue-title">{{ it.title }}</div>
<div class="issue-sub">{{ it.body }}</div>
</div>
<UiButton size="sm" variant="secondary" @click="it.onAction()">{{ it.action }}</UiButton>
</div>
</div>
</Card>
<Card>
<div class="card-head card-head-inline">
<div>
<Eyebrow>Quick actions</Eyebrow>
<div class="card-title">Common tasks</div>
</div>
</div>
<div class="qa-grid">
<button v-for="a in quickActions" :key="a.label" class="qa" @click="a.onClick">
<UiIcon :name="a.icon" :size="15" stroke="var(--text-mute)" />
{{ a.label }}
</button>
</div>
</Card>
</div>
</div>
<!-- Invite user · 3-step modal (stubbed: step 1 fields only, but with stepper text) -->
<Modal :open="inviteOpen" :title="'Invite user'" :eyebrow="`Step ${inviteStep} of 3`" size="md" @close="inviteOpen = false; inviteStep = 1">
<div v-if="inviteStep === 1" class="form-stack">
<label class="field"><Eyebrow>Full name</Eyebrow><input class="input" value="Magnus Eriksen" /></label>
<label class="field"><Eyebrow>Email</Eyebrow><input class="input" value="magnus@dezky.com" /></label>
<label class="field"><Eyebrow>Role</Eyebrow>
<div class="radio-row">
<button class="active">Member</button><button>Admin</button>
</div>
</label>
<label class="field"><Eyebrow>License tier</Eyebrow>
<div class="radio-row">
<button>Basic</button><button class="active">Business</button>
</div>
</label>
</div>
<div v-else-if="inviteStep === 2" class="form-stack">
<div>
<Eyebrow>Group memberships</Eyebrow>
<div class="check-stack">
<label v-for="(g, i) in ['Engineering', 'Design', 'Operations', 'Finance', 'Sales']" :key="g">
<input type="checkbox" :checked="i === 0" /> {{ g }}
</label>
</div>
</div>
<div>
<Eyebrow>Apps</Eyebrow>
<div class="check-stack">
<label v-for="a in ['Mail', 'Drev', 'Møder', 'Chat']" :key="a">
<input type="checkbox" checked /> {{ a }}
</label>
</div>
</div>
</div>
<div v-else>
<div class="review-box">
<dl class="def">
<div><dt>Name</dt><dd>Magnus Eriksen</dd></div>
<div><dt>Email</dt><dd>magnus@dezky.com</dd></div>
<div><dt>Role</dt><dd>Member · Business</dd></div>
<div><dt>Groups</dt><dd>Engineering</dd></div>
<div><dt>Apps</dt><dd>Mail · Drev · Møder · Chat</dd></div>
</dl>
</div>
<div class="muted">
We'll provision the account across Authentik, Stalwart, OCIS, Jitsi and Zulip, then email Magnus an activation link valid for 7 days.
</div>
</div>
<template #footer>
<UiButton variant="ghost" @click="inviteOpen = false; inviteStep = 1">Cancel</UiButton>
<UiButton v-if="inviteStep > 1" variant="secondary" @click="inviteStep--">Back</UiButton>
<UiButton v-if="inviteStep < 3" variant="primary" @click="inviteStep++">Continue</UiButton>
<UiButton v-else variant="primary" @click="sendInvite">Send invitation</UiButton>
</template>
</Modal>
<!-- Add seats — strict port of AddSeatsModal -->
<Modal :open="seatsOpen" title="Add seats" eyebrow="Billing · seats" size="md" @close="seatsOpen = false">
<div class="seats">
<div class="seats-grid">
<div class="seats-cell"><Eyebrow>Active users</Eyebrow><div class="seats-big">11</div></div>
<div class="seats-cell"><Eyebrow>Current seats</Eyebrow><div class="seats-big">25</div></div>
<div class="seats-cell"><Eyebrow>After change</Eyebrow><div class="seats-big ok">{{ 25 + seatsExtra }}</div></div>
</div>
<div>
<Eyebrow>How many seats to add</Eyebrow>
<div class="stepper-row">
<button class="step-btn" @click="seatsExtra = Math.max(1, seatsExtra - 1)"></button>
<input type="number" :value="seatsExtra" @input="(e) => (seatsExtra = Math.max(1, Math.min(500, parseInt((e.target as HTMLInputElement).value || '0') || 1)))" class="step-num" />
<button class="step-btn" @click="seatsExtra = Math.min(500, seatsExtra + 1)">+</button>
</div>
<div class="quick-amounts">
<button v-for="n in [5, 10, 25, 50]" :key="n" :class="{ active: seatsExtra === n }" @click="seatsExtra = n">+{{ n }}</button>
</div>
</div>
<div class="charge-summary">
<Eyebrow>What you'll pay</Eyebrow>
<div class="charge-row"><span>{{ seatsExtra }} new seat{{ seatsExtra === 1 ? '' : 's' }} × {{ pricePerSeat }} DKK / month</span><Mono>{{ monthly.toLocaleString('da-DK') }} DKK / mo</Mono></div>
<div class="charge-row sep"><span class="muted">Prorated for current cycle ({{ daysUntilRenewal }} days until renewal)</span><Mono dim>{{ prorated.toLocaleString('da-DK') }} DKK</Mono></div>
<div class="charge-row total"><span>Charged today</span><span class="big">{{ prorated.toLocaleString('da-DK') }} DKK</span></div>
<div class="charge-row"><span class="muted">Next invoice on 01 Jun 2026</span><Mono dim>{{ (1940 + monthly).toLocaleString('da-DK') }} DKK</Mono></div>
</div>
<div class="info-strip">
<UiIcon name="card" :size="14" stroke="var(--text-mute)" />
<span>Charged to <Mono>Visa 4242</Mono>. Seats are added instantly invitations can be sent right away.</span>
</div>
</div>
<template #footer>
<UiButton variant="ghost" @click="seatsOpen = false">Cancel</UiButton>
<UiButton variant="primary" @click="() => { seatsOpen = false; toast.ok(`${seatsExtra} seats added · charged ${prorated.toLocaleString('da-DK')} DKK`) }">
<template #leading><UiIcon name="plus" :size="13" /></template>
Add {{ seatsExtra }} seat{{ seatsExtra === 1 ? '' : 's' }}
</UiButton>
</template>
</Modal>
</div>
</template>
<style scoped>
.content { padding: 24px 40px 64px 40px; }
.row { display: grid; gap: 16px; margin-top: 16px; }
.two-col-14 { grid-template-columns: 1.4fr 1fr; }
.two-col-11 { grid-template-columns: 1fr 1fr; }
.strip { margin-bottom: 16px; }
.strip-grid { display: grid; grid-template-columns: repeat(4, 1fr); }
.strip-cell { padding: 24px; border-right: 1px solid var(--border); }
.strip-cell.noborder { border-right: none; }
.card-head {
padding: 20px 24px 16px 24px;
border-bottom: 1px solid var(--border);
}
.card-head-inline { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
.card-block-head { padding: 20px 24px 12px 24px; }
.card-title {
font-family: var(--font-display);
font-weight: 600;
font-size: 18px;
letter-spacing: -0.01em;
margin-top: 4px;
}
.card-sub { font-size: 13px; color: var(--text-mute); margin-top: 4px; }
/* License progress */
.progress-block { margin-bottom: 16px; }
.progress-bar {
height: 8px;
background: var(--bg);
border-radius: 999px;
overflow: hidden;
}
.progress-bar span { display: block; height: 100%; background: var(--text); }
.progress-legend {
display: flex;
justify-content: space-between;
margin-top: 8px;
font-family: var(--font-mono);
font-size: 12px;
color: var(--text-mute);
}
/* Add-seats CTA box (dashed) */
.seats-cta {
padding: 16px;
background: var(--bg);
border-radius: 6px;
border: 1px dashed var(--border-hi, var(--border));
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.seats-cta-text { font-size: 13px; color: var(--text-dim); }
/* Audit list */
.audit-list { padding: 0 8px 8px 8px; }
.audit-row {
padding: 10px 16px;
display: flex;
align-items: center;
gap: 12px;
border-radius: 6px;
font-size: 13px;
}
.audit-content { flex: 1; min-width: 0; }
.audit-line { display: flex; gap: 6px; align-items: baseline; flex-wrap: wrap; }
.audit-actor { font-weight: 500; }
/* Issues — strict bg with left tone border */
.issues { display: flex; flex-direction: column; gap: 10px; }
.issue {
padding: 14px;
background: var(--bg);
border-radius: 6px;
display: flex;
align-items: center;
gap: 12px;
border-left: 2px solid var(--border);
}
.issue[data-tone='ok'] { border-left-color: var(--ok); }
.issue[data-tone='warn'] { border-left-color: var(--warn); }
.issue[data-tone='bad'] { border-left-color: var(--bad); }
.issue[data-tone='info'] { border-left-color: var(--info); }
.issue-body { flex: 1; min-width: 0; }
.issue-title { font-size: 13px; font-weight: 500; }
.issue-sub { font-size: 12px; color: var(--text-mute); margin-top: 2px; }
/* Quick actions — 2-col grid of "tiles" */
.qa-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
.qa {
background: var(--surface);
border: 1px solid var(--border);
padding: 14px 16px;
border-radius: 6px;
cursor: pointer;
display: flex;
align-items: center;
gap: 10px;
font-size: 13px;
font-weight: 500;
color: var(--text);
font-family: inherit;
text-align: left;
}
.qa:hover { background: var(--elevated, var(--row-hover, var(--surface))); }
/* Invite modal helpers */
.form-stack { display: flex; flex-direction: column; gap: 14px; }
.field { display: flex; flex-direction: column; gap: 6px; }
.input {
height: 36px;
padding: 0 12px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
font-family: inherit;
font-size: 13px;
color: var(--text);
outline: none;
}
.input:focus { border-color: var(--text); }
.radio-row { display: inline-flex; border: 1px solid var(--border); border-radius: 6px; padding: 2px; width: fit-content; }
.radio-row button { padding: 6px 14px; border: none; border-radius: 4px; background: transparent; color: var(--text); font-size: 12px; font-weight: 500; font-family: inherit; cursor: pointer; }
.radio-row button.active { background: var(--text); color: var(--bg); }
.check-stack { display: flex; flex-direction: column; gap: 6px; margin-top: 6px; font-size: 13px; }
.check-stack label { display: flex; align-items: center; gap: 8px; }
.review-box { padding: 16px; background: var(--bg); border-radius: 6px; margin-bottom: 16px; }
.def { margin: 0; display: grid; grid-template-columns: 140px 1fr; row-gap: 12px; column-gap: 16px; }
.def > div { display: contents; }
.def dt { font-family: var(--font-mono); font-size: 10px; letter-spacing: 0.12em; text-transform: uppercase; color: var(--text-mute); }
.def dd { margin: 0; font-size: 13px; color: var(--text); }
.muted { font-size: 12px; color: var(--text-mute); line-height: 1.55; }
/* Add seats modal */
.seats { display: flex; flex-direction: column; gap: 18px; }
.seats-grid {
padding: 16px;
background: var(--bg);
border-radius: 8px;
border: 1px solid var(--border);
display: grid;
grid-template-columns: repeat(3, 1fr);
}
.seats-cell { padding: 0 12px; border-right: 1px solid var(--border); }
.seats-cell:first-child { padding-left: 0; }
.seats-cell:last-child { border-right: none; padding-right: 0; }
.seats-big { font-family: var(--font-display); font-weight: 600; font-size: 24px; margin-top: 4px; }
.seats-big.ok { color: var(--ok); }
.stepper-row { display: flex; align-items: center; gap: 12px; margin: 12px 0 10px; }
.step-btn { width: 36px; height: 36px; border-radius: 6px; background: var(--surface); border: 1px solid var(--border); cursor: pointer; font-family: inherit; font-size: 16px; color: var(--text); }
.step-num { flex: 1; height: 56px; padding: 0 16px; background: var(--surface); border: 1px solid var(--border); border-radius: 8px; font-family: var(--font-display); font-size: 32px; font-weight: 600; color: var(--text); text-align: center; outline: none; }
.quick-amounts { display: flex; gap: 6px; flex-wrap: wrap; }
.quick-amounts button { padding: 4px 10px; border-radius: 4px; cursor: pointer; background: var(--surface); color: var(--text); border: 1px solid var(--border); font-family: var(--font-mono); font-size: 11px; }
.quick-amounts button.active { background: var(--text); color: var(--bg); border-color: var(--text); }
.charge-summary { padding: 16px; background: var(--surface); border-radius: 8px; border: 1px solid var(--border); display: flex; flex-direction: column; gap: 8px; }
.charge-row { display: flex; justify-content: space-between; font-size: 13px; align-items: baseline; }
.charge-row.sep { padding-bottom: 8px; border-bottom: 1px solid var(--border); }
.charge-row.total { font-weight: 600; }
.charge-row .big { font-family: var(--font-display); font-size: 18px; letter-spacing: -0.01em; }
.charge-row .muted { color: var(--text-mute); font-weight: 400; }
.info-strip { padding: 12px; background: var(--bg); border-radius: 6px; border: 1px solid var(--border); display: flex; gap: 10px; align-items: flex-start; font-size: 12px; color: var(--text-dim); line-height: 1.55; }
</style>