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
+610 -156
View File
@@ -1,186 +1,640 @@
<script setup lang="ts">
// Post-login landing. Auth middleware (nuxt-oidc-auth) gates access — anonymous
// visitors get bounced to /login by the customLoginPage config in nuxt.config.ts.
const { user, logout } = useOidcAuth()
// End-user dashboard. Faithfully ports project/platform-screens.jsx
// `EndUserDashboard` — same layout, same spacing tokens, same copy.
import type { IconName } from '~/components/UiIcon.vue'
import { appTiles, currentUser, todayAgenda, recentFiles, needsAttention } from '~/data/enduser'
const toast = useToast()
const router = useRouter()
const presence = ref<'available' | 'meeting' | 'focus' | 'away'>('available')
// Date eyebrow ("Monday, 25 May") + dynamic greeting that follows the source's
// hour-bucket rules.
const now = new Date()
const dateEyebrow = now.toLocaleDateString('en-GB', { weekday: 'long', day: 'numeric', month: 'long' })
const firstName = currentUser.name.split(' ')[0]
const greet = (() => {
const h = now.getHours()
if (h < 5) return 'Still up'
if (h < 12) return 'Good morning'
if (h < 17) return 'Good afternoon'
return 'Good evening'
})()
const previewFile = ref<typeof recentFiles[number] | null>(null)
const joining = ref<typeof todayAgenda[number] | null>(null)
const joinMic = ref(true)
const joinCam = ref(true)
watch(joining, (v) => { if (v) { joinMic.value = true; joinCam.value = true } })
function openApp(name: string) {
toast.info(`Opening ${name}`)
}
const APP_ICONS: Record<string, IconName> = {
mail: 'mail', drev: 'folder', moder: 'video', chat: 'chat',
}
// Tone → icon tint colour for the pending-task icon boxes.
function attentionIconStyle(tone: string) {
if (tone === 'bad') return { background: 'rgba(226, 48, 48, 0.12)', color: 'var(--bad)' }
if (tone === 'warn') return { background: 'rgba(232, 154, 31, 0.12)', color: 'var(--warn)' }
return { background: 'rgba(10, 10, 10, 0.08)', color: 'var(--text)' }
}
function fireAttention(item: typeof needsAttention[number]) {
if (item.target === 'security') return router.push('/security')
if (item.target === 'file') {
previewFile.value = { id: 'attn-q3', name: 'Q3 forecast.xlsx', path: 'Drev · /Finance', updated: 'yesterday', size: '482 KB' }
return
}
toast.ok(`${item.cta} · ${item.title}`)
}
</script>
<template>
<div class="page">
<header class="bar">
<div class="brand">
<span class="brand-tile">
<NodeMark :size="22" />
</span>
<span class="brand-name">dezky</span>
</div>
<div class="me">
<span class="email">{{ user?.userInfo?.email || user?.userName }}</span>
<button class="logout" @click="logout()">sign out</button>
<div class="dash">
<!-- Greeting + presence -->
<header class="head">
<div>
<Eyebrow>{{ dateEyebrow }}</Eyebrow>
<h1>{{ greet }}, {{ firstName }}.</h1>
</div>
<EnduserPresenceSelector v-model="presence" />
</header>
<main class="stage">
<section class="hero">
<p class="eyebrow">Workspace · welcome</p>
<h1>Hi, {{ user?.userInfo?.name || user?.userName }}.</h1>
<p class="tagline">Sovereign workspace platform · all your services in one place.</p>
</section>
<!-- 4 app tiles -->
<section class="tiles">
<button v-for="t in appTiles" :key="t.key" class="tile" @click="openApp(t.name)">
<span class="tile-icon">
<UiIcon :name="APP_ICONS[t.key] ?? 'file'" :size="18" />
</span>
<div class="tile-body">
<div class="tile-name">{{ t.name }}</div>
<div class="tile-badge">{{ t.badge }}</div>
</div>
</button>
</section>
<section class="grid">
<a href="https://files.dezky.local" target="_blank" class="tile">
<span class="tile-name">Files</span>
<span class="tile-meta">OCIS · S3-backed storage</span>
</a>
<a href="https://mail.dezky.local/admin/" target="_blank" class="tile">
<span class="tile-name">Mail</span>
<span class="tile-meta">Stalwart · JMAP/IMAP/SMTP</span>
</a>
<a href="https://office.dezky.local" target="_blank" class="tile">
<span class="tile-name">Office</span>
<span class="tile-meta">Collabora · document editing</span>
</a>
<a href="https://auth.dezky.local" target="_blank" class="tile">
<span class="tile-name">Identity</span>
<span class="tile-meta">Authentik · SSO &amp; access</span>
</a>
</section>
</main>
<!-- Today's meetings + recent files -->
<section class="two-col">
<Card :pad="0">
<div class="card-head">
<div>
<Eyebrow>Today</Eyebrow>
<div class="card-title">Meetings</div>
</div>
<UiButton size="sm" variant="ghost" @click="toast.info('Opening calendar at cal.dezky.com')">
View calendar
<template #trailing><UiIcon name="chevRight" :size="12" /></template>
</UiButton>
</div>
<div
v-for="(m, i) in todayAgenda"
:key="m.id"
class="agenda-row"
:class="{ last: i === todayAgenda.length - 1 }"
>
<div class="agenda-time">{{ m.time }}</div>
<div class="agenda-meta">
<div class="agenda-title">{{ m.title }}</div>
<div class="agenda-with">{{ m.with }}</div>
</div>
<div class="agenda-in">in {{ m.in }}</div>
<UiButton size="sm" variant="primary" @click="joining = m">Join</UiButton>
</div>
</Card>
<Card :pad="0">
<div class="card-head no-action">
<Eyebrow>Recent</Eyebrow>
<div class="card-title">Files</div>
</div>
<button
v-for="(f, i) in recentFiles.slice(0, 5)"
:key="f.id"
class="file-row"
:class="{ last: i === 4 }"
@click="previewFile = f"
>
<span class="file-icon"><UiIcon name="file" :size="14" /></span>
<div class="file-text">
<div class="file-name">{{ f.name }}</div>
<div class="file-meta">{{ f.path }} · {{ f.updated }}</div>
</div>
<UiIcon name="chevRight" :size="12" stroke="var(--text-mute)" />
</button>
</Card>
</section>
<!-- Pending tasks -->
<section class="block">
<Card :pad="0">
<div class="card-head">
<div>
<Eyebrow>Needs your attention</Eyebrow>
<div class="card-title">Pending · {{ needsAttention.length }} items</div>
</div>
<UiButton size="sm" variant="ghost" @click="toast.info('Opening full task list')">
See all
<template #trailing><UiIcon name="chevRight" :size="12" /></template>
</UiButton>
</div>
<div class="attention">
<div
v-for="(t, i) in needsAttention"
:key="t.id"
class="att-row"
:class="{
'right-col': i % 2 === 1,
'bottom': i >= needsAttention.length - 2,
}"
>
<span class="att-icon" :style="attentionIconStyle(t.tone)">
<UiIcon :name="(t.icon as IconName)" :size="14" />
</span>
<div class="att-text">
<div class="att-title">{{ t.title }}</div>
<Mono dim>{{ t.hint }}</Mono>
</div>
<UiButton
size="sm"
:variant="t.tone === 'bad' ? 'primary' : 'secondary'"
@click="fireAttention(t)"
>
{{ t.cta }}
</UiButton>
</div>
</div>
</Card>
</section>
<!-- Announcement + system status -->
<section class="two-col">
<div class="announce">
<div class="announce-text">
<div class="announce-kicker">// announcement</div>
<div class="announce-head">We're moving to single-sign-on next Monday. Set up your authenticator app this week.</div>
<div class="announce-by">posted by Anne · 2h ago</div>
</div>
<UiButton variant="primary" @click="router.push('/security')">Set it up</UiButton>
</div>
<Card :pad="0">
<div class="card-head no-action">
<Eyebrow>System</Eyebrow>
<div class="card-title">All services operational</div>
</div>
<div class="services">
<div v-for="s in ['Mail', 'Drev', 'Møder', 'Chat', 'Auth (SSO)']" :key="s" class="svc-row">
<span class="svc-name">{{ s }}</span>
<div class="svc-state">
<StatusDot color="var(--ok)" :size="7" :glow="false" />
<span>operational</span>
</div>
</div>
</div>
</Card>
</section>
<!-- File preview modal -->
<Modal :open="previewFile !== null" eyebrow="Drev · preview" :title="previewFile?.name" size="md" @close="previewFile = null">
<div class="preview">
<div class="preview-stage">
<span class="preview-icon"><UiIcon name="file" :size="28" /></span>
<Mono dim>preview not available · open in Drev to view</Mono>
</div>
<div class="preview-meta">
<dl>
<div><dt>Location</dt><dd><Mono>{{ previewFile?.path }}</Mono></dd></div>
<div><dt>Modified</dt><dd>{{ previewFile?.updated }}</dd></div>
<div><dt>Size</dt><dd><Mono>{{ previewFile?.size ?? '2.4 MB' }}</Mono></dd></div>
<div><dt>Shared with</dt><dd>3 people · Engineering team</dd></div>
<div><dt>Permissions</dt><dd>You can edit</dd></div>
</dl>
</div>
<div class="preview-actions">
<UiButton size="sm" variant="secondary" @click="toast.ok('Link copied')">
<template #leading><UiIcon name="copy" :size="13" /></template>
Copy link
</UiButton>
<UiButton size="sm" variant="secondary" @click="toast.info('Opening sharing')">
<template #leading><UiIcon name="users" :size="13" /></template>
Manage access
</UiButton>
<UiButton size="sm" variant="ghost" @click="toast.info('Starred')">
<template #leading><UiIcon name="external" :size="13" /></template>
Star
</UiButton>
</div>
</div>
<template #footer>
<UiButton variant="ghost" @click="previewFile = null">Close</UiButton>
<div style="flex: 1" />
<UiButton variant="secondary" @click="toast.info('Downloading')">
<template #leading><UiIcon name="download" :size="13" /></template>
Download
</UiButton>
<UiButton variant="primary" @click="toast.info('Opening in Drev')">
<template #leading><UiIcon name="external" :size="13" /></template>
Open in Drev
</UiButton>
</template>
</Modal>
<!-- Join meeting modal -->
<Modal :open="joining !== null" eyebrow="Møder" :title="joining ? `Join · ${joining.title}` : ''" size="md" @close="joining = null">
<div class="join">
<div class="cam">
<div class="cam-avatar">A</div>
<div class="cam-label">camera preview</div>
</div>
<div class="join-info">
<dl>
<div><dt>Meeting</dt><dd>{{ joining?.title }}</dd></div>
<div><dt>With</dt><dd>{{ joining?.with }}</dd></div>
<div><dt>Starts</dt><dd>{{ joining?.time }} · in {{ joining?.in }}</dd></div>
<div><dt>Room</dt><dd><Mono>meet.dezky.com/{{ joining?.title.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '') }}</Mono></dd></div>
</dl>
</div>
<div class="join-toggles">
<button class="toggle" :class="{ off: !joinMic }" @click="joinMic = !joinMic">
<UiIcon :name="joinMic ? 'check' : 'x'" :size="14" :stroke="joinMic ? 'var(--ok)' : 'var(--bad)'" :stroke-width="2.5" />
<div class="toggle-text">
<div class="toggle-label">Microphone</div>
<Mono dim>{{ joinMic ? 'unmuted' : 'muted' }}</Mono>
</div>
</button>
<button class="toggle" :class="{ off: !joinCam }" @click="joinCam = !joinCam">
<UiIcon :name="joinCam ? 'check' : 'x'" :size="14" :stroke="joinCam ? 'var(--ok)' : 'var(--bad)'" :stroke-width="2.5" />
<div class="toggle-text">
<div class="toggle-label">Camera</div>
<Mono dim>{{ joinCam ? 'on' : 'off' }}</Mono>
</div>
</button>
</div>
</div>
<template #footer>
<UiButton variant="ghost" @click="joining = null">Cancel</UiButton>
<UiButton variant="primary" @click="joining = null; toast.ok('Joining meeting…')">
<template #leading><UiIcon name="video" :size="13" /></template>
Join now
</UiButton>
</template>
</Modal>
</div>
</template>
<style scoped>
.page {
min-height: 100vh;
/* Container — single column, 1400px max, balanced 32 top / 40 sides / 64 bottom. */
.dash {
padding: 32px 40px 64px 40px;
max-width: 1400px;
margin: 0 auto;
}
/* Greeting strip. No subtitle line, no bottom border — the design lets the
tiles below do the visual divide. */
.head {
margin-bottom: 32px;
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 24px;
flex-wrap: wrap;
}
.head h1 {
font-family: var(--font-display);
font-size: 44px;
font-weight: 600;
letter-spacing: -0.03em;
line-height: 1.05;
margin: 8px 0 0 0;
}
/* App tiles — 4 col grid, 130 minHeight, dark inverted icon box. */
.tiles {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
margin-bottom: 24px;
}
.tile {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
padding: 20px;
display: flex;
flex-direction: column;
gap: 18px;
min-height: 130px;
cursor: pointer;
color: inherit;
font-family: inherit;
text-align: left;
transition: border-color 0.12s, background 0.12s;
}
.bar {
padding: 16px 24px;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid var(--border);
}
.brand {
display: flex;
align-items: center;
gap: 10px;
}
.brand-tile {
width: 30px;
height: 30px;
border-radius: 7px;
background: #0a0a0a;
.tile:hover { border-color: var(--text); }
.tile-icon {
width: 36px;
height: 36px;
border-radius: 8px;
background: var(--text);
color: var(--bg);
display: inline-flex;
align-items: center;
justify-content: center;
}
.brand-name {
font-family: var(--font-mono);
font-size: 12px;
font-weight: 600;
}
.me {
display: flex;
align-items: center;
gap: 14px;
}
.email {
font-family: var(--font-mono);
font-size: 12px;
color: var(--text-dim);
}
.logout {
background: transparent;
border: 1px solid var(--border);
border-radius: 6px;
padding: 6px 12px;
font-family: var(--font-mono);
font-size: 11px;
color: var(--text);
}
.logout:hover {
background: rgba(10, 10, 10, 0.04);
}
.stage {
flex: 1;
padding: 48px 24px;
max-width: 960px;
width: 100%;
margin: 0 auto;
}
.hero {
margin-bottom: 48px;
}
.eyebrow {
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--text-mute);
margin: 0 0 12px 0;
}
.hero h1 {
margin: 0;
font-family: var(--font-display);
font-weight: 600;
font-size: 40px;
letter-spacing: -0.025em;
line-height: 1.05;
}
.tagline {
margin: 12px 0 0 0;
color: var(--text-dim);
font-size: 15px;
line-height: 1.5;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 12px;
}
.tile {
background: var(--elevated);
border: 1px solid var(--border);
border-radius: 10px;
padding: 20px;
text-decoration: none;
color: inherit;
display: flex;
flex-direction: column;
gap: 6px;
transition: border-color 120ms ease, transform 120ms ease;
}
.tile:hover {
border-color: var(--border-hi);
transform: translateY(-1px);
}
.tile-name {
font-family: var(--font-display);
font-weight: 600;
font-size: 17px;
letter-spacing: -0.015em;
}
.tile-badge {
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-mute);
margin-top: 6px;
}
.tile-meta {
/* Two-column blocks reused for meetings/files + announce/status */
.two-col {
display: grid;
grid-template-columns: 1.6fr 1fr;
gap: 16px;
margin-top: 16px;
}
.two-col:first-of-type { margin-top: 0; }
/* Stand-alone full-width sections between two-col rows. Source design uses
marginTop: 16 between each top-level block after the tile grid. */
.block { margin-top: 16px; }
/* Card head — eyebrow + 18px title + optional ghost action right */
.card-head {
padding: 20px 24px 16px 24px;
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.card-head.no-action { display: block; }
.card-title {
font-family: var(--font-display);
font-weight: 600;
font-size: 18px;
margin-top: 4px;
letter-spacing: -0.01em;
}
/* Meetings — 4-col row: time / meta / "in X" / Join */
.agenda-row {
padding: 14px 24px;
display: grid;
grid-template-columns: 60px 1fr auto auto;
align-items: center;
gap: 16px;
border-bottom: 1px solid var(--border);
}
.agenda-row.last { border-bottom: none; }
.agenda-time {
font-family: var(--font-mono);
font-size: 14px;
font-weight: 600;
}
.agenda-title { font-size: 14px; font-weight: 500; }
.agenda-with { font-size: 12px; color: var(--text-mute); margin-top: 2px; }
.agenda-in {
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-mute);
}
/* Recent files — 28x28 icon, name + meta line, chev */
.file-row {
padding: 12px 24px;
width: 100%;
display: flex;
align-items: center;
gap: 12px;
background: transparent;
border: none;
border-bottom: 1px solid var(--border);
cursor: pointer;
font-family: inherit;
text-align: left;
color: var(--text);
transition: background 0.1s;
}
.file-row.last { border-bottom: none; }
.file-row:hover { background: var(--row-hover); }
.file-icon {
width: 28px;
height: 28px;
border-radius: 6px;
background: var(--bg);
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--text-mute);
flex-shrink: 0;
}
.file-text { flex: 1; min-width: 0; }
.file-name {
font-size: 13px;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.file-meta {
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-mute);
margin-top: 2px;
}
/* Pending tasks — 2-col grid, dividers only between cells, none on last row */
.attention { display: grid; grid-template-columns: repeat(2, 1fr); gap: 0; }
.att-row {
padding: 14px 24px;
display: flex;
align-items: center;
gap: 12px;
border-bottom: 1px solid var(--border);
}
.att-row:not(.right-col) { border-right: 1px solid var(--border); }
.att-row.bottom { border-bottom: none; }
.att-icon {
width: 30px;
height: 30px;
border-radius: 6px;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.att-text { flex: 1; min-width: 0; }
.att-title { font-size: 13px; font-weight: 500; }
/* Announcement — carbon background, two-up flex row with button on the right */
.announce {
background: var(--text);
color: var(--bg);
border-radius: 8px;
padding: 28px 32px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
}
.announce-text { min-width: 0; }
.announce-kicker {
font-family: var(--font-mono);
font-size: 11px;
color: var(--accent);
letter-spacing: 0.08em;
}
.announce-head {
font-family: var(--font-display);
font-size: 22px;
font-weight: 600;
letter-spacing: -0.02em;
margin-top: 8px;
text-wrap: balance;
line-height: 1.25;
}
.announce-by {
font-family: var(--font-mono);
font-size: 12px;
opacity: 0.6;
margin-top: 8px;
}
/* System services — 5 rows, mono name + green dot + 'operational' */
.services {
padding: 14px 24px 18px 24px;
display: flex;
flex-direction: column;
gap: 10px;
}
.svc-row {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 13px;
}
.svc-name { font-family: var(--font-mono); }
.svc-state {
display: flex;
align-items: center;
gap: 8px;
color: var(--text-mute);
font-size: 12px;
}
/* Preview modal */
.preview { display: flex; flex-direction: column; gap: 14px; }
.preview-stage {
aspect-ratio: 4 / 3;
background: var(--surface);
border-radius: 8px;
border: 1px solid var(--border);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
overflow: hidden;
}
.preview-icon {
width: 64px;
height: 64px;
border-radius: 12px;
background: var(--bg);
color: var(--text-dim);
display: inline-flex;
align-items: center;
justify-content: center;
}
.preview-meta { padding: 14px; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; }
.preview-actions { display: flex; gap: 8px; }
.preview-meta dl, .join-info dl {
margin: 0;
display: flex;
flex-direction: column;
gap: 10px;
}
.preview-meta dl > div, .join-info dl > div { display: flex; gap: 12px; }
.preview-meta dt, .join-info dt {
width: 110px;
flex-shrink: 0;
font-family: var(--font-mono);
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.12em;
color: var(--text-mute);
}
.preview-meta dd, .join-info dd { margin: 0; font-size: 13px; color: var(--text); }
/* Join meeting modal */
.join { display: flex; flex-direction: column; gap: 16px; }
.cam {
aspect-ratio: 16 / 9;
border-radius: 8px;
background: linear-gradient(135deg, #0A0A0A, #1A1A1A);
display: flex;
align-items: center;
justify-content: center;
position: relative;
overflow: hidden;
}
.cam-avatar {
width: 80px;
height: 80px;
border-radius: 999px;
background: var(--accent);
color: var(--accent-fg);
display: inline-flex;
align-items: center;
justify-content: center;
font-family: var(--font-display);
font-weight: 600;
font-size: 32px;
}
.cam-label {
position: absolute;
bottom: 12px;
left: 12px;
color: #F4F3EE;
opacity: 0.6;
font-family: var(--font-mono);
font-size: 11px;
}
.join-info { padding: 14px; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; }
.join-toggles { display: flex; gap: 10px; }
.toggle {
flex: 1;
padding: 12px 14px;
border-radius: 8px;
cursor: pointer;
font-family: inherit;
background: var(--surface);
border: 1px solid var(--border);
display: flex;
align-items: center;
gap: 10px;
text-align: left;
}
.toggle.off {
background: rgba(226, 48, 48, 0.08);
border-color: rgba(226, 48, 48, 0.3);
}
.toggle-text { display: flex; flex-direction: column; }
.toggle-label { font-size: 13px; font-weight: 500; }
</style>