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:
Ronni Baslund
2026-05-28 20:00:33 +02:00
parent be430179d9
commit 0bd4e5498e
144 changed files with 22110 additions and 209 deletions
+316
View File
@@ -0,0 +1,316 @@
<script setup lang="ts">
// Devices & sessions. Faithfully ports project/platform-enduser.jsx
// `DevicesScreen` lines 37233. Two grouped sections: Desktop / Mobile &
// tablet. Per-row "..." menu is portaled (see EnduserDeviceActions).
import { devices } from '~/data/enduser'
const toast = useToast()
// Source groups desktop (kind === 'desktop') vs mobile + tablet. Our `devices`
// fixture uses `laptop` for desktop, plus `phone` / `tablet`.
const desktops = computed(() => devices.filter((d) => d.kind === 'laptop'))
const mobiles = computed(() => devices.filter((d) => d.kind === 'phone' || d.kind === 'tablet'))
const signOutOpen = ref(false)
const keepCurrent = ref(true)
const forceMfa = ref(false)
const renameDevice = ref<typeof devices[number] | null>(null)
const renameValue = ref('')
const revokeDevice = ref<typeof devices[number] | null>(null)
function onRename(d: typeof devices[number]) {
renameDevice.value = d
renameValue.value = d.label
}
function onRevoke(d: typeof devices[number]) {
if (d.current) return
revokeDevice.value = d
}
function confirmRevoke() {
toast.warn(`Revoked ${revokeDevice.value?.label}`, 'Session ended within 30s')
revokeDevice.value = null
}
function confirmRename() {
toast.ok(`Renamed to "${renameValue.value}"`)
renameDevice.value = null
}
function confirmSignOutAll() {
signOutOpen.value = false
const n = keepCurrent.value ? devices.length - 1 : devices.length
toast.warn(`Signed out ${n} sessions`, forceMfa.value ? 'MFA re-enrolment required on next sign-in' : '')
}
</script>
<template>
<div>
<PageHeader
eyebrow="Account"
title="Devices & sessions"
subtitle="Everywhere you've signed into dezky. Revoke anything you don't recognize."
>
<template #actions>
<UiButton variant="danger" @click="signOutOpen = true">
<template #leading><UiIcon name="logout" :size="13" /></template>
Sign out everywhere
</UiButton>
</template>
</PageHeader>
<div class="content">
<!-- Intro card · "Currently signed in" -->
<Card style="margin-bottom: 16px;">
<div class="intro">
<div>
<Eyebrow>Currently signed in</Eyebrow>
<h3>{{ devices.length }} sessions across {{ desktops.length }} desktops and {{ mobiles.length }} mobile / tablet</h3>
</div>
<Mono dim>last refresh · now</Mono>
</div>
<p class="intro-body">
We track every active session. If you sign out everywhere, you'll need to sign in again on each device — including this one.
</p>
</Card>
<!-- Desktop -->
<div class="section-label">
<UiIcon name="device" :size="13" stroke="var(--text-mute)" />
<Mono dim>Desktop · {{ desktops.length }}</Mono>
</div>
<ul class="device-list">
<li v-for="d in desktops" :key="d.id">
<Card :pad="16">
<div class="device">
<div class="device-icon">
<span class="laptop" />
</div>
<div class="device-text">
<div class="device-row">
<span class="device-name">{{ d.label }}</span>
<Mono dim>{{ d.os }}</Mono>
<Badge v-if="d.current" tone="ok" dot>this device</Badge>
<Badge v-if="d.trusted && !d.current" tone="info">trusted</Badge>
<Badge v-if="d.stale" tone="warn">inactive</Badge>
</div>
<div class="device-meta">
<Mono>{{ d.app }}</Mono>
<span>·</span>
<Mono>{{ d.location }}</Mono>
<span>·</span>
<Mono>{{ d.ip }}</Mono>
<span>·</span>
<span>active {{ d.lastActive }}</span>
</div>
</div>
<UiButton v-if="!d.current" size="sm" variant="ghost" @click="onRevoke(d)">
<template #leading><UiIcon name="logout" :size="13" /></template>
Revoke
</UiButton>
<EnduserDeviceActions
:device="d"
@rename="onRename"
@trust="toast.ok(`${$event.label} ${$event.trusted ? 'untrusted' : 'trusted'}`)"
@history="toast.info(`Viewing history of ${$event.label}`)"
@revoke="onRevoke"
/>
</div>
</Card>
</li>
</ul>
<!-- Mobile & tablet -->
<div class="section-label" style="margin-top: 24px;">
<UiIcon name="device" :size="13" stroke="var(--text-mute)" />
<Mono dim>Mobile & tablet · {{ mobiles.length }}</Mono>
</div>
<ul class="device-list">
<li v-for="d in mobiles" :key="d.id">
<Card :pad="16">
<div class="device">
<div class="device-icon">
<span :class="d.kind === 'tablet' ? 'tablet' : 'phone'" />
</div>
<div class="device-text">
<div class="device-row">
<span class="device-name">{{ d.label }}</span>
<Mono dim>{{ d.os }}</Mono>
<Badge v-if="d.trusted" tone="info">trusted</Badge>
</div>
<div class="device-meta">
<Mono>{{ d.app }}</Mono>
<span>·</span>
<Mono>{{ d.location }}</Mono>
<span>·</span>
<Mono>{{ d.ip }}</Mono>
<span>·</span>
<span>active {{ d.lastActive }}</span>
</div>
</div>
<UiButton size="sm" variant="ghost" @click="onRevoke(d)">
<template #leading><UiIcon name="logout" :size="13" /></template>
Revoke
</UiButton>
<EnduserDeviceActions
:device="d"
@rename="onRename"
@trust="toast.ok(`${$event.label} ${$event.trusted ? 'untrusted' : 'trusted'}`)"
@history="toast.info(`Viewing history of ${$event.label}`)"
@revoke="onRevoke"
/>
</div>
</Card>
</li>
</ul>
</div>
<!-- Sign-out everywhere modal -->
<Modal :open="signOutOpen" eyebrow="Destructive · all sessions" title="Sign out everywhere?" size="md" @close="signOutOpen = false">
<div class="modal-stack">
<div class="callout-bad">
<UiIcon name="shield" :size="16" />
<div>All other sessions will be revoked immediately. On each device, you'll need to sign in again with your password and MFA. Anyone using a stolen session token will lose access.</div>
</div>
<div class="rows">
<div class="row">
<div>
<div class="row-title">Keep this device signed in</div>
<Mono dim>recommended · you won't get locked out</Mono>
</div>
<EnduserToggle v-model="keepCurrent" />
</div>
<div class="row">
<div>
<div class="row-title">Force MFA re-enrolment</div>
<Mono dim>use if you think your authenticator was compromised</Mono>
</div>
<EnduserToggle v-model="forceMfa" />
</div>
</div>
<Mono dim>any pending file uploads or chat messages on the other devices will be lost</Mono>
</div>
<template #footer>
<UiButton variant="ghost" @click="signOutOpen = false">Cancel</UiButton>
<UiButton variant="danger" @click="confirmSignOutAll">
<template #leading><UiIcon name="logout" :size="13" /></template>
{{ keepCurrent ? `Sign out ${devices.length - 1} other sessions` : `Sign out all ${devices.length} sessions` }}
</UiButton>
</template>
</Modal>
<!-- Rename modal -->
<Modal :open="renameDevice !== null" eyebrow="Device · rename" :title="renameDevice ? `Rename ${renameDevice.label}` : ''" size="sm" @close="renameDevice = null">
<EnduserFormField label="Device name">
<input v-model="renameValue" placeholder="e.g. Work laptop, Anne's iPhone" />
</EnduserFormField>
<Mono dim style="margin-top: 10px; display: block;">shown to you here and on the workspace audit log · co-workers do not see it</Mono>
<template #footer>
<UiButton variant="ghost" @click="renameDevice = null">Cancel</UiButton>
<UiButton variant="primary" :disabled="!renameValue.trim()" @click="confirmRename">
<template #leading><UiIcon name="check" :size="13" /></template>
Save name
</UiButton>
</template>
</Modal>
<!-- Revoke confirm (Modal · matches source DefList layout) -->
<Modal :open="revokeDevice !== null" eyebrow="Revoke session" :title="revokeDevice ? `Sign out ${revokeDevice.label}?` : ''" size="md" @close="revokeDevice = null">
<div class="revoke-stack">
<div class="revoke-detail">
<dl>
<div><dt>Device</dt><dd>{{ revokeDevice?.label }}</dd></div>
<div><dt>OS</dt><dd>{{ revokeDevice?.os }}</dd></div>
<div><dt>App</dt><dd>{{ revokeDevice?.app }}</dd></div>
<div><dt>Location</dt><dd>{{ revokeDevice?.location }} · {{ revokeDevice?.ip }}</dd></div>
<div><dt>Last active</dt><dd>{{ revokeDevice?.lastActive }}</dd></div>
</dl>
</div>
<p class="revoke-text">
This device will be signed out within 30 seconds. The person using it will be returned to the sign-in screen and any unsaved work in the app may be lost.
</p>
<div v-if="revokeDevice?.trusted" class="callout-warn">
<UiIcon name="shield" :size="14" />
<div>This is a <b>trusted device</b>. Revoking removes the trust — next sign-in will require MFA.</div>
</div>
</div>
<template #footer>
<UiButton variant="ghost" @click="revokeDevice = null">Cancel</UiButton>
<UiButton variant="danger" @click="confirmRevoke">
<template #leading><UiIcon name="logout" :size="13" /></template>
Sign out device
</UiButton>
</template>
</Modal>
</div>
</template>
<style scoped>
.content { padding: 20px 40px 64px 40px; max-width: 1000px; }
.intro { display: flex; justify-content: space-between; align-items: flex-start; gap: 24px; }
.intro h3 { font-family: var(--font-display); font-weight: 600; font-size: 17px; margin: 4px 0 0 0; }
.intro-body { margin: 12px 0 0 0; font-size: 13px; color: var(--text-mute); line-height: 1.6; }
.section-label { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; color: var(--text-mute); }
.device-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 8px; }
.device { display: flex; align-items: center; gap: 14px; }
.device-icon {
width: 48px; height: 48px; border-radius: 8px;
background: var(--bg); border: 1px solid var(--border);
display: inline-flex; align-items: center; justify-content: center;
color: var(--text-dim); flex-shrink: 0;
}
/* Source CSS device glyphs — laptop = rounded rect + base nub,
phone = tall portrait rect, tablet = wider rect. */
.laptop { width: 38px; height: 26px; border: 1.5px solid currentColor; border-radius: 4px; position: relative; }
.laptop::after { content: ''; position: absolute; left: 50%; bottom: -5px; transform: translateX(-50%); width: 14px; height: 2px; background: currentColor; border-radius: 1px; }
.phone { width: 22px; height: 36px; border: 1.5px solid currentColor; border-radius: 12px; position: relative; }
.phone::after { content: ''; position: absolute; left: 50%; bottom: 3px; transform: translateX(-50%); width: 8px; height: 1px; background: currentColor; opacity: 0.6; border-radius: 1px; }
.tablet { width: 30px; height: 36px; border: 1.5px solid currentColor; border-radius: 10px; position: relative; }
.tablet::after { content: ''; position: absolute; left: 50%; bottom: 3px; transform: translateX(-50%); width: 8px; height: 1px; background: currentColor; opacity: 0.6; border-radius: 1px; }
.device-text { flex: 1; min-width: 0; }
.device-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.device-name { font-size: 14px; font-weight: 500; }
.device-meta { display: flex; align-items: center; gap: 10px; margin-top: 6px; font-size: 12px; color: var(--text-mute); flex-wrap: wrap; }
.modal-stack { display: flex; flex-direction: column; gap: 14px; }
.callout-bad {
padding: 14px;
background: rgba(226, 48, 48, 0.06);
border: 1px solid rgba(226, 48, 48, 0.20);
border-radius: 6px;
display: flex; gap: 10px;
font-size: 13px; color: var(--text-dim); line-height: 1.5;
}
.callout-bad :deep(svg) { color: var(--bad); margin-top: 2px; flex-shrink: 0; }
.callout-warn {
padding: 12px;
background: rgba(232, 154, 31, 0.06);
border: 1px solid rgba(232, 154, 31, 0.20);
border-radius: 6px;
font-size: 12px; color: var(--text-dim); line-height: 1.55;
display: flex; gap: 10px;
}
.callout-warn :deep(svg) { color: var(--warn); margin-top: 2px; flex-shrink: 0; }
.rows {
padding: 12px;
background: var(--bg); border: 1px solid var(--border); border-radius: 6px;
display: flex; flex-direction: column; gap: 12px;
}
.row { display: flex; align-items: center; justify-content: space-between; }
.row + .row { border-top: 1px solid var(--border); padding-top: 12px; }
.row-title { font-size: 13px; font-weight: 500; }
.revoke-stack { display: flex; flex-direction: column; gap: 14px; }
.revoke-detail { padding: 14px; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; }
.revoke-detail dl { margin: 0; display: flex; flex-direction: column; gap: 10px; }
.revoke-detail dl > div { display: flex; gap: 12px; }
.revoke-detail dt { font-family: var(--font-mono); font-size: 10px; text-transform: uppercase; letter-spacing: 0.12em; color: var(--text-mute); width: 110px; flex-shrink: 0; }
.revoke-detail dd { margin: 0; font-size: 13px; color: var(--text); }
.revoke-text { margin: 0; font-size: 13px; line-height: 1.6; color: var(--text-dim); }
</style>