Files
Ronni Baslund f8618b2bbc feat(portal): real OCIS storage data via refresh-token service auth
The Storage page + endpoint landed earlier but had no working OCIS
backend credential. OCIS has no service-account/client-credentials grant
and trusts a single issuer, and basic auth resolves no user in our
external-IdP setup — so authenticate OcisClient via an OIDC
refresh-token bootstrap instead:

- One-time headless login of svc-platform-api against the ocis provider
  (public client ocis-web, issuer .../o/ocis/) yields a refresh token,
  persisted in Mongo (ocis_credentials) and rotated on every use.
- OcisClient mints access tokens with the refresh_token grant; the
  service user holds the OCIS admin role (OCIS_ADMIN_USER_ID) so
  libregraph ListAllDrives works.
- scripts/bootstrap-ocis.mjs re-runs the bootstrap if the token lapses.
- Dashboard Plan card gains a storage capacity bar beside seats;
  hidden when storage is unavailable.
- compose + .env.example: OCIS service OIDC env and admin user id.
- docs/NEXT-STEPS: document the mechanism and the dead-end alternatives.
2026-05-31 21:29:17 +02:00

492 lines
22 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">
// Customer-admin dashboard. Layout descends from project/platform-screens.jsx
// `AdminDashboard`, but the data is real: workspace identity, seats, spend,
// plan and recent admin events all come from /api/me + /api/tenants/:slug/*.
//
// Storage usage is real now too (OCIS libregraph via /tenants/:slug/storage) —
// shown as a second capacity bar in the Plan card. Sections still without a
// backend (mail-flow health, "open issues" like DMARC/failed-login heuristics)
// stay removed rather than faked until Stalwart metrics / a domain-health
// checker exist.
import type { IconName } from '~/components/UiIcon.vue'
import type { AuditEventDoc, TenantUserDoc } from '~/types/workspace'
const toast = useToast()
const router = useRouter()
const { fetchMe } = useMe()
await fetchMe()
const { tenant, subscription, planLabel, currency, seatLimit, perSeatMonthly, monthlySpend, primaryDomain, renewsAt } = useTenant()
const slug = computed(() => tenant.value?.slug ?? '')
// Workspace users (seat usage) + recent audit, both tenant-scoped. Gated on a
// resolved slug so we don't fire against /api/tenants//... before /me lands.
const { data: users } = await useFetch<TenantUserDoc[]>(
() => `/api/tenants/${slug.value}/users`,
{ key: 'admin-dash-users', default: () => [], immediate: !!slug.value, watch: [slug] },
)
const { data: auditRaw } = await useFetch<AuditEventDoc[]>(
() => `/api/tenants/${slug.value}/audit?limit=6`,
{ key: 'admin-dash-audit', default: () => [], immediate: !!slug.value, watch: [slug] },
)
// Aggregate storage usage (OCIS) — second capacity bar in the Plan card.
interface StorageSummary {
available: boolean
usedBytes: number
quotaBytes: number
freeBytes: number
}
const { data: storage } = await useFetch<StorageSummary | null>(
() => `/api/tenants/${slug.value}/storage`,
{ key: 'admin-dash-storage', default: () => null, immediate: !!slug.value, watch: [slug] },
)
const storageAvailable = computed(() => storage.value?.available === true)
const storagePct = computed(() => percent(storage.value?.usedBytes ?? 0, storage.value?.quotaBytes ?? 0))
const seatsUsed = computed(() => (users.value ?? []).filter((u) => u.active !== false).length)
const seatsAvailable = computed(() => Math.max(0, seatLimit.value - seatsUsed.value))
const seatPct = computed(() =>
seatLimit.value ? Math.min(100, Math.round((seatsUsed.value / seatLimit.value) * 100)) : 0,
)
const moneyFmt = computed(
() => new Intl.NumberFormat('da-DK', { style: 'currency', currency: currency.value, maximumFractionDigits: 0 }),
)
function fmtDate(d: Date | null): string {
return d ? d.toLocaleDateString('da-DK', { day: '2-digit', month: 'long', year: 'numeric' }) : '—'
}
const statusLabel = computed(() => {
const s = tenant.value?.status ?? 'pending'
return s.charAt(0).toUpperCase() + s.slice(1)
})
const stats = computed<Array<{ label: string; value: string; hint: string }>>(() => [
{ label: 'Seats used', value: `${seatsUsed.value} / ${seatLimit.value}`, hint: `${seatsAvailable.value} available` },
{ label: 'Monthly spend', value: moneyFmt.value.format(monthlySpend.value), hint: renewsAt.value ? `renews ${fmtDate(renewsAt.value)}` : '' },
{ label: 'Plan', value: planLabel.value, hint: subscription.value?.cycle ?? '' },
{ label: 'Status', value: statusLabel.value, hint: `${tenant.value?.domains?.length ?? 0} domain${(tenant.value?.domains?.length ?? 0) === 1 ? '' : 's'}` },
])
// Map raw audit events onto the row shape the activity list renders. Tone is
// derived from outcome (failed actions read red); everything else is neutral.
const recent = computed(() =>
(auditRaw.value ?? []).map((e) => ({
id: e._id,
when: new Date(e.at).toLocaleTimeString('da-DK', { hour: '2-digit', minute: '2-digit' }),
actor: e.actorType === 'system' ? 'system' : e.actorEmail ?? '—',
action: e.action,
target: e.resourceName ?? e.resourceId ?? '',
tone: e.outcome === 'failure' ? 'bad' : 'info',
})),
)
const inviteOpen = ref(false)
const inviteStep = ref(1)
const seatsOpen = ref(false)
const seatsExtra = ref(5)
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')
}
// Add-seats modal math, fed by the real subscription. The seat-change mutation
// itself isn't wired yet (subscription PATCH is operator-only), so confirming
// still toasts — but the figures shown are the customer's real numbers.
// perSeatMonthly is already cycle-normalized + in major units.
const pricePerSeat = computed(() => perSeatMonthly.value)
const daysUntilRenewal = computed(() => {
if (!renewsAt.value) return 30
const ms = renewsAt.value.getTime() - Date.now()
return Math.max(0, Math.round(ms / 86_400_000))
})
const monthly = computed(() => seatsExtra.value * pricePerSeat.value)
const prorated = computed(() => Math.round(monthly.value * (daysUntilRenewal.value / 30)))
</script>
<template>
<div>
<PageHeader
:eyebrow="tenant ? `${tenant.name}${primaryDomain ? ` · ${primaryDomain}` : ''}` : 'Workspace'"
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"
: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">{{ planLabel }} · {{ seatLimit }} seats</div>
<div class="card-sub">
<template v-if="renewsAt">Renewing {{ fmtDate(renewsAt) }} · </template>{{ moneyFmt.format(monthlySpend) }} / month
</div>
</div>
<UiButton size="sm" variant="secondary" @click="router.push('/admin/billing')">Manage plan</UiButton>
</div>
<div class="progress-block">
<div class="bar-label">Seats</div>
<div class="progress-bar"><span :style="{ width: `${seatPct}%` }" /></div>
<div class="progress-legend">
<span>{{ seatsUsed }} active</span>
<span>{{ seatsAvailable }} available</span>
</div>
</div>
<div v-if="storageAvailable" class="progress-block">
<div class="bar-label">Storage</div>
<div class="progress-bar"><span :style="{ width: `${storagePct}%` }" /></div>
<div class="progress-legend">
<span>{{ formatBytes(storage!.usedBytes) }} used</span>
<span>{{ formatBytes(storage!.freeBytes) }} free</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 v-if="recent.length === 0" class="audit-empty">
<Mono dim>No recent activity yet.</Mono>
</div>
</div>
</Card>
</div>
<!-- Quick actions -->
<div class="row">
<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 qa-grid-wide">
<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">{{ seatsUsed }}</div></div>
<div class="seats-cell"><Eyebrow>Current seats</Eyebrow><div class="seats-big">{{ seatLimit }}</div></div>
<div class="seats-cell"><Eyebrow>After change</Eyebrow><div class="seats-big ok">{{ seatLimit + 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' }} × {{ moneyFmt.format(pricePerSeat) }} / month</span><Mono>{{ moneyFmt.format(monthly) }} / mo</Mono></div>
<div class="charge-row sep"><span class="muted">Prorated for current cycle ({{ daysUntilRenewal }} days until renewal)</span><Mono dim>{{ moneyFmt.format(prorated) }}</Mono></div>
<div class="charge-row total"><span>Charged today</span><span class="big">{{ moneyFmt.format(prorated) }}</span></div>
<div class="charge-row"><span class="muted"><template v-if="renewsAt">Next invoice on {{ fmtDate(renewsAt) }}</template><template v-else>Next invoice</template></span><Mono dim>{{ moneyFmt.format(monthlySpend + monthly) }}</Mono></div>
</div>
<div class="info-strip">
<UiIcon name="card" :size="14" stroke="var(--text-mute)" />
<span>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 ${moneyFmt.format(prorated)}`) }">
<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; }
.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; }
.bar-label {
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--text-mute);
margin-bottom: 8px;
}
.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; }
.audit-empty { padding: 24px 16px; text-align: center; }
/* Quick actions — grid of "tiles" */
.qa-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
/* Full-width card → 3 columns so the six actions sit in two tidy rows. */
.qa-grid-wide { grid-template-columns: repeat(3, 1fr); }
.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>