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
+506
View File
@@ -0,0 +1,506 @@
<script setup lang="ts">
// Strict port of project/platform-collab.jsx `IntegrationsScreen` (lines
// 440-575) with IntegrationTile (589) and IntegrationDetail (622).
// 4 tabs: Marketplace · Connected · Webhooks · API tokens.
import { integrations, integrationCategories, type Integration } from '~/data/workspace'
const tab = ref<'marketplace' | 'connected' | 'webhooks' | 'api'>('marketplace')
const cat = ref<typeof integrationCategories[number]>('All')
const open = ref<Integration | null>(null)
const buildCustomOpen = ref(false)
const newWebhookOpen = ref(false)
const newTokenOpen = ref(false)
const disconnectOpen = ref(false)
const revokeToken = ref<{ name: string; suffix: string } | null>(null)
const toast = useToast()
function connectedAction(i: Integration, id: string) {
if (id === 'configure') open.value = i
else if (id === 'logs') toast.info(`Logs for ${i.name}`)
else if (id === 'sync') toast.info(`Syncing ${i.name} now`)
else if (id === 'disconnect') { open.value = i; disconnectOpen.value = true }
}
const connectedItems = [
{ id: 'configure', label: 'Configure', icon: 'brush' as const },
{ id: 'logs', label: 'View logs', icon: 'file' as const },
{ id: 'sync', label: 'Sync now', icon: 'refresh' as const },
{ id: 'sep1', separator: true },
{ id: 'disconnect', label: 'Disconnect', icon: 'plug' as const, danger: true },
]
function confirmDisconnect() {
const name = open.value?.name
disconnectOpen.value = false
open.value = null
toast.warn(`${name} disconnected`)
}
function confirmRevoke() {
const name = revokeToken.value?.name
revokeToken.value = null
toast.bad(`${name} revoked`)
}
const filtered = computed(() => {
if (tab.value === 'connected') return integrations.filter((i) => i.connected)
if (cat.value === 'All') return integrations
return integrations.filter((i) => i.cat === cat.value)
})
const connectedCount = computed(() => integrations.filter((i) => i.connected).length)
const apiTokens = [
{ name: 'CI deploy token', prefix: 'dz_live_', suffix: 'a91f', scope: 'users:read · billing:read', created: '14 Feb 2026', lastUsed: '2 min ago' },
{ name: 'Monitoring scrape', prefix: 'dz_live_', suffix: '88ce', scope: 'metrics:read', created: '02 Mar 2026', lastUsed: '14 sec ago' },
{ name: 'Old migration · revoke', prefix: 'dz_live_', suffix: '441b', scope: 'admin:*', created: '11 Jan 2026', lastUsed: '24 d ago' },
]
</script>
<template>
<div>
<PageHeader
eyebrow="Marketplace"
title="Integrations"
subtitle="Connect dezky to the tools your team already uses."
>
<template #actions>
<UiButton variant="secondary" @click="buildCustomOpen = true">
<template #leading><UiIcon name="plug" :size="14" /></template>
Build custom · API
</UiButton>
</template>
</PageHeader>
<div class="tab-wrap">
<Tabs
v-model="tab"
:items="[
{ value: 'marketplace', label: 'Marketplace', count: integrations.length },
{ value: 'connected', label: 'Connected', count: connectedCount },
{ value: 'webhooks', label: 'Webhooks' },
{ value: 'api', label: 'API tokens' },
]"
/>
</div>
<!-- Marketplace -->
<div v-if="tab === 'marketplace'" class="content">
<div class="cat-row">
<button
v-for="c in integrationCategories"
:key="c"
class="pill"
:class="{ active: cat === c }"
@click="cat = c"
>
{{ c }}
<span v-if="c === 'Accounting'" class="dk-flag" :class="{ active: cat === c }">DK</span>
</button>
<div class="spacer" />
<div class="input-search">
<UiIcon name="search" :size="14" stroke="var(--text-mute)" />
<input placeholder="Search integrations…" />
</div>
</div>
<div class="tile-grid">
<button v-for="i in filtered" :key="i.id" class="tile" @click="open = i">
<div class="tile-head">
<div class="i-icon" :style="{ background: i.color, color: i.accent }">{{ i.icon }}</div>
<Badge v-if="i.connected" tone="ok" dot>connected</Badge>
<Badge v-else-if="i.danish" tone="info">DK</Badge>
</div>
<div class="tile-body">
<div class="tile-name-row">
<span class="tile-name">{{ i.name }}</span>
<Mono dim>· {{ i.cat }}</Mono>
</div>
<div class="tile-desc">{{ i.desc }}</div>
</div>
<div class="tile-foot">
<Mono dim>{{ i.kind }}</Mono>
<span v-if="i.connected" class="users">{{ i.users }} users</span>
<span v-else class="connect">Connect</span>
</div>
</button>
</div>
</div>
<!-- Connected -->
<div v-else-if="tab === 'connected'" class="content">
<Card :pad="0">
<table class="tbl">
<thead>
<tr><th>Integration</th><th>Type</th><th>Users</th><th>Status</th><th /></tr>
</thead>
<tbody>
<tr v-for="i in filtered" :key="i.id">
<td>
<div class="conn-cell">
<div class="i-icon small" :style="{ background: i.color, color: i.accent }">{{ i.icon }}</div>
<div>
<div class="conn-name">{{ i.name }}</div>
<Mono dim>{{ i.cat }}</Mono>
</div>
</div>
</td>
<td><Mono>{{ i.kind }}</Mono></td>
<td><Mono>{{ i.users || 0 }}</Mono></td>
<td><Badge tone="ok" dot>connected</Badge></td>
<td class="right">
<UiButton size="sm" variant="ghost" @click="open = i">Configure</UiButton>
<AdminKebabMenu :items="connectedItems" :icon-size="13" @select="(id) => connectedAction(i, id)" />
</td>
</tr>
</tbody>
</table>
</Card>
</div>
<!-- Webhooks -->
<div v-else-if="tab === 'webhooks'" class="content">
<div class="empty-card">
<UiIcon name="plug" :size="28" stroke="var(--text-mute)" />
<div class="empty-title">No webhooks yet</div>
<div class="empty-body">Webhooks let external services react to events in dezky (user.created, file.shared, billing.charged, etc.).</div>
<UiButton variant="primary" @click="newWebhookOpen = true">
<template #leading><UiIcon name="plus" :size="14" /></template>
New webhook
</UiButton>
</div>
</div>
<!-- API tokens -->
<div v-else class="content api">
<div class="row">
<div class="lead">API tokens authenticate scripts and external services to your workspace. Treat them like passwords.</div>
<UiButton variant="primary" @click="newTokenOpen = true">
<template #leading><UiIcon name="plus" :size="14" /></template>
Generate token
</UiButton>
</div>
<Card :pad="0">
<table class="tbl">
<thead>
<tr><th>Token</th><th>Scope</th><th>Created</th><th>Last used</th><th /></tr>
</thead>
<tbody>
<tr v-for="t in apiTokens" :key="t.name">
<td>
<div class="tok-name">{{ t.name }}</div>
<Mono dim>{{ t.prefix }}····{{ t.suffix }}</Mono>
</td>
<td><Mono dim>{{ t.scope }}</Mono></td>
<td><Mono dim>{{ t.created }}</Mono></td>
<td><Mono dim>{{ t.lastUsed }}</Mono></td>
<td class="right">
<UiButton size="sm" variant="danger" @click="revokeToken = { name: t.name, suffix: t.suffix }">
<template #leading><UiIcon name="trash" :size="13" /></template>
Revoke
</UiButton>
</td>
</tr>
</tbody>
</table>
</Card>
</div>
<!-- Detail side panel -->
<SidePanel :open="!!open" :eyebrow="open?.cat || ''" :title="open?.name || ''" width="lg" @close="open = null">
<div v-if="open" class="detail">
<div class="detail-head">
<div class="i-icon big" :style="{ background: open.color, color: open.accent }">{{ open.icon }}</div>
<div class="detail-meta">
<div class="detail-name">{{ open.name }}</div>
<Mono dim>{{ open.cat }} · {{ open.kind }}</Mono>
<div style="margin-top: 8px">
<Badge v-if="open.connected" tone="ok" dot>connected · {{ open.users }} users</Badge>
<Badge v-else tone="neutral">not connected</Badge>
</div>
</div>
</div>
<div class="detail-desc">{{ open.desc }}</div>
<template v-if="!open.connected">
<Eyebrow>What this integration does</Eyebrow>
<div class="bullets">
<div v-for="b in [
`Provisions users from dezky into ${open.name} on invite`,
`Single sign-on via Authentik · removes ${open.name} passwords`,
`Group sync · dezky groups become ${open.name} teams`,
'Audit trail · sign-ins logged in your global audit log',
]" :key="b" class="bullet">
<UiIcon name="check" :size="12" stroke="var(--ok)" :stroke-width="2.5" />
<span>{{ b }}</span>
</div>
</div>
</template>
<template v-else>
<Eyebrow>Configuration</Eyebrow>
<div class="cfg">
<div class="cfg-row">
<Mono dim>SSO endpoint</Mono>
<Mono>https://sso.dezky.com/{{ open.id }}</Mono>
</div>
<div class="cfg-row">
<Mono dim>Last sign-in</Mono>
<span>2 minutes ago · anne@dezky.com</span>
</div>
<div class="cfg-row">
<Mono dim>Last sync</Mono>
<span>5 minutes ago · 11 users</span>
</div>
</div>
</template>
</div>
<template #footer>
<template v-if="open?.connected">
<UiButton variant="danger" @click="disconnectOpen = true">
<template #leading><UiIcon name="plug" :size="13" /></template>
Disconnect
</UiButton>
<div style="flex: 1" />
<UiButton variant="secondary" @click="toast.info(`Logs for ${open?.name}`)">View logs</UiButton>
<UiButton variant="primary" @click="open = null; toast.ok('Settings saved')">Save changes</UiButton>
</template>
<template v-else>
<UiButton variant="ghost" @click="open = null">Cancel</UiButton>
<div style="flex: 1" />
<UiButton variant="primary" @click="toast.ok(`${open?.name} connected`); open = null">
<template #leading><UiIcon name="plug" :size="13" /></template>
Connect {{ open?.name }}
</UiButton>
</template>
</template>
</SidePanel>
<!-- Build custom API modal stub -->
<Modal :open="buildCustomOpen" eyebrow="Integrations · custom" title="Build a custom integration" size="md" @close="buildCustomOpen = false">
<div class="form-stack">
<div class="lead">
Use dezky's REST API + webhooks to wire any system into your workspace. Token-scoped,
rate-limited, and audit-logged.
</div>
<label class="field"><Eyebrow>Integration name</Eyebrow><input class="input" placeholder="Acme finance bridge" /></label>
<label class="field"><Eyebrow>Description</Eyebrow><input class="input" placeholder="Posts invoice events to /accounts" /></label>
</div>
<template #footer>
<UiButton variant="ghost" @click="buildCustomOpen = false">Cancel</UiButton>
<UiButton variant="primary" @click="buildCustomOpen = false; tab = 'api'; toast.info('Generate a token to start')">
<template #leading><UiIcon name="check" :size="13" /></template>
Continue
</UiButton>
</template>
</Modal>
<!-- New webhook modal -->
<Modal :open="newWebhookOpen" eyebrow="Integrations · webhooks" title="New webhook" size="md" @close="newWebhookOpen = false">
<div class="form-stack">
<label class="field"><Eyebrow>Endpoint URL</Eyebrow><input class="input" placeholder="https://example.com/dezky" /></label>
<label class="field"><Eyebrow>Events</Eyebrow><input class="input" placeholder="user.created, file.shared" /></label>
<label class="field"><Eyebrow>Signing secret</Eyebrow><input class="input" value="auto-generated · copy after save" /></label>
</div>
<template #footer>
<UiButton variant="ghost" @click="newWebhookOpen = false">Cancel</UiButton>
<UiButton variant="primary" @click="newWebhookOpen = false; toast.ok('Webhook created')">Create webhook</UiButton>
</template>
</Modal>
<!-- New API token modal -->
<Modal :open="newTokenOpen" eyebrow="Integrations · API tokens" title="New API token" size="md" @close="newTokenOpen = false">
<div class="form-stack">
<label class="field"><Eyebrow>Token name</Eyebrow><input class="input" placeholder="CI deploy token" /></label>
<label class="field"><Eyebrow>Scopes</Eyebrow><input class="input" placeholder="users:read · billing:read" /></label>
<label class="field"><Eyebrow>Expires</Eyebrow>
<select class="input"><option>30 days</option><option>90 days</option><option>1 year</option><option>Never</option></select>
</label>
</div>
<template #footer>
<UiButton variant="ghost" @click="newTokenOpen = false">Cancel</UiButton>
<UiButton variant="primary" @click="newTokenOpen = false; toast.ok('Token created — copy now, it will not be shown again')">
<template #leading><UiIcon name="key" :size="13" /></template>
Generate token
</UiButton>
</template>
</Modal>
<!-- Confirm disconnect -->
<ConfirmDialog
:open="disconnectOpen"
eyebrow="Integration"
:title="`Disconnect ${open?.name || ''}?`"
confirm-label="Disconnect"
tone="danger"
@close="disconnectOpen = false"
@confirm="confirmDisconnect"
>
Existing user sessions in {{ open?.name }} will keep working until they expire, but new
sign-ins and provisioning will stop immediately.
</ConfirmDialog>
<!-- Confirm revoke API token -->
<ConfirmDialog
:open="!!revokeToken"
eyebrow="API token"
:title="`Revoke ${revokeToken?.name || ''}?`"
confirm-label="Revoke token"
tone="danger"
@close="revokeToken = null"
@confirm="confirmRevoke"
>
Any script using <Mono>dz_live_····{{ revokeToken?.suffix }}</Mono> will fail
authentication immediately. This cannot be undone.
</ConfirmDialog>
</div>
</template>
<style scoped>
.tab-wrap { padding: 16px 40px 0 40px; }
.content { padding: 20px 40px 64px 40px; }
.cat-row { display: flex; align-items: center; gap: 8px; margin-bottom: 20px; flex-wrap: wrap; }
.pill {
padding: 6px 12px;
border-radius: 999px;
background: transparent;
color: var(--text-dim);
border: 1px solid var(--border);
font-size: 12px;
font-weight: 500;
font-family: inherit;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 6px;
}
.pill.active { background: var(--text); color: var(--bg); border-color: var(--text); }
.dk-flag {
font-family: var(--font-mono);
font-size: 9px;
padding: 1px 5px;
background: var(--bg);
color: var(--text-mute);
border-radius: 2px;
letter-spacing: 0.06em;
}
.dk-flag.active { background: var(--accent); color: var(--accent-fg); }
.spacer { flex: 1; }
.input-search {
display: flex;
align-items: center;
gap: 8px;
padding: 0 12px;
height: 36px;
width: 240px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
}
.input-search input { flex: 1; border: none; outline: none; background: transparent; font-family: inherit; font-size: 13px; color: var(--text); }
.tile-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 12px; }
.tile {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 10px;
padding: 18px;
text-align: left;
font-family: inherit;
color: var(--text);
cursor: pointer;
display: flex;
flex-direction: column;
gap: 14px;
min-height: 168px;
transition: border-color 120ms;
}
.tile:hover { border-color: var(--text); }
.tile-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; }
.tile-body { flex: 1; }
.tile-name-row { display: flex; align-items: center; gap: 6px; }
.tile-name { font-family: var(--font-display); font-weight: 600; font-size: 16px; letter-spacing: -0.015em; }
.tile-desc { font-size: 12px; color: var(--text-mute); margin-top: 6px; line-height: 1.5; }
.tile-foot { display: flex; align-items: center; justify-content: space-between; }
.users { font-size: 12px; color: var(--text-dim); }
.connect {
font-size: 12px;
color: var(--accent-fg);
background: var(--accent);
padding: 3px 10px;
border-radius: 4px;
font-weight: 600;
}
.i-icon {
width: 44px;
height: 44px;
border-radius: 10px;
display: inline-flex;
align-items: center;
justify-content: center;
font-family: var(--font-display);
font-weight: 700;
font-size: 22px;
flex-shrink: 0;
}
.i-icon.small { width: 32px; height: 32px; font-size: 16px; }
.i-icon.big { width: 56px; height: 56px; font-size: 28px; }
.tbl { width: 100%; border-collapse: collapse; }
.tbl th {
text-align: left;
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--text-mute);
padding: 12px 16px;
border-bottom: 1px solid var(--border);
font-weight: 500;
}
.tbl td { padding: 12px 16px; border-bottom: 1px solid var(--border); font-size: 13px; vertical-align: middle; }
.tbl tr:last-child td { border-bottom: none; }
.tbl .right { text-align: right; display: flex; gap: 4px; justify-content: flex-end; }
tr td.right { display: table-cell; text-align: right; }
.conn-cell { display: flex; align-items: center; gap: 12px; }
.conn-name { font-size: 13px; font-weight: 500; }
.empty-card {
padding: 60px 24px;
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
}
.empty-title { font-family: var(--font-display); font-weight: 600; font-size: 17px; }
.empty-body { font-size: 13px; color: var(--text-mute); max-width: 420px; line-height: 1.5; }
.content.api { max-width: 900px; }
.row { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; }
.lead { font-size: 13px; color: var(--text-mute); max-width: 540px; line-height: 1.5; }
.tok-name { font-size: 13px; font-weight: 500; }
.detail { padding-bottom: 24px; }
.detail-head { display: flex; align-items: center; gap: 14px; }
.detail-meta { flex: 1; }
.detail-name { font-family: var(--font-display); font-weight: 600; font-size: 20px; letter-spacing: -0.015em; }
.detail-desc { margin-top: 16px; font-size: 13px; color: var(--text-dim); line-height: 1.6; }
.bullets { display: flex; flex-direction: column; gap: 10px; margin-top: 10px; }
.bullet { display: flex; align-items: flex-start; gap: 10px; font-size: 13px; }
.cfg { display: flex; flex-direction: column; gap: 10px; margin-top: 10px; }
.cfg-row { padding: 12px; background: var(--bg); border-radius: 6px; border: 1px solid var(--border); display: flex; align-items: center; justify-content: space-between; gap: 12px; font-size: 13px; }
/* Modal form 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); }
</style>