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
@@ -0,0 +1,312 @@
<script setup lang="ts">
// Mirror of InviteOperatorModal but scoped to a partner. POSTs to
// /api/partners/:slug/users which proxies platform-api's
// /partners/:slug/users — the server resolves the partner, creates the
// Authentik user, adds them to the dezky-partner-staff group, sets
// User.partnerId, and returns the same recovery-link/temp-password
// handoff shape as the operator invite. UI text differs to match the
// partner-staff context.
interface InviteResult {
subject: string
userId: string
// True if the user already existed in Authentik and we just attached them.
// When set, link/tempPassword are absent and the UI hides the credential
// row (the user already has a password from their original signup).
attached?: boolean
link?: string
tempPassword?: string
}
const props = defineProps<{ open: boolean; partnerSlug: string; partnerName: string }>()
const emit = defineEmits<{ close: []; invited: [InviteResult] }>()
const name = ref('')
const email = ref('')
const busy = ref(false)
const error = ref<string | null>(null)
const result = ref<InviteResult | null>(null)
const copied = ref(false)
watch(
() => props.open,
(v) => {
if (v) {
name.value = ''
email.value = ''
busy.value = false
error.value = null
result.value = null
copied.value = false
}
},
)
onMounted(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape' && props.open && !busy.value) emit('close')
}
document.addEventListener('keydown', onKey)
onBeforeUnmount(() => document.removeEventListener('keydown', onKey))
})
const canSubmit = computed(() => {
return (
!busy.value &&
name.value.trim().length >= 2 &&
/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.value.trim())
)
})
async function submit() {
if (!canSubmit.value) return
busy.value = true
error.value = null
try {
result.value = await $fetch<InviteResult>(`/api/partners/${props.partnerSlug}/users`, {
method: 'POST',
body: { name: name.value.trim(), email: email.value.trim() },
})
emit('invited', result.value)
} catch (e) {
const err = e as { data?: { data?: { message?: string }; message?: string; statusMessage?: string } }
error.value =
err.data?.data?.message ||
err.data?.message ||
err.data?.statusMessage ||
'Invite failed'
} finally {
busy.value = false
}
}
async function copyToClipboard(value: string) {
try {
await navigator.clipboard.writeText(value)
copied.value = true
setTimeout(() => (copied.value = false), 2000)
} catch {
// Non-secure context — user can still select the readonly input.
}
}
function done() {
emit('close')
}
</script>
<template>
<Teleport to="body">
<div v-if="open" class="backdrop" @click="!busy && emit('close')">
<div class="modal" role="dialog" aria-label="Invite team member" @click.stop>
<header>
<div>
<Eyebrow>{{ partnerName }}</Eyebrow>
<h2>Invite team member</h2>
</div>
<button class="x" type="button" aria-label="Close" :disabled="busy" @click="emit('close')">
<UiIcon name="x" :size="12" />
</button>
</header>
<!-- Step 1: collect details -->
<div v-if="!result" class="body">
<section>
<label class="label">Name</label>
<input v-model="name" type="text" placeholder="Ronni Baslund" :disabled="busy" />
</section>
<section>
<label class="label">Email</label>
<input v-model="email" type="email" placeholder="ronni@baslund.com" :disabled="busy" />
</section>
<div class="note">
<UiIcon name="shield" :size="13" />
<Mono dim>
Adds the user to the <strong>dezky-partner-staff</strong> Authentik group and
attaches them to <strong>{{ partnerName }}</strong>. They'll receive a
single-use recovery link to set their password + enroll MFA.
</Mono>
</div>
<p v-if="error" class="err">{{ error }}</p>
</div>
<!-- Step 2: show the credential to share -->
<div v-else class="body result">
<Badge tone="ok" dot>{{ result.attached ? 'attached' : 'invited' }}</Badge>
<!-- Existing-user path: no credential, just confirm attach -->
<template v-if="result.attached">
<p class="success">
<Mono>{{ email }}</Mono> already existed in Authentik. They're now
part of <Mono>{{ partnerName }}</Mono> no new password needed;
they sign in with their existing credentials and will see the
partner workspace on next login.
</p>
</template>
<template v-else-if="result.link">
<p class="success">
{{ name }} (<Mono>{{ email }}</Mono>) was added to
<Mono>{{ partnerName }}</Mono>. Share the link below it's single-use
and they'll set their own password + MFA.
</p>
<div class="cred-row">
<input :value="result.link" readonly @focus="($event.target as HTMLInputElement).select()" />
<UiButton variant="secondary" @click="copyToClipboard(result.link!)">
{{ copied ? 'Copied' : 'Copy' }}
</UiButton>
</div>
</template>
<template v-else-if="result.tempPassword">
<p class="success">
{{ name }} (<Mono>{{ email }}</Mono>) was added to
<Mono>{{ partnerName }}</Mono>. Authentik doesn't have a recovery
flow configured yet, so we set a temporary password — share it with
them out-of-band, they'll be prompted to change it on first login.
</p>
<section>
<label class="label">Username / email</label>
<div class="cred-row">
<input :value="email" readonly @focus="($event.target as HTMLInputElement).select()" />
<UiButton variant="secondary" @click="copyToClipboard(email)">
Copy
</UiButton>
</div>
</section>
<section>
<label class="label">Temporary password</label>
<div class="cred-row">
<input :value="result.tempPassword" readonly @focus="($event.target as HTMLInputElement).select()" />
<UiButton variant="secondary" @click="copyToClipboard(result.tempPassword!)">
{{ copied ? 'Copied' : 'Copy' }}
</UiButton>
</div>
</section>
<Mono dim>
// configure a recovery flow in Authentik (Flows → recovery) to
switch this to a self-service link · once SMTP is wired the
credential gets emailed automatically
</Mono>
</template>
</div>
<footer v-if="!result">
<UiButton variant="ghost" :disabled="busy" @click="emit('close')">Cancel</UiButton>
<UiButton variant="primary" :disabled="!canSubmit" @click="submit">
{{ busy ? 'Inviting' : 'Send invite' }}
</UiButton>
</footer>
<footer v-else>
<UiButton variant="primary" @click="done">Done</UiButton>
</footer>
</div>
</div>
</Teleport>
</template>
<style scoped>
.backdrop {
position: fixed; inset: 0;
background: rgba(0, 0, 0, 0.55);
display: flex; align-items: center; justify-content: center;
padding: 24px;
z-index: 180;
}
.modal {
width: 100%; max-width: 520px;
background: var(--elevated);
border: 1px solid var(--border);
border-radius: 10px;
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.5);
display: flex; flex-direction: column;
overflow: hidden;
}
header {
padding: 16px 20px;
display: flex; justify-content: space-between; align-items: flex-start;
border-bottom: 1px solid var(--border);
}
h2 { margin: 4px 0 0 0; font-family: var(--font-display); font-weight: 600; font-size: 18px; }
.x {
width: 26px; height: 26px;
border: 0; border-radius: 6px; background: transparent;
color: var(--text-mute); cursor: pointer;
display: inline-flex; align-items: center; justify-content: center;
}
.x:hover:not(:disabled) { background: var(--surface); color: var(--text); }
.x:disabled { opacity: 0.4; cursor: not-allowed; }
.body {
padding: 16px 20px;
display: flex; flex-direction: column; gap: 14px;
}
section { display: flex; flex-direction: column; gap: 6px; }
.label {
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--text-mute);
font-weight: 500;
}
input {
height: 34px;
padding: 0 12px;
background: var(--input-bg);
border: 1px solid var(--border);
color: var(--text);
border-radius: 6px;
font-family: inherit;
font-size: 13px;
outline: 0;
}
input:focus { border-color: var(--border-hi); }
input:disabled { opacity: 0.6; }
.note {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 12px 14px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text-mute);
}
.note strong { color: var(--text); font-family: var(--font-mono); font-weight: 600; }
.err {
margin: 0;
padding: 10px 12px;
background: rgba(240, 88, 88, 0.08);
border: 1px solid rgba(240, 88, 88, 0.24);
color: var(--bad);
border-radius: 6px;
font-size: 12px;
}
.result .success {
margin: 4px 0 0 0;
font-size: 13px;
color: var(--text-dim);
line-height: 1.55;
}
.cred-row { display: flex; gap: 8px; align-items: center; }
.cred-row input {
flex: 1;
font-family: var(--font-mono);
font-size: 11px;
}
footer {
padding: 12px 20px;
display: flex;
justify-content: flex-end;
gap: 8px;
border-top: 1px solid var(--border);
background: var(--surface);
}
</style>
+1
View File
@@ -24,6 +24,7 @@ const NAV: NavRow[] = [
{ id: 'users', label: 'Users (global)', icon: 'users', href: '/users' },
{ id: 'support', label: 'Support', icon: 'help', href: '/support' },
{ sec: 'Commercial' },
{ id: 'pricing', label: 'Pricing', icon: 'card', href: '/pricing' },
{ id: 'billing', label: 'Platform billing', icon: 'card', href: '/billing' },
{ id: 'reports', label: 'Reports', icon: 'database', href: '/reports' },
{ sec: 'Operations' },
+74
View File
@@ -0,0 +1,74 @@
<script setup lang="ts">
// Toast stack mounted once in the default layout. Top-right.
const { toasts, dismiss } = useToast()
const TONE_COLOR: Record<string, string> = {
info: 'var(--info)',
ok: 'var(--ok)',
warn: 'var(--warn)',
bad: 'var(--bad)',
}
</script>
<template>
<div class="stack">
<TransitionGroup name="toast">
<div v-for="t in toasts" :key="t.id" class="toast" :data-tone="t.tone">
<span class="dot" :style="{ background: TONE_COLOR[t.tone] }" />
<div class="body">
<div class="msg">{{ t.message }}</div>
<div v-if="t.hint" class="hint">{{ t.hint }}</div>
</div>
<button class="x" @click="dismiss(t.id)">
<UiIcon name="x" :size="12" />
</button>
</div>
</TransitionGroup>
</div>
</template>
<style scoped>
.stack {
position: fixed;
top: 16px;
right: 16px;
display: flex;
flex-direction: column;
gap: 8px;
z-index: 200;
pointer-events: none;
}
.toast {
pointer-events: auto;
background: var(--elevated);
border: 1px solid var(--border);
border-radius: 8px;
padding: 10px 12px;
display: flex;
gap: 10px;
align-items: flex-start;
min-width: 280px;
max-width: 360px;
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.28);
}
.dot { width: 8px; height: 8px; border-radius: 999px; margin-top: 6px; flex-shrink: 0; }
.body { flex: 1; min-width: 0; }
.msg { font-size: 13px; font-weight: 500; }
.hint { font-family: var(--font-mono); font-size: 11px; color: var(--text-mute); margin-top: 2px; }
.x {
background: transparent;
border: 0;
padding: 4px;
border-radius: 4px;
color: var(--text-mute);
cursor: pointer;
}
.x:hover { background: var(--surface); color: var(--text); }
.toast-enter-active, .toast-leave-active { transition: opacity 0.18s, transform 0.18s; }
.toast-enter-from, .toast-leave-to { opacity: 0; transform: translateX(20px); }
</style>
+6 -2
View File
@@ -4,7 +4,7 @@
// outside-click + Escape + route-change dismissal so the parent topbar stays
// dumb.
const { user, logout } = useOidcAuth()
const { user } = useOidcAuth()
const { state: tweaks, setTheme } = useTweaks()
const route = useRoute()
@@ -25,7 +25,11 @@ function flipTheme() {
async function signOut() {
close()
await logout()
// Use our custom endpoint instead of useOidcAuth().logout() — see
// apps/operator/server/api/auth/sign-out.get.ts. It ends BOTH the local
// session and the Authentik IdP session (required for shared-workstation
// safety on an elevated-privilege portal) and lands on /signed-out.
await navigateTo('/api/auth/sign-out', { external: true })
}
watch(() => route.path, close)
+54
View File
@@ -0,0 +1,54 @@
// Cached fetch of the signed-in operator's profile from platform-api.
// Mirrors the portal's useMe — kept here so any future middleware /
// layout in operator can read identity data SSR-safely without flashing
// the wrong layout to the browser.
//
// No current consumer; the portal version is what motivated this pattern
// (route middleware fetching /api/me with bare $fetch missed the session
// cookie on SSR, causing a flash of the end-user dashboard before the
// client-side redirect kicked in). Adding the same shape here means the
// trap is pre-disarmed if operator ever grows comparable middleware.
interface MeProfile {
_id: string
authentikSubjectId: string
email: string
name: string
role: string
active: boolean
platformAdmin: boolean
tenantIds: string[]
partnerId?: string
partner?: { _id: string; slug: string; name: string; status: string }
lastLoginAt?: string
}
interface MeResponse {
profile: MeProfile
tenants: unknown[]
subscriptions: unknown[]
}
export function useMe() {
const state = useState<MeResponse | null>('operator-me', () => null)
async function fetchMe(force = false): Promise<MeResponse | null> {
if (state.value && !force) return state.value
try {
// useRequestFetch on SSR forwards the incoming request's headers
// (including the nuxt-oidc-auth session cookie) when calling the
// Nitro route. Bare $fetch on SSR has no cookie context and would
// 401, producing a stale-state / wrong-layout flash on full reload.
const fetcher = useRequestFetch()
state.value = await fetcher<MeResponse>('/api/me')
} catch {
state.value = null
}
return state.value
}
const profile = computed<MeProfile | null>(() => state.value?.profile ?? null)
const isPlatformAdmin = computed(() => !!profile.value?.platformAdmin)
return { state, profile, isPlatformAdmin, fetchMe }
}
+37
View File
@@ -0,0 +1,37 @@
// Lightweight toast stack. Used by buttons/actions that want to confirm
// they fired. Rendered by components/ToastStack.vue in the default layout.
export type ToastTone = 'info' | 'ok' | 'warn' | 'bad'
export interface Toast {
id: number
tone: ToastTone
message: string
hint?: string
}
const toasts = ref<Toast[]>([])
let counter = 0
export const useToast = () => {
function push(tone: ToastTone, message: string, hint?: string) {
const id = ++counter
toasts.value = [...toasts.value, { id, tone, message, hint }]
const ttl = tone === 'bad' ? 7000 : 4000
setTimeout(() => {
toasts.value = toasts.value.filter((t) => t.id !== id)
}, ttl)
}
function dismiss(id: number) {
toasts.value = toasts.value.filter((t) => t.id !== id)
}
return {
toasts,
push,
info: (m: string, h?: string) => push('info', m, h),
ok: (m: string, h?: string) => push('ok', m, h),
warn: (m: string, h?: string) => push('warn', m, h),
bad: (m: string, h?: string) => push('bad', m, h),
dismiss,
}
}
+1
View File
@@ -61,6 +61,7 @@ onMounted(() => {
<IncidentModal />
<NotificationDrawer />
<TweaksPanel />
<ToastStack />
</div>
</template>
+22
View File
@@ -10,6 +10,17 @@ export default defineNuxtConfig({
css: ['~/assets/styles/tokens.css', '~/assets/styles/base.css'],
// Auto-import from the shared packages/ui workspace in addition to the
// app's own components/. /shared-packages is bind-mounted in
// docker-compose.yml — outside containers the same files live at
// <repo>/packages/ui/components/. The local dir keeps the default
// directory-based prefix; the shared dir uses no prefix so
// CountrySelect.vue is just <CountrySelect>.
components: [
'~/components',
{ path: '/shared-packages/ui/components', pathPrefix: false },
],
app: {
head: {
htmlAttrs: { 'data-theme': 'dark' },
@@ -55,6 +66,11 @@ export default defineNuxtConfig({
pkce: true,
skipAccessTokenParsing: true,
exposeAccessToken: true,
// Also expose id_token so /api/auth/sign-out can pass it as
// id_token_hint to Authentik's end-session endpoint. Without it
// Authentik can't identify the session to terminate and falls back
// to its own "you've logged out" confirmation page.
exposeIdToken: true,
},
},
},
@@ -74,5 +90,11 @@ export default defineNuxtConfig({
routeRules: {
'/api/**': { cors: true },
},
// Persist nuxt-oidc-auth's session store on disk so HMR / dev-server
// restarts don't sign operators out. The default memory driver is fine
// in prod where one long-running container holds the state.
storage: {
oidc: { driver: 'fs', base: '.nuxt/oidc-store' },
},
},
})
+77 -1
View File
@@ -225,6 +225,36 @@ async function confirmDetach() {
}
}
// ── Team (partner users) ──────────────────────────────────────────────────
// Lists users whose User.partnerId === this partner. Invite flow surfaces a
// modal that POSTs to /api/partners/:slug/users, which proxies platform-api
// and creates the Authentik user + group + local User doc atomically.
interface PartnerUser {
_id: string
authentikSubjectId: string
email: string
name: string
role: string
active: boolean
lastLoginAt?: string
createdAt?: string
}
const { data: team, refresh: refreshTeam } = await useFetch<PartnerUser[]>(
() => `/api/partners/${slug.value}/users`,
{ default: () => [], watch: [slug] },
)
const inviteOpen = ref(false)
function onInvited() {
// Don't close the modal — the user needs to see the recovery link / temp
// password. Just refresh the team list in the background so the new user
// is visible once they click Done.
void refreshTeam()
}
// ── Soft-terminate partner ────────────────────────────────────────────────
const terminateOpen = ref(false)
const terminateBusy = ref(false)
@@ -371,7 +401,7 @@ async function confirmTerminate() {
</div>
<div class="dl-row">
<dt>Country</dt>
<dd><input v-model="draft.billingInfo.country" class="field mono country" type="text" maxlength="2" placeholder="DK" :disabled="saving" /></dd>
<dd><CountrySelect v-model="draft.billingInfo.country" :disabled="saving" /></dd>
</div>
</dl>
</Card>
@@ -420,6 +450,43 @@ async function confirmTerminate() {
</table>
</Card>
<Card :pad="0">
<div class="card-head padded">
<div>
<h2>Team</h2>
<p class="hint">People at <Mono>{{ partner.name }}</Mono> who can sign in. <Mono dim>partnerId</Mono> on the user record points here.</p>
</div>
<UiButton variant="primary" @click="inviteOpen = true">
<template #leading><UiIcon name="plus" :size="13" /></template>
Invite team member
</UiButton>
</div>
<table>
<thead>
<tr>
<th>Name</th><th>Email</th><th>Role</th><th>Last login</th><th>Status</th>
</tr>
</thead>
<tbody>
<tr v-if="(team ?? []).length === 0" class="empty">
<td colspan="5">
<span class="empty-inner">No team members yet. Click <Mono>Invite team member</Mono> to add one.</span>
</td>
</tr>
<tr v-for="u in (team ?? [])" :key="u._id">
<td>
<div class="cell-name">{{ u.name }}</div>
<Mono dim>{{ u.authentikSubjectId }}</Mono>
</td>
<td><Mono>{{ u.email }}</Mono></td>
<td><Badge tone="neutral">{{ u.role }}</Badge></td>
<td><Mono :dim="!u.lastLoginAt">{{ u.lastLoginAt ? new Date(u.lastLoginAt).toLocaleString() : 'never' }}</Mono></td>
<td><Badge :tone="u.active ? 'ok' : 'bad'" dot>{{ u.active ? 'active' : 'disabled' }}</Badge></td>
</tr>
</tbody>
</table>
</Card>
<Card>
<h2 class="danger">Soft-terminate partner</h2>
<p>
@@ -492,6 +559,15 @@ async function confirmTerminate() {
</p>
<p v-if="terminateError" class="err">{{ terminateError }}</p>
</ConfirmDialog>
<!-- Invite partner team-member modal -->
<InvitePartnerUserModal
:open="inviteOpen"
:partner-slug="partner.slug"
:partner-name="partner.name"
@close="inviteOpen = false"
@invited="onInvited"
/>
</div>
</template>
+346
View File
@@ -0,0 +1,346 @@
<script setup lang="ts">
// Pricing catalog editor. Operator-only. Each (plan, cycle) is a single row
// with three independent per-currency amounts (DKK / EUR / USD). Operator
// types clean round numbers in each currency — no FX derivation. Empty cells
// mean "we don't sell this plan/cycle in that currency."
//
// Backed by /prices in platform-api. Amounts are stored in MINOR units
// (4900 = 49.00); display uses major-unit strings with 2 decimals.
const CURRENCIES = ['DKK', 'EUR', 'USD'] as const
type Currency = (typeof CURRENCIES)[number]
interface PriceRow {
_id: string
plan: 'mvp' | 'pro' | 'enterprise'
cycle: 'monthly' | 'quarterly' | 'yearly'
amounts: Partial<Record<Currency, number>>
active: boolean
createdAt?: string
updatedAt?: string
}
const toast = useToast()
const showInactive = ref(false)
const { data: prices, refresh } = await useFetch<PriceRow[]>(
() => `/api/prices${showInactive.value ? '?includeInactive=true' : ''}`,
{ key: 'pricing-catalog', default: () => [], watch: [showInactive] },
)
const PLAN_LABEL: Record<PriceRow['plan'], string> = {
mvp: 'Starter',
pro: 'Business',
enterprise: 'Enterprise',
}
const CYCLE_LABEL: Record<PriceRow['cycle'], string> = {
monthly: 'Monthly',
quarterly: 'Quarterly',
yearly: 'Yearly',
}
function toMajor(minor?: number): string {
if (typeof minor !== 'number') return ''
return (minor / 100).toFixed(2)
}
function toMinor(major: string): number | undefined {
if (!major.trim()) return undefined // empty → unset that currency
const cleaned = major.replace(/\s+/g, '').replace(',', '.')
const n = Number(cleaned)
if (!Number.isFinite(n) || n < 0) return NaN
return Math.round(n * 100)
}
// Per-row draft. Keyed by row._id, holds one major-unit string per currency.
// undefined draft = not currently editing.
type Draft = Record<Currency, string>
const drafts = reactive<Record<string, Draft>>({})
const saving = ref<string | null>(null)
function startEdit(row: PriceRow) {
drafts[row._id] = {
DKK: toMajor(row.amounts.DKK),
EUR: toMajor(row.amounts.EUR),
USD: toMajor(row.amounts.USD),
}
}
function cancelEdit(id: string) {
delete drafts[id]
}
async function saveEdit(row: PriceRow) {
const draft = drafts[row._id]
if (!draft) return
const next: Partial<Record<Currency, number>> = {}
for (const c of CURRENCIES) {
const parsed = toMinor(draft[c])
if (parsed === undefined) continue // empty input → currency stays unset
if (Number.isNaN(parsed)) {
toast.warn(`Invalid ${c} amount`, 'Enter a number ≥ 0, or blank to leave unset')
return
}
next[c] = parsed
}
saving.value = row._id
try {
await $fetch(`/api/prices/${row._id}`, { method: 'PATCH', body: { amounts: next } })
toast.ok('Prices updated', `${PLAN_LABEL[row.plan]} · ${CYCLE_LABEL[row.cycle]}`)
cancelEdit(row._id)
await refresh()
} catch (err) {
const e = err as { data?: { data?: { message?: string }; message?: string } }
toast.warn('Update failed', e.data?.data?.message ?? e.data?.message ?? 'Try again')
} finally {
saving.value = null
}
}
async function toggleActive(row: PriceRow) {
saving.value = row._id
try {
await $fetch(`/api/prices/${row._id}`, { method: 'PATCH', body: { active: !row.active } })
toast.ok(
row.active ? 'Price deactivated' : 'Price reactivated',
`${PLAN_LABEL[row.plan]} · ${CYCLE_LABEL[row.cycle]}`,
)
await refresh()
} catch (err) {
const e = err as { data?: { data?: { message?: string }; message?: string } }
toast.warn('Toggle failed', e.data?.data?.message ?? e.data?.message ?? 'Try again')
} finally {
saving.value = null
}
}
// Add-row form. Used to insert an Enterprise row or replace a deactivated one.
const addForm = reactive<{
plan: PriceRow['plan']
cycle: PriceRow['cycle']
amounts: Record<Currency, string>
}>({
plan: 'enterprise',
cycle: 'monthly',
amounts: { DKK: '', EUR: '', USD: '' },
})
const adding = ref(false)
async function addRow() {
const amounts: Partial<Record<Currency, number>> = {}
for (const c of CURRENCIES) {
const parsed = toMinor(addForm.amounts[c])
if (parsed === undefined) continue
if (Number.isNaN(parsed)) {
toast.warn(`Invalid ${c} amount`, 'Enter a number ≥ 0')
return
}
amounts[c] = parsed
}
if (Object.keys(amounts).length === 0) {
toast.warn('No prices entered', 'Set at least one currency amount')
return
}
adding.value = true
try {
await $fetch('/api/prices', {
method: 'POST',
body: { plan: addForm.plan, cycle: addForm.cycle, amounts },
})
toast.ok('Row added', `${PLAN_LABEL[addForm.plan]} · ${CYCLE_LABEL[addForm.cycle]}`)
for (const c of CURRENCIES) addForm.amounts[c] = ''
await refresh()
} catch (err) {
const e = err as { data?: { data?: { message?: string }; message?: string } }
toast.warn('Add failed', e.data?.data?.message ?? e.data?.message ?? 'Try again')
} finally {
adding.value = false
}
}
// Sort: active first, then plan order (mvp/pro/enterprise), then cycle order.
const PLAN_ORDER: Record<PriceRow['plan'], number> = { mvp: 0, pro: 1, enterprise: 2 }
const CYCLE_ORDER: Record<PriceRow['cycle'], number> = { monthly: 0, quarterly: 1, yearly: 2 }
const sortedPrices = computed<PriceRow[]>(() =>
[...(prices.value ?? [])].sort((a, b) => {
if (a.active !== b.active) return a.active ? -1 : 1
if (a.plan !== b.plan) return PLAN_ORDER[a.plan] - PLAN_ORDER[b.plan]
return CYCLE_ORDER[a.cycle] - CYCLE_ORDER[b.cycle]
}),
)
</script>
<template>
<div class="stage">
<PageHeader
eyebrow="Operator · operator.dezky.local"
title="Pricing catalog"
subtitle="One row per plan + cycle, with independent prices per currency. Changes affect subscriptions provisioned from now on — existing customers keep the price snapshot taken at provisioning."
>
<template #actions>
<label class="toggle">
<input v-model="showInactive" type="checkbox" />
Show inactive
</label>
</template>
</PageHeader>
<Card :pad="0">
<table>
<thead>
<tr>
<th>Plan</th>
<th>Cycle</th>
<th v-for="c in CURRENCIES" :key="c" class="th-amount">{{ c }} / seat</th>
<th>Status</th>
<th class="th-actions">Actions</th>
</tr>
</thead>
<tbody>
<tr v-if="sortedPrices.length === 0" class="empty">
<td :colspan="4 + CURRENCIES.length">
<span class="empty-inner">No prices yet add one below.</span>
</td>
</tr>
<tr v-for="row in sortedPrices" :key="row._id" :class="{ inactive: !row.active }">
<td>
<div class="cell-name">{{ PLAN_LABEL[row.plan] }}</div>
<Mono dim>{{ row.plan }}</Mono>
</td>
<td>{{ CYCLE_LABEL[row.cycle] }}</td>
<td v-for="c in CURRENCIES" :key="c" class="cell-amount">
<template v-if="drafts[row._id]">
<input
v-model="drafts[row._id][c]"
type="text"
inputmode="decimal"
class="amount-input"
:placeholder="`— ${c}`"
:disabled="saving === row._id"
@keydown.enter="saveEdit(row)"
@keydown.escape="cancelEdit(row._id)"
/>
</template>
<template v-else>
<template v-if="row.amounts[c] !== undefined">
<span class="amount">{{ toMajor(row.amounts[c]) }}</span>
</template>
<Mono v-else dim></Mono>
</template>
</td>
<td>
<Badge :tone="row.active ? 'ok' : 'neutral'" dot>{{ row.active ? 'active' : 'inactive' }}</Badge>
</td>
<td class="cell-actions">
<div class="actions">
<template v-if="drafts[row._id]">
<UiButton size="sm" variant="primary" :disabled="saving === row._id" @click="saveEdit(row)">
{{ saving === row._id ? 'Saving' : 'Save' }}
</UiButton>
<UiButton size="sm" variant="ghost" :disabled="saving === row._id" @click="cancelEdit(row._id)">
Cancel
</UiButton>
</template>
<template v-else>
<UiButton size="sm" variant="secondary" @click="startEdit(row)">Edit</UiButton>
<UiButton size="sm" variant="ghost" :disabled="saving === row._id" @click="toggleActive(row)">
{{ row.active ? 'Deactivate' : 'Reactivate' }}
</UiButton>
</template>
</div>
</td>
</tr>
</tbody>
</table>
</Card>
<Card>
<h2>Add a row</h2>
<p class="hint">
Used for missing Enterprise rows or to replace a deactivated row.
Fill in only the currencies you want to offer; blank cells mean
"not sold in this currency."
</p>
<div class="add-form">
<label class="field">
<Eyebrow>Plan</Eyebrow>
<select v-model="addForm.plan">
<option value="mvp">Starter</option>
<option value="pro">Business</option>
<option value="enterprise">Enterprise</option>
</select>
</label>
<label class="field">
<Eyebrow>Cycle</Eyebrow>
<select v-model="addForm.cycle">
<option value="monthly">Monthly</option>
<option value="quarterly">Quarterly</option>
<option value="yearly">Yearly</option>
</select>
</label>
<label v-for="c in CURRENCIES" :key="c" class="field">
<Eyebrow>{{ c }} / seat</Eyebrow>
<input
v-model="addForm.amounts[c]"
type="text"
inputmode="decimal"
:placeholder="`e.g. ${c === 'DKK' ? '49.00' : '7.00'}`"
/>
</label>
<UiButton variant="primary" :disabled="adding" @click="addRow">
{{ adding ? 'Adding' : 'Add row' }}
</UiButton>
</div>
</Card>
</div>
</template>
<style scoped>
.stage { padding: 24px 40px 64px; display: flex; flex-direction: column; gap: 18px; }
.toggle {
display: inline-flex;
align-items: center;
gap: 8px;
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-mute);
}
table { width: 100%; border-collapse: collapse; table-layout: fixed; }
th, td { padding: 12px 16px; text-align: left; border-bottom: 1px solid var(--border); font-size: 13px; vertical-align: middle; }
th { font-family: var(--font-mono); font-size: 10px; letter-spacing: 0.12em; text-transform: uppercase; color: var(--text-mute); font-weight: 500; }
.cell-name { font-size: 13px; font-weight: 500; }
.empty .empty-inner { display: block; padding: 24px 0; text-align: center; color: var(--text-mute); }
tr.inactive { opacity: 0.55; }
.th-amount, .cell-amount { text-align: right; width: 130px; white-space: nowrap; }
.amount { font-family: var(--font-mono); font-size: 13px; font-variant-numeric: tabular-nums; }
.amount-input {
width: 96px;
padding: 6px 8px;
text-align: right;
background: var(--surface);
border: 1px solid var(--border-hi);
border-radius: 4px;
font-family: var(--font-mono);
font-size: 13px;
color: var(--text);
}
.th-actions, .cell-actions { text-align: right; width: 220px; }
.actions { display: inline-flex; gap: 6px; align-items: center; justify-content: flex-end; }
.add-form { display: grid; grid-template-columns: repeat(5, 1fr) auto; gap: 12px; align-items: end; }
.field { display: flex; flex-direction: column; gap: 6px; }
.field select, .field input {
height: 34px;
padding: 0 12px;
background: var(--input-bg);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text);
font-family: inherit;
font-size: 13px;
outline: 0;
}
.hint { font-size: 13px; color: var(--text-dim); margin: 0 0 12px; }
</style>
+112
View File
@@ -0,0 +1,112 @@
<script setup lang="ts">
// Sign-out landing for the operator portal. /api/auth/sign-out cleared the
// local session and bounced through Authentik's end-session endpoint, which
// ended the IdP session. By the time we render here the user has no
// session anywhere — clicking Sign in again forces fresh credentials.
definePageMeta({ layout: 'blank', auth: false, oidcAuth: { enabled: false } })
function signInAgain() {
return navigateTo('/auth/oidc/login', { external: true })
}
</script>
<template>
<div class="shell">
<div class="card">
<div class="badge">
<UiIcon name="shield" :size="22" />
</div>
<p class="eyebrow">dezky · ops</p>
<h1>You're signed out</h1>
<p class="lead">
Your operator session has been ended. Sign in again whenever you're
ready Authentik will ask for fresh credentials.
</p>
<button class="primary" type="button" @click="signInAgain">Sign in again</button>
<p class="hint">operator.dezky.local</p>
</div>
</div>
</template>
<style scoped>
.shell {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 32px;
background: var(--bg);
}
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: 36px 36px 32px;
width: 100%;
max-width: 420px;
text-align: center;
box-shadow: 0 24px 60px rgba(0, 0, 0, 0.4);
}
.badge {
width: 48px;
height: 48px;
border-radius: 12px;
display: inline-flex;
align-items: center;
justify-content: center;
background: rgba(52, 199, 123, 0.12);
color: var(--ok);
margin: 0 auto 16px;
}
.eyebrow {
font-family: var(--font-mono);
font-size: 11px;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--text-mute);
margin: 0 0 10px 0;
}
h1 {
font-family: var(--font-display);
font-weight: 600;
font-size: 24px;
letter-spacing: -0.02em;
line-height: 1.15;
margin: 0;
}
.lead {
font-size: 13px;
color: var(--text-dim);
line-height: 1.55;
margin: 14px 0 26px;
}
.primary {
display: block;
width: 100%;
height: 42px;
background: var(--accent);
color: var(--accent-fg);
border: none;
border-radius: 7px;
font-weight: 600;
font-size: 13px;
font-family: inherit;
cursor: pointer;
}
.primary:hover { filter: brightness(0.96); }
.hint {
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.06em;
color: var(--text-mute);
margin: 22px 0 0 0;
}
</style>
@@ -0,0 +1,35 @@
// Sign-out for the operator portal. Same shape as the customer portal's
// sign-out (apps/portal/server/api/auth/sign-out.get.ts) — ends BOTH the
// local nuxt-oidc-auth session AND the Authentik IdP session so the next
// person at the same browser must re-enter credentials. Required for
// shared-workstation safety; operator portal carries elevated privileges.
//
// Flow:
// 1. Read the id_token off the local session (needed as id_token_hint).
// 2. Clear the local session (cookie + persistent store).
// 3. 302 the BROWSER through Authentik's dezky-operator end-session URL
// with post_logout_redirect_uri=/signed-out.
//
// The brief URL-bar flash to auth.dezky.local is unavoidable: that's the
// only host that can clear the Authentik session cookie (server-to-server
// invalidation alone leaves the browser cookie, which would let the next
// visit silently re-authorize).
import { getUserSession, clearUserSession } from 'nuxt-oidc-auth/runtime/server/utils/session.js'
const END_SESSION = 'https://auth.dezky.local/application/o/dezky-operator/end-session/'
const POST_LOGOUT_REDIRECT = 'https://operator.dezky.local/signed-out'
export default defineEventHandler(async (event) => {
const session = await getUserSession(event).catch(() => ({} as any))
const idToken: string | undefined = (session as any).idToken
await clearUserSession(event).catch(() => {})
const params = new URLSearchParams({
post_logout_redirect_uri: POST_LOGOUT_REDIRECT,
...(idToken && { id_token_hint: idToken }),
})
return sendRedirect(event, `${END_SESSION}?${params.toString()}`, 302)
})
+26
View File
@@ -0,0 +1,26 @@
// Operator identity proxy. Same shape as the portal's /api/me — pulls
// /users/me from platform-api with the signed-in operator's access token,
// plus tenants + subscriptions for context. Consumed by the useMe()
// composable; no UI surface uses it yet, but the path is here so any
// future middleware / layout that needs profile data has a known endpoint.
import { getUserSession } from 'nuxt-oidc-auth/runtime/server/utils/session.js'
export default defineEventHandler(async (event) => {
const session = await getUserSession(event).catch(() => null)
const accessToken = (session as { accessToken?: string } | null)?.accessToken
if (!accessToken) {
throw createError({ statusCode: 401, statusMessage: 'Not signed in or no access token' })
}
const base = process.env.PLATFORM_API_INTERNAL_URL ?? 'http://platform-api:3001'
const headers = { Authorization: `Bearer ${accessToken}` }
const [profile, tenants, subscriptions] = await Promise.all([
$fetch(`${base}/users/me`, { headers }),
$fetch(`${base}/tenants`, { headers }),
$fetch(`${base}/subscriptions`, { headers }),
])
return { profile, tenants, subscriptions }
})
@@ -0,0 +1,6 @@
import { platformApi } from '~~/server/utils/platform-api'
export default defineEventHandler(async (event) => {
const slug = getRouterParam(event, 'slug')
return platformApi(event, `/partners/${slug}/users`)
})
@@ -0,0 +1,7 @@
import { platformApi } from '~~/server/utils/platform-api'
export default defineEventHandler(async (event) => {
const slug = getRouterParam(event, 'slug')
const body = await readBody(event)
return platformApi(event, `/partners/${slug}/users`, { method: 'POST', body })
})
@@ -0,0 +1,6 @@
import { platformApi } from '~~/server/utils/platform-api'
export default defineEventHandler(async (event) => {
const id = getRouterParam(event, 'id')
return platformApi(event, `/prices/${id}`, { method: 'DELETE' })
})
@@ -0,0 +1,7 @@
import { platformApi } from '~~/server/utils/platform-api'
export default defineEventHandler(async (event) => {
const id = getRouterParam(event, 'id')
const body = await readBody(event)
return platformApi(event, `/prices/${id}`, { method: 'PATCH', body })
})
@@ -0,0 +1,7 @@
import { platformApi } from '~~/server/utils/platform-api'
export default defineEventHandler(async (event) => {
const q = getQuery(event)
const includeInactive = q.includeInactive === 'true'
return platformApi(event, `/prices${includeInactive ? '?includeInactive=true' : ''}`)
})
@@ -0,0 +1,6 @@
import { platformApi } from '~~/server/utils/platform-api'
export default defineEventHandler(async (event) => {
const body = await readBody(event)
return platformApi(event, '/prices', { method: 'POST', body })
})
+3 -1
View File
@@ -1,3 +1,5 @@
<template>
<NuxtPage />
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
</template>
+9
View File
@@ -25,3 +25,12 @@ button {
a {
color: inherit;
}
::-webkit-scrollbar { width: 10px; height: 10px; }
::-webkit-scrollbar-thumb { background: rgba(128, 128, 128, 0.25); border-radius: 6px; }
::-webkit-scrollbar-track { background: transparent; }
:focus-visible {
outline: 2px solid var(--signal);
outline-offset: 2px;
}
+32 -6
View File
@@ -1,8 +1,9 @@
/* Dezky design tokens — workspace surface (light/bone).
Ported from project/platform-tokens.jsx (THEMES.light + ACCENTS.signal). */
/* Dezky portal design tokens.
Mirrors apps/operator/assets/styles/tokens.css (which ports project/platform-tokens.jsx
THEMES + ACCENTS + DENSITIES) so both portals share a visual vocabulary. */
:root {
/* Surface */
/* Surface — light/bone */
--bg: #F4F3EE; /* bone */
--surface: #FAFAF7; /* paper */
--elevated: #FFFFFF;
@@ -13,14 +14,20 @@
--text: #0A0A0A; /* carbon */
--text-dim: rgba(10, 10, 10, 0.55);
--text-mute: rgba(10, 10, 10, 0.4);
--row-hover: rgba(10, 10, 10, 0.03);
/* Sidebar (always-dark for brand consistency) */
/* Sidebar always-dark for brand presence in light mode */
--side-bg: #0A0A0A;
--side-surf: #141413;
--side-border: #1F1F1C;
--side-text: #F4F3EE;
--side-dim: rgba(244, 243, 238, 0.55);
--side-mute: rgba(244, 243, 238, 0.35);
--side-hover: rgba(244, 243, 238, 0.06);
--side-active: rgba(244, 243, 238, 0.1);
/* Brand accent */
--accent: #D4FF3A; /* signal */
/* Brand accent — defaults to Signal yellow */
--accent: #D4FF3A;
--accent-fg: #0A0A0A;
--signal: #D4FF3A;
@@ -35,6 +42,11 @@
--font-display: 'Inter Tight', 'Inter', sans-serif;
--font-mono: 'JetBrains Mono', ui-monospace, 'Menlo', monospace;
/* Density (defaults to comfy) */
--row-h: 56px;
--pad: 24px;
--gap: 20px;
/* Field input surface */
--input-bg: var(--bg);
}
@@ -48,9 +60,23 @@
--text: #F4F3EE;
--text-dim: rgba(244, 243, 238, 0.72);
--text-mute: rgba(244, 243, 238, 0.45);
--row-hover: rgba(244, 243, 238, 0.04);
--ok: #34C77B;
--warn: #F0B14A;
--bad: #F05858;
--info: #4D8BE8;
--input-bg: rgba(244, 243, 238, 0.04);
}
[data-density='compact'] {
--row-h: 44px;
--pad: 16px;
--gap: 14px;
}
/* Accent presets — toggled via TweaksPanel for the whitelabel preview. The
`--signal` brand-locked node-dot stays the same; only `--accent` flexes. */
[data-accent='signal'] { --accent: #D4FF3A; --accent-fg: #0A0A0A; }
[data-accent='cobalt'] { --accent: #3F6BFF; --accent-fg: #FFFFFF; }
[data-accent='coral'] { --accent: #FF6B4A; --accent-fg: #FFFFFF; }
[data-accent='moss'] { --accent: #5B8C5A; --accent-fg: #FFFFFF; }
+215
View File
@@ -0,0 +1,215 @@
<script setup lang="ts">
// Waffle app launcher. Strict port of project/platform-app.jsx `AppLauncher`
// (lines 303-377). Right-aligned drop-in (margin 64px 20px 0 0, width 440),
// 3-col grid of centered tiles. The app you're currently inside is rendered
// with a signal-yellow icon container and a "HERE" pill.
import type { IconName } from './UiIcon.vue'
const launcher = useAppLauncher()
const route = useRoute()
const partnerMode = usePartnerMode()
interface Tile {
key: string
name: string
icon: IconName
ext: string
current?: boolean
}
// Section context drives which extras (Admin / Partner) appear in the grid,
// and which tile is marked `current` ("HERE" pill).
const section = computed<'partner' | 'admin' | 'user'>(() => {
if (partnerMode.isActive.value) return 'admin'
if (route.path.startsWith('/partner')) return 'partner'
if (route.path.startsWith('/admin')) return 'admin'
return 'user'
})
const tiles = computed<Tile[]>(() => {
const isAdmin = section.value === 'admin'
const isPartner = section.value === 'partner'
const base: Tile[] = [
{ key: 'mail', name: 'Mail', icon: 'mail', ext: 'mail.dezky.com' },
{ key: 'drev', name: 'Drev', icon: 'folder', ext: 'drev.dezky.com' },
{ key: 'moder', name: 'Møder', icon: 'video', ext: 'meet.dezky.com' },
{ key: 'chat', name: 'Chat', icon: 'chat', ext: 'chat.dezky.com' },
{ key: 'cal', name: 'Kalender', icon: 'calendar', ext: 'cal.dezky.com' },
{ key: 'contacts', name: 'Kontakter', icon: 'users', ext: 'contacts.dezky.com' },
]
if (isAdmin) {
base.push({ key: 'admin', name: 'Admin', icon: 'shield', ext: 'admin.dezky.com', current: !isPartner })
}
if (isPartner) {
base.push({ key: 'partner', name: 'Partner', icon: 'briefcase', ext: 'partner.nordicmsp.dk', current: true })
}
base.push({ key: 'docs', name: 'Docs', icon: 'file', ext: 'docs.dezky.com' })
return base
})
const toast = useToast()
function open(t: Tile) {
launcher.hide()
if (t.key === 'admin') return navigateTo('/admin')
if (t.key === 'partner') return navigateTo('/partner')
toast.info(`Opening ${t.name}`, t.ext)
}
onMounted(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape' && launcher.open.value) launcher.hide()
}
document.addEventListener('keydown', onKey)
onBeforeUnmount(() => document.removeEventListener('keydown', onKey))
})
</script>
<template>
<Teleport to="body">
<Transition name="launcher">
<div v-if="launcher.open.value" class="scrim" @click="launcher.hide">
<div class="panel" @click.stop>
<header>
<div class="head-meta">
<Eyebrow>Apps</Eyebrow>
<div class="head-title">Open in new tab</div>
</div>
<button class="x" @click="launcher.hide" aria-label="Close">
<UiIcon name="x" :size="16" />
</button>
</header>
<div class="grid">
<a
v-for="t in tiles"
:key="t.key"
href="#"
class="tile"
@click.prevent="open(t)"
>
<span class="tile-icon" :class="{ current: t.current }">
<UiIcon :name="t.icon" :size="20" />
</span>
<span class="tile-name">{{ t.name }}</span>
<span v-if="t.current" class="here">HERE</span>
</a>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
.scrim {
position: fixed;
inset: 0;
background: rgba(10, 10, 10, 0.36);
z-index: 75;
display: flex;
justify-content: flex-end;
}
.panel {
margin: 64px 20px 0 0;
width: 440px;
height: max-content;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 10px;
box-shadow: 0 24px 60px rgba(0, 0, 0, 0.25);
overflow: hidden;
}
header {
padding: 16px 20px;
border-bottom: 1px solid var(--border);
display: flex;
justify-content: space-between;
align-items: center;
}
.head-meta { display: flex; flex-direction: column; gap: 2px; }
.head-title {
font-family: var(--font-display);
font-weight: 600;
font-size: 16px;
letter-spacing: -0.015em;
margin-top: 2px;
}
.x {
background: transparent;
border: 0;
padding: 6px;
border-radius: 4px;
color: var(--text-mute);
cursor: pointer;
}
.x:hover { background: var(--surface); color: var(--text); }
.grid {
padding: 12px;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 4px;
}
.tile {
position: relative;
padding: 14px 8px;
border-radius: 6px;
text-decoration: none;
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
color: var(--text);
transition: background 0.12s;
}
.tile:hover { background: var(--row-hover); }
.tile-icon {
width: 44px;
height: 44px;
border-radius: 10px;
background: var(--text);
color: var(--bg);
display: inline-flex;
align-items: center;
justify-content: center;
}
.tile-icon.current {
background: var(--accent);
color: var(--accent-fg);
}
.tile-name {
font-size: 12px;
font-weight: 500;
text-align: center;
}
.here {
position: absolute;
top: 8px;
right: 8px;
font-family: var(--font-mono);
font-size: 9px;
font-weight: 600;
color: var(--accent-fg);
background: var(--accent);
padding: 1px 4px;
border-radius: 2px;
letter-spacing: 0.04em;
}
.launcher-enter-active, .launcher-leave-active { transition: opacity 0.14s; }
.launcher-enter-from, .launcher-leave-to { opacity: 0; }
.launcher-enter-active .panel { animation: launcherIn 0.18s ease-out; }
@keyframes launcherIn {
from { opacity: 0; transform: translateY(-6px); }
to { opacity: 1; transform: translateY(0); }
}
</style>
+48
View File
@@ -0,0 +1,48 @@
<script setup lang="ts">
const props = withDefaults(
defineProps<{ name?: string; size?: number }>(),
{ name: '?', size: 32 },
)
const palette = ['#3D3D38', '#3F5B47', '#5B4D3F', '#3F4D5B', '#5B3F4D', '#4D5B3F']
const initials = computed(() =>
props.name
.split(' ')
.filter(Boolean)
.slice(0, 2)
.map((p) => p[0].toUpperCase())
.join(''),
)
const color = computed(() => {
let s = 0
for (let i = 0; i < props.name.length; i++) s = (s * 31 + props.name.charCodeAt(i)) >>> 0
return palette[s % palette.length]
})
</script>
<template>
<div
class="avatar"
:style="{
width: size + 'px',
height: size + 'px',
background: color,
fontSize: size * 0.36 + 'px',
}"
>{{ initials }}</div>
</template>
<style scoped>
.avatar {
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 999px;
color: #F4F3EE;
font-weight: 600;
letter-spacing: -0.01em;
flex-shrink: 0;
}
</style>
+39
View File
@@ -0,0 +1,39 @@
<script setup lang="ts">
type Tone = 'neutral' | 'ok' | 'warn' | 'bad' | 'info' | 'accent' | 'invert'
withDefaults(defineProps<{ tone?: Tone; dot?: boolean }>(), { tone: 'neutral', dot: false })
</script>
<template>
<span class="badge" :data-tone="tone">
<span v-if="dot" class="badge-dot" />
<slot />
</span>
</template>
<style scoped>
.badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 3px 8px;
font-size: 11px;
font-family: var(--font-mono);
font-weight: 500;
line-height: 1.4;
letter-spacing: 0.02em;
border-radius: 4px;
border: 1px solid;
white-space: nowrap;
}
.badge-dot { width: 6px; height: 6px; border-radius: 999px; background: currentColor; }
.badge[data-tone='neutral'] { background: var(--surface); color: var(--text-dim); border-color: var(--border); }
.badge[data-tone='ok'] { background: rgba(31, 138, 91, 0.1); color: var(--ok); border-color: rgba(31, 138, 91, 0.2); }
.badge[data-tone='warn'] { background: rgba(232, 154, 31, 0.12); color: var(--warn); border-color: rgba(232, 154, 31, 0.24); }
.badge[data-tone='bad'] { background: rgba(226, 48, 48, 0.1); color: var(--bad); border-color: rgba(226, 48, 48, 0.22); }
.badge[data-tone='info'] { background: rgba(42, 111, 219, 0.1); color: var(--info); border-color: rgba(42, 111, 219, 0.22); }
.badge[data-tone='accent'] { background: var(--accent); color: var(--accent-fg); border-color: var(--accent); }
.badge[data-tone='invert'] { background: #0A0A0A; color: #F4F3EE; border-color: #0A0A0A; }
</style>
+16
View File
@@ -0,0 +1,16 @@
<script setup lang="ts">
withDefaults(
defineProps<{ pad?: number; surface?: 'surface' | 'elevated' | 'bg' }>(),
{ pad: 24, surface: 'surface' },
)
</script>
<template>
<div class="card" :style="{ padding: pad + 'px', background: `var(--${surface})` }">
<slot />
</div>
</template>
<style scoped>
.card { border: 1px solid var(--border); border-radius: 8px; }
</style>
+126
View File
@@ -0,0 +1,126 @@
<script setup lang="ts">
withDefaults(
defineProps<{
open: boolean
title: string
eyebrow?: string
confirmLabel?: string
cancelLabel?: string
tone?: 'primary' | 'danger'
busy?: boolean
}>(),
{
confirmLabel: 'Confirm',
cancelLabel: 'Cancel',
tone: 'primary',
busy: false,
},
)
const emit = defineEmits<{ close: []; confirm: [] }>()
onMounted(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') emit('close')
}
document.addEventListener('keydown', onKey)
onBeforeUnmount(() => document.removeEventListener('keydown', onKey))
})
</script>
<template>
<Teleport to="body">
<div v-if="open" class="backdrop" @click="emit('close')">
<div class="modal" @click.stop>
<header>
<div>
<Eyebrow v-if="eyebrow">{{ eyebrow }}</Eyebrow>
<h3>{{ title }}</h3>
</div>
<button class="close" @click="emit('close')">
<UiIcon name="x" :size="18" />
</button>
</header>
<div class="body">
<slot />
</div>
<footer>
<UiButton variant="ghost" @click="emit('close')">{{ cancelLabel }}</UiButton>
<UiButton :variant="tone === 'danger' ? 'danger' : 'primary'" :disabled="busy" @click="emit('confirm')">
{{ busy ? 'Working' : confirmLabel }}
</UiButton>
</footer>
</div>
</div>
</Teleport>
</template>
<style scoped>
.backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
z-index: 80;
}
.modal {
background: var(--bg);
border: 1px solid var(--border);
border-radius: 10px;
width: 100%;
max-width: 480px;
max-height: 90vh;
display: flex;
flex-direction: column;
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.4);
}
header {
padding: 18px 24px;
border-bottom: 1px solid var(--border);
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
h3 {
margin: 4px 0 0 0;
font-family: var(--font-display);
font-weight: 600;
font-size: 17px;
letter-spacing: -0.015em;
}
.close {
background: transparent;
border: none;
padding: 6px;
border-radius: 4px;
color: var(--text-dim);
cursor: pointer;
}
.close:hover { background: var(--surface); }
.body {
flex: 1;
overflow-y: auto;
padding: 20px 24px;
font-size: 13px;
line-height: 1.55;
color: var(--text-dim);
}
footer {
padding: 14px 24px;
border-top: 1px solid var(--border);
display: flex;
gap: 8px;
justify-content: flex-end;
background: var(--surface);
}
</style>
@@ -0,0 +1,76 @@
<script setup lang="ts">
// Banner shown at top of viewport when a partner admin is acting inside a
// specific customer org. Distinct color (indigo — partner mode is normal
// operating mode, not danger). Persistent until partner exits.
import { customers } from '~/data/customers'
const partnerMode = usePartnerMode()
const router = useRouter()
const activeCustomer = computed(() =>
customers.find((c) => c.id === partnerMode.activeCustomerId.value) || null,
)
onMounted(() => partnerMode.hydrate())
function exit() {
partnerMode.exit()
router.push('/partner/customers')
}
</script>
<template>
<div v-if="partnerMode.isActive.value && activeCustomer" class="banner">
<span class="dot" />
<div class="meta">
<Mono>Partner view</Mono>
<span class="text">managing <strong>{{ activeCustomer.name }}</strong> · actions are attributed to NordicMSP in the customer's audit log</span>
</div>
<button class="exit" @click="exit">
<UiIcon name="logout" :size="12" />
<span>Exit partner view</span>
</button>
</div>
</template>
<style scoped>
.banner {
display: flex;
align-items: center;
gap: 12px;
background: linear-gradient(to right, rgba(63, 107, 255, 0.18), rgba(63, 107, 255, 0.08));
border-bottom: 1px solid rgba(63, 107, 255, 0.36);
padding: 8px 16px;
font-size: 12.5px;
}
.dot {
width: 8px;
height: 8px;
border-radius: 999px;
background: #4D8BE8;
flex-shrink: 0;
box-shadow: 0 0 0 4px rgba(77, 139, 232, 0.18);
}
.meta { flex: 1; display: flex; align-items: center; gap: 12px; color: var(--text); min-width: 0; }
.text { color: var(--text-dim); }
.text strong { color: var(--text); font-weight: 600; }
.exit {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 5px 10px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 6px;
font-family: var(--font-mono);
font-size: 11px;
cursor: pointer;
flex-shrink: 0;
white-space: nowrap;
}
.exit:hover { background: var(--surface); }
</style>
+16
View File
@@ -0,0 +1,16 @@
<script setup lang="ts"></script>
<template>
<span class="eyebrow"><slot /></span>
</template>
<style scoped>
.eyebrow {
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.18em;
text-transform: uppercase;
font-weight: 500;
color: var(--text-mute);
}
</style>
+32
View File
@@ -0,0 +1,32 @@
<script setup lang="ts">
defineProps<{ label: string; value: string; tone?: 'ok' | 'warn' | 'bad' }>()
</script>
<template>
<div class="cell">
<div class="label">{{ label }}</div>
<div class="value" :data-tone="tone">{{ value }}</div>
</div>
</template>
<style scoped>
.cell { min-width: 0; }
.label {
font-family: var(--font-mono);
font-size: 9px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--text-mute);
}
.value {
font-family: var(--font-mono);
font-size: 14px;
font-weight: 600;
margin-top: 4px;
font-variant-numeric: tabular-nums;
color: var(--text);
}
.value[data-tone='ok'] { color: var(--ok); }
.value[data-tone='warn'] { color: var(--warn); }
.value[data-tone='bad'] { color: var(--bad); }
</style>
+124
View File
@@ -0,0 +1,124 @@
<script setup lang="ts">
// Generic modal — for forms, wizards, and confirmations more elaborate than
// ConfirmDialog. Uses an explicit `size` token mapping to widths sm/md/lg.
const props = withDefaults(
defineProps<{
open: boolean
title?: string
eyebrow?: string
size?: 'sm' | 'md' | 'lg'
}>(),
{ size: 'md' },
)
const emit = defineEmits<{ close: [] }>()
const maxWidth = computed(() => ({ sm: 440, md: 600, lg: 880 })[props.size || 'md'])
onMounted(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape' && props.open) emit('close')
}
document.addEventListener('keydown', onKey)
onBeforeUnmount(() => document.removeEventListener('keydown', onKey))
})
</script>
<template>
<Teleport to="body">
<Transition name="modal">
<div v-if="open" class="backdrop" @click="emit('close')">
<div class="modal" :style="{ maxWidth: maxWidth + 'px' }" @click.stop>
<header v-if="title || eyebrow || $slots.header">
<div class="lhs">
<Eyebrow v-if="eyebrow">{{ eyebrow }}</Eyebrow>
<h3 v-if="title">{{ title }}</h3>
<slot name="header" />
</div>
<button class="close" @click="emit('close')" aria-label="Close">
<UiIcon name="x" :size="18" />
</button>
</header>
<div class="body">
<slot />
</div>
<footer v-if="$slots.footer">
<slot name="footer" />
</footer>
</div>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
.backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
z-index: 80;
}
.modal {
background: var(--bg);
border: 1px solid var(--border);
border-radius: 10px;
width: 100%;
max-height: 90vh;
display: flex;
flex-direction: column;
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.4);
}
header {
padding: 18px 24px;
border-bottom: 1px solid var(--border);
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
flex-shrink: 0;
}
h3 {
margin: 4px 0 0 0;
font-family: var(--font-display);
font-weight: 600;
font-size: 17px;
letter-spacing: -0.015em;
}
.close {
background: transparent;
border: none;
padding: 6px;
border-radius: 4px;
color: var(--text-dim);
cursor: pointer;
}
.close:hover { background: var(--surface); }
.body {
flex: 1;
overflow-y: auto;
padding: 22px 24px;
}
footer {
padding: 14px 24px;
border-top: 1px solid var(--border);
display: flex;
gap: 8px;
justify-content: flex-end;
background: var(--surface);
flex-shrink: 0;
}
.modal-enter-active, .modal-leave-active { transition: opacity 0.15s; }
.modal-enter-from, .modal-leave-to { opacity: 0; }
</style>
+12
View File
@@ -0,0 +1,12 @@
<script setup lang="ts">
withDefaults(defineProps<{ dim?: boolean }>(), { dim: false })
</script>
<template>
<span class="mono" :class="{ dim }"><slot /></span>
</template>
<style scoped>
.mono { font-family: var(--font-mono); font-size: 11px; letter-spacing: 0.04em; color: inherit; }
.mono.dim { color: var(--text-mute); }
</style>
@@ -0,0 +1,55 @@
<script setup lang="ts">
// Notification drawer. Right-side slide-out. Triggered by the topbar bell.
const drawer = useNotificationDrawer()
const TONE_COLOR: Record<string, string> = {
info: 'var(--info)',
ok: 'var(--ok)',
warn: 'var(--warn)',
bad: 'var(--bad)',
}
</script>
<template>
<SidePanel :open="drawer.open.value" width="sm" eyebrow="Notifications" title="Inbox" @close="drawer.hide">
<template #footer>
<UiButton variant="ghost" @click="drawer.markAllRead">Mark all read</UiButton>
<UiButton variant="secondary" @click="$router.push('/profile?tab=notifications'); drawer.hide()">Preferences</UiButton>
</template>
<div v-if="drawer.items.value.length === 0" class="empty">
<Mono dim>No notifications.</Mono>
</div>
<div v-else class="list">
<div v-for="n in drawer.items.value" :key="n.id" class="row" :class="{ unread: !n.read }">
<span class="dot" :style="{ background: TONE_COLOR[n.tone] }" />
<div class="meta">
<div class="row-head">
<div class="title">{{ n.title }}</div>
<Mono dim>{{ n.when }}</Mono>
</div>
<div class="body">{{ n.body }}</div>
</div>
</div>
</div>
</SidePanel>
</template>
<style scoped>
.empty { padding: 32px 0; text-align: center; }
.list { display: flex; flex-direction: column; gap: 0; }
.row {
display: flex;
gap: 12px;
padding: 14px 0;
border-bottom: 1px solid var(--border);
align-items: flex-start;
}
.row.unread { background: linear-gradient(to right, rgba(212, 255, 58, 0.06), transparent 60%); padding-left: 8px; margin-left: -8px; }
.dot { width: 8px; height: 8px; border-radius: 999px; margin-top: 6px; flex-shrink: 0; }
.meta { flex: 1; min-width: 0; }
.row-head { display: flex; justify-content: space-between; gap: 8px; align-items: center; }
.title { font-weight: 600; font-size: 13.5px; }
.body { font-size: 13px; color: var(--text-dim); margin-top: 4px; line-height: 1.45; }
</style>
+47
View File
@@ -0,0 +1,47 @@
<script setup lang="ts">
defineProps<{ eyebrow?: string; title: string; subtitle?: string }>()
</script>
<template>
<header class="page-header">
<div class="lhs">
<Eyebrow v-if="eyebrow">{{ eyebrow }}</Eyebrow>
<h1>{{ title }}</h1>
<p v-if="subtitle">{{ subtitle }}</p>
</div>
<div v-if="$slots.actions" class="rhs">
<slot name="actions" />
</div>
</header>
</template>
<style scoped>
.page-header {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 24px;
padding: 32px 40px 24px 40px;
border-bottom: 1px solid var(--border);
}
.lhs { min-width: 0; }
h1 {
font-family: var(--font-display);
font-weight: 600;
font-size: 32px;
letter-spacing: -0.025em;
margin: 8px 0 0 0;
line-height: 1.05;
}
p {
margin: 8px 0 0 0;
color: var(--text-mute);
font-size: 14px;
max-width: 640px;
}
.rhs { display: flex; gap: 8px; flex-shrink: 0; }
</style>
+444
View File
@@ -0,0 +1,444 @@
<script setup lang="ts">
// Portal sidebar. Faithful port of project/platform-app.jsx `Sidebar`. Always
// carbon. Workspace switcher button on top, nav in the middle, user footer at
// the bottom. Item sets vary by role:
//
// end-user → END_USER_NAV (flat list, no sections)
// customer admin → ADMIN_NAV (Workspace / Commercial / Other sections)
// partner admin → PARTNER_NAV (Commercial / Partner sections)
// partner-in-customer → ADMIN_NAV (acts-as), with "Exit partner view" chip
// immediately under the switcher
//
// Personal pages (profile, devices, security, notifications) are NOT in the
// admin/partner sidebar — they're reached via the topbar user menu in the
// source design.
import type { IconName } from './UiIcon.vue'
import { customers as fixtureCustomers } from '~/data/customers'
interface NavItem {
id: string
label: string
icon: IconName
href: string
badge?: number | string
}
interface NavSection { sec: string }
type NavRow = NavItem | NavSection
const isSection = (r: NavRow): r is NavSection => 'sec' in r
const { state } = usePortalTweaks()
const { collapsed, toggle } = useSidebar()
const partnerMode = usePartnerMode()
const route = useRoute()
// Section context is derived from the URL prefix, not the role tweak. This
// keeps the shell self-consistent: visiting /partner always shows the partner
// sidebar, /admin always shows admin, everything else is the end-user surface.
// The role tweak in TweaksPanel is a "preview as" affordance — it navigates
// you to the right landing page on switch, but it doesn't override the shell.
type Section = 'partner' | 'admin' | 'user'
const section = computed<Section>(() => {
if (partnerMode.isActive.value) return 'admin' // partner acting-as a customer
if (route.path.startsWith('/partner')) return 'partner'
if (route.path.startsWith('/admin')) return 'admin'
return 'user'
})
// "My profile" lives in the topbar avatar menu, not the sidebar — keeps the
// sidebar focused on places (workspace apps + admin work) while personal
// settings are one consistent menu-click away from any screen.
const END_USER_NAV: NavRow[] = [
{ id: 'dashboard', label: 'Dashboard', icon: 'home', href: '/' },
{ id: 'devices', label: 'Devices & sessions', icon: 'device', href: '/devices' },
{ id: 'security', label: 'Security', icon: 'shield', href: '/security' },
{ id: 'support', label: 'Help & support', icon: 'help', href: '/help' },
]
const ADMIN_NAV: NavRow[] = [
{ id: 'dashboard', label: 'Dashboard', icon: 'home', href: '/admin' },
{ sec: 'Workspace' },
{ id: 'users', label: 'Users & groups', icon: 'users', href: '/admin/users' },
{ id: 'mail', label: 'Mail settings', icon: 'mail', href: '/admin/mail' },
{ id: 'meetings', label: 'Meetings', icon: 'video', href: '/admin/meetings' },
{ id: 'chat', label: 'Chat', icon: 'chat', href: '/admin/chat' },
{ id: 'domains', label: 'Domains', icon: 'globe', href: '/admin/domains', badge: 1 },
{ id: 'storage', label: 'Storage', icon: 'database', href: '/admin/storage' },
{ id: 'security', label: 'Security & audit', icon: 'shield', href: '/admin/security' },
{ sec: 'Commercial' },
{ id: 'billing', label: 'Billing', icon: 'card', href: '/admin/billing' },
{ id: 'branding', label: 'Branding', icon: 'brush', href: '/admin/branding' },
{ id: 'integrations', label: 'Integrations', icon: 'plug', href: '/admin/integrations' },
{ sec: 'Other' },
{ id: 'support', label: 'Help & support', icon: 'help', href: '/help' },
]
const PARTNER_NAV: NavRow[] = [
{ id: 'p_dashboard', label: 'Partner dashboard', icon: 'home', href: '/partner' },
{ id: 'p_customers', label: 'Customer orgs', icon: 'building', href: '/partner/customers' },
{ sec: 'Commercial' },
{ id: 'p_billing', label: 'Partner billing', icon: 'card', href: '/partner/billing' },
{ id: 'p_reports', label: 'Reports', icon: 'database', href: '/partner/reports' },
{ sec: 'Partner' },
{ id: 'p_branding', label: 'Branding defaults', icon: 'brush', href: '/partner/branding' },
{ id: 'p_team', label: 'Partner team', icon: 'users', href: '/partner/team' },
{ id: 'p_audit', label: 'Partner audit', icon: 'file', href: '/partner/audit' },
{ id: 'p_settings', label: 'Partner settings', icon: 'shield', href: '/partner/settings' },
]
const navItems = computed<NavRow[]>(() => {
if (section.value === 'partner') {
// Inject the live customer count onto the Customer orgs row. Undefined
// when the count is 0 so the badge hides rather than rendering "0".
return PARTNER_NAV.map((row) =>
'id' in row && row.id === 'p_customers'
? { ...row, badge: partnerCustomerCount.value || undefined }
: row,
)
}
if (section.value === 'admin') return ADMIN_NAV
return END_USER_NAV
})
// Active row resolution by URL path. Specific paths first, then more general.
const currentId = computed(() => {
const p = route.path
if (p === '/') return 'dashboard'
if (p.startsWith('/profile')) return 'profile'
if (p.startsWith('/devices')) return 'devices'
if (p.startsWith('/security')) return 'security'
if (p.startsWith('/help')) return 'support'
if (p === '/admin') return 'dashboard'
if (p.startsWith('/admin/users')) return 'users'
if (p.startsWith('/admin/mail')) return 'mail'
if (p.startsWith('/admin/meetings')) return 'meetings'
if (p.startsWith('/admin/chat')) return 'chat'
if (p.startsWith('/admin/domains')) return 'domains'
if (p.startsWith('/admin/storage')) return 'storage'
if (p.startsWith('/admin/security')) return 'security'
if (p.startsWith('/admin/billing')) return 'billing'
if (p.startsWith('/admin/branding')) return 'branding'
if (p.startsWith('/admin/integrations')) return 'integrations'
if (p === '/partner') return 'p_dashboard'
if (p.startsWith('/partner/customers')) return 'p_customers'
if (p.startsWith('/partner/billing')) return 'p_billing'
if (p.startsWith('/partner/reports')) return 'p_reports'
if (p.startsWith('/partner/branding')) return 'p_branding'
if (p.startsWith('/partner/team')) return 'p_team'
if (p.startsWith('/partner/audit')) return 'p_audit'
if (p.startsWith('/partner/settings')) return 'p_settings'
return ''
})
// Customer currently being acted-as (partner-in-customer mode)
const activeCustomer = computed(() =>
fixtureCustomers.find((c) => c.id === partnerMode.activeCustomerId.value) || null,
)
// Workspace-switcher content matches the URL section.
type SwitcherKind = 'customer' | 'partner' | 'in-customer'
const switcherKind = computed<SwitcherKind>(() => {
if (partnerMode.isActive.value) return 'in-customer'
if (section.value === 'partner') return 'partner'
return 'customer'
})
const router = useRouter()
function exitCustomer() {
partnerMode.exit()
router.push('/partner/customers')
}
// Real partner identity + customer count. Only fetched for partner-staff
// users (gated via isPartnerStaff) — keeps the end-user / admin shells from
// hitting a 403 against the partner-scoped endpoint. useFetch with a stable
// key dedupes with the /partner/customers page's request.
const { partner, isPartnerStaff } = useMe()
const { data: partnerTenants } = await useFetch<unknown[]>('/api/partner/tenants', {
key: 'partner-tenants',
default: () => [],
immediate: isPartnerStaff.value,
})
const partnerCustomerCount = computed(() => partnerTenants.value?.length ?? 0)
</script>
<template>
<aside class="sidebar" :class="{ collapsed }">
<!-- Workspace switcher -->
<button class="switcher" :title="collapsed ? 'Workspace' : undefined">
<!-- Customer admin: bone tile with node-mark -->
<template v-if="switcherKind === 'customer'">
<span class="ws-tile bone">
<NodeMark :size="28" fg="#0A0A0A" accent="var(--signal)" />
</span>
<div v-if="!collapsed" class="ws-text">
<div class="ws-name">baslund</div>
<div class="ws-sub">Business · 11/25</div>
</div>
</template>
<!-- Partner admin (portfolio view): carbon tile with chartreuse 'n' -->
<template v-else-if="switcherKind === 'partner'">
<span class="ws-tile carbon">{{ (partner?.name ?? 'n').charAt(0).toLowerCase() }}</span>
<div v-if="!collapsed" class="ws-text">
<div class="ws-name">{{ partner?.name ?? '—' }}</div>
<div class="ws-sub">
Partner · {{ partnerCustomerCount }} {{ partnerCustomerCount === 1 ? 'customer' : 'customers' }}
</div>
</div>
</template>
<!-- Partner-in-customer mode: customer brand color tile, "via NordicMSP" -->
<template v-else>
<span class="ws-tile" :style="{ background: activeCustomer?.brandColor || '#0A0A0A' }" />
<div v-if="!collapsed" class="ws-text">
<div class="ws-name">{{ activeCustomer?.name }}</div>
<div class="ws-sub mono">via NordicMSP</div>
</div>
</template>
<UiIcon v-if="!collapsed" name="chevUpDown" :size="14" stroke="var(--side-mute)" />
</button>
<!-- Exit partner view chip (when acting-as a customer) -->
<button v-if="partnerMode.isActive.value" class="exit-chip" @click="exitCustomer">
<UiIcon name="chevLeft" :size="13" />
<span v-if="!collapsed">Exit partner view</span>
</button>
<!-- Nav -->
<nav>
<template v-for="(item, i) in navItems" :key="i">
<div v-if="isSection(item) && !collapsed" class="section">{{ item.sec }}</div>
<div v-else-if="isSection(item)" class="section-spacer" />
<NuxtLink
v-else
:to="item.href"
:class="['row', { active: currentId === item.id }]"
:title="collapsed ? item.label : undefined"
>
<UiIcon :name="item.icon" :size="15" />
<span v-if="!collapsed" class="label">{{ item.label }}</span>
<span v-if="!collapsed && item.badge !== undefined" class="badge">{{ item.badge }}</span>
</NuxtLink>
</template>
</nav>
<!-- User footer -->
<div class="foot">
<button class="user" :title="collapsed ? 'Anne Hansen' : undefined">
<Avatar name="Anne Hansen" :size="26" />
<div v-if="!collapsed" class="user-text">
<div class="user-name">Anne Hansen</div>
<div class="user-role">
{{ section === 'partner' ? 'partner admin' : section === 'admin' ? 'admin' : 'user' }}
</div>
</div>
<UiIcon v-if="!collapsed" name="chevUpDown" :size="12" stroke="var(--side-mute)" />
</button>
<button class="collapse" @click="toggle" :title="collapsed ? 'Expand · ⌘[' : 'Collapse · ⌘['">
<UiIcon :name="collapsed ? 'chevRight' : 'chevLeft'" :size="11" />
<span v-if="!collapsed">collapse · [</span>
</button>
</div>
</aside>
</template>
<style scoped>
.sidebar {
width: 232px;
flex-shrink: 0;
background: var(--side-bg);
color: var(--side-text);
border-right: 1px solid var(--side-border);
display: flex;
flex-direction: column;
align-self: stretch;
transition: width 180ms ease;
position: sticky;
top: 0;
max-height: 100vh;
}
.sidebar.collapsed { width: 56px; }
/* Workspace switcher row */
.switcher {
display: flex;
align-items: center;
gap: 10px;
padding: 14px;
margin: 8px;
background: transparent;
border: 1px solid transparent;
border-radius: 8px;
color: inherit;
font-family: inherit;
cursor: pointer;
text-align: left;
min-height: 36px;
}
.switcher:hover { background: var(--side-hover); }
.sidebar.collapsed .switcher { padding: 8px; justify-content: center; margin: 8px 6px; }
.ws-tile {
width: 36px;
height: 36px;
border-radius: 8px;
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
}
.ws-tile.bone { background: #F4F3EE; }
.ws-tile.carbon {
background: #0A0A0A;
color: var(--signal);
font-family: var(--font-mono);
font-weight: 700;
font-size: 18px;
}
.ws-text { flex: 1; min-width: 0; }
.ws-name {
font-family: var(--font-mono);
font-size: 13px;
font-weight: 600;
line-height: 1.2;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ws-sub {
font-size: 11px;
color: var(--side-mute);
margin-top: 2px;
}
.ws-sub.mono { font-family: var(--font-mono); font-size: 10px; }
/* Exit partner chip — sits between switcher and nav */
.exit-chip {
display: flex;
align-items: center;
gap: 8px;
margin: 0 8px 8px 8px;
padding: 8px 12px;
background: rgba(125, 160, 255, 0.14);
color: #A8C0FF;
border: 1px solid rgba(125, 160, 255, 0.18);
border-radius: 6px;
cursor: pointer;
font-family: inherit;
font-size: 12px;
text-align: left;
}
.exit-chip:hover { background: rgba(125, 160, 255, 0.22); }
.sidebar.collapsed .exit-chip { justify-content: center; padding: 8px 0; }
/* Nav */
nav {
flex: 1;
padding: 4px 8px;
overflow-y: auto;
}
.section {
padding: 14px 12px 6px 12px;
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--side-mute);
font-weight: 500;
}
.section-spacer { height: 12px; }
.row {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 8px 12px;
background: transparent;
color: var(--side-dim);
border: none;
border-radius: 6px;
text-decoration: none;
font-family: inherit;
font-size: 13px;
font-weight: 400;
margin-bottom: 1px;
transition: background 0.12s;
}
.sidebar.collapsed .row { padding: 8px 0; justify-content: center; }
.row:hover { background: var(--side-hover); color: var(--side-text); }
.row.active {
background: var(--side-active);
color: var(--side-text);
font-weight: 500;
}
.label { flex: 1; min-width: 0; }
/* Source uses signal accent for badges */
.badge {
font-family: var(--font-mono);
font-size: 10px;
padding: 1px 6px;
border-radius: 3px;
background: var(--accent);
color: var(--accent-fg);
font-weight: 600;
line-height: 1.4;
}
/* User footer */
.foot {
border-top: 1px solid var(--side-border);
padding: 8px;
}
.user {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 8px;
background: transparent;
border: none;
border-radius: 6px;
color: var(--side-dim);
font-family: inherit;
font-size: 13px;
cursor: pointer;
text-align: left;
}
.user:hover { background: var(--side-hover); }
.user-text { flex: 1; min-width: 0; }
.user-name { font-size: 12px; color: var(--side-text); font-weight: 500; }
.user-role {
font-size: 10px;
color: var(--side-mute);
font-family: var(--font-mono);
margin-top: 1px;
}
.sidebar.collapsed .user { justify-content: center; padding: 8px 0; }
/* Collapse toggle */
.collapse {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 8px;
margin-top: 4px;
background: transparent;
border: none;
border-radius: 6px;
color: var(--side-mute);
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.04em;
cursor: pointer;
}
.collapse:hover { background: var(--side-hover); color: var(--side-dim); }
.sidebar.collapsed .collapse { justify-content: center; padding: 8px 0; }
</style>
+285
View File
@@ -0,0 +1,285 @@
<script setup lang="ts">
// Portal topbar: workspace label, optional org switcher (partner admins), global
// search, app launcher, notifications, profile menu.
import { customers } from '~/data/customers'
const launcher = useAppLauncher()
const drawer = useNotificationDrawer()
const partnerMode = usePartnerMode()
const router = useRouter()
const route = useRoute()
// Section context is URL-driven (same rule as the sidebar). The org switcher
// only appears in the partner section or when acting-as a customer.
type Section = 'partner' | 'admin' | 'user'
const section = computed<Section>(() => {
if (partnerMode.isActive.value) return 'admin'
if (route.path.startsWith('/partner')) return 'partner'
if (route.path.startsWith('/admin')) return 'admin'
return 'user'
})
const showOrgSwitcher = computed(() =>
section.value === 'partner' || partnerMode.isActive.value,
)
const activeCustomer = computed(() =>
customers.find((c) => c.id === partnerMode.activeCustomerId.value) || null,
)
const orgSwitcherOpen = ref(false)
function pickCustomer(id: string) {
orgSwitcherOpen.value = false
partnerMode.enter(id)
router.push('/admin')
}
function leaveCustomerMode() {
orgSwitcherOpen.value = false
partnerMode.exit()
router.push('/partner/customers')
}
// Dummy global search — opens AppLauncher for now until real search is wired.
const searchValue = ref('')
</script>
<template>
<header>
<!-- Org switcher · only in the partner section or when acting as a customer -->
<button
v-if="showOrgSwitcher"
class="orgswitch"
@click="orgSwitcherOpen = !orgSwitcherOpen"
>
<span
class="org-chip"
:style="{ background: activeCustomer?.brandColor || '#0A0A0A' }"
>
{{ (activeCustomer?.name || 'NordicMSP').slice(0, 1) }}
</span>
<span class="org-name">{{ activeCustomer?.name || 'NordicMSP · Partner view' }}</span>
<UiIcon name="chevDown" :size="12" />
</button>
<button class="palette" type="button" @click="launcher.show">
<UiIcon name="search" :size="13" stroke="var(--text-mute)" />
<input
v-model="searchValue"
type="text"
placeholder="Search mail, files, people…"
@click.stop
@focus.stop
/>
<span class="kbd">K</span>
</button>
<div class="right">
<button class="icon-btn" title="Apps" @click="launcher.toggle">
<UiIcon name="waffle" :size="14" />
</button>
<button class="icon-btn" title="Notifications" @click="drawer.toggle">
<UiIcon name="bell" :size="14" />
<span v-if="drawer.unreadCount.value > 0" class="icon-btn-dot" />
</button>
<PortalUserMenu />
</div>
<!-- Org switcher dropdown -->
<Teleport to="body">
<div v-if="orgSwitcherOpen" class="org-drop-scrim" @click="orgSwitcherOpen = false" />
<div v-if="orgSwitcherOpen" class="org-drop">
<div class="org-drop-head">
<Eyebrow>NordicMSP · {{ customers.length }} customers</Eyebrow>
</div>
<button class="org-drop-row" :class="{ on: !partnerMode.isActive.value }" @click="leaveCustomerMode">
<span class="org-drop-chip" style="background: #0A0A0A">N</span>
<div class="org-drop-meta">
<div class="org-drop-name">Partner view</div>
<Mono dim>portfolio overview</Mono>
</div>
</button>
<div class="org-drop-divider" />
<button
v-for="c in customers"
:key="c.id"
class="org-drop-row"
:class="{ on: partnerMode.activeCustomerId.value === c.id }"
@click="pickCustomer(c.id)"
>
<span class="org-drop-chip" :style="{ background: c.brandColor }">{{ c.name.slice(0, 1) }}</span>
<div class="org-drop-meta">
<div class="org-drop-name">{{ c.name }}</div>
<Mono dim>{{ c.domain }} · {{ c.plan }}</Mono>
</div>
</button>
</div>
</Teleport>
</header>
</template>
<style scoped>
header {
height: 52px;
flex-shrink: 0;
display: flex;
align-items: center;
gap: 12px;
padding: 0 16px;
background: var(--bg);
border-bottom: 1px solid var(--border);
}
.orgswitch {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 5px 10px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
font-family: inherit;
font-size: 12px;
color: var(--text);
cursor: pointer;
flex-shrink: 0;
}
.orgswitch:hover { background: var(--elevated); }
.org-chip {
width: 18px; height: 18px;
border-radius: 4px;
display: inline-flex;
align-items: center;
justify-content: center;
color: #F4F3EE;
font-family: var(--font-mono);
font-weight: 700;
font-size: 10px;
}
.org-name { font-weight: 500; max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.palette {
flex: 1;
min-width: 0;
max-width: 540px;
display: flex;
align-items: center;
gap: 10px;
height: 32px;
padding: 0 12px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
cursor: text;
color: var(--text-mute);
font-family: inherit;
font-size: 12px;
text-align: left;
}
.palette input {
flex: 1;
background: transparent;
border: none;
outline: none;
font: inherit;
color: var(--text);
min-width: 0;
}
.palette input::placeholder { color: var(--text-mute); }
.kbd {
font-family: var(--font-mono);
font-size: 10px;
padding: 2px 6px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 3px;
color: var(--text-mute);
flex-shrink: 0;
}
.right {
margin-left: auto;
display: flex;
align-items: center;
gap: 12px;
flex-shrink: 0;
}
.icon-btn {
position: relative;
height: 32px;
width: 32px;
border-radius: 6px;
background: var(--surface);
border: 1px solid var(--border);
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--text);
cursor: pointer;
}
.icon-btn:hover { background: var(--elevated); }
.icon-btn-dot {
position: absolute;
top: 6px;
right: 7px;
width: 6px;
height: 6px;
border-radius: 999px;
background: var(--bad);
border: 1.5px solid var(--bg);
}
.org-drop-scrim {
position: fixed;
inset: 0;
z-index: 90;
background: transparent;
}
.org-drop {
position: fixed;
top: 56px;
left: 232px;
z-index: 100;
width: 320px;
max-height: 60vh;
overflow-y: auto;
background: var(--elevated);
border: 1px solid var(--border);
border-radius: 8px;
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.32);
padding: 6px;
}
.org-drop-head { padding: 8px 10px 4px 10px; }
.org-drop-row {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 8px 10px;
background: transparent;
border: 1px solid transparent;
border-radius: 6px;
color: var(--text);
font-family: inherit;
font-size: 13px;
text-align: left;
cursor: pointer;
}
.org-drop-row:hover { background: var(--surface); }
.org-drop-row.on { background: var(--surface); border-color: var(--border); }
.org-drop-chip {
width: 22px; height: 22px;
border-radius: 5px;
display: inline-flex;
align-items: center;
justify-content: center;
color: #F4F3EE;
font-family: var(--font-mono);
font-weight: 700;
font-size: 11px;
}
.org-drop-meta { min-width: 0; flex: 1; }
.org-drop-name { font-weight: 500; }
.org-drop-divider { height: 1px; background: var(--border); margin: 6px 0; }
</style>
@@ -0,0 +1,194 @@
<script setup lang="ts">
// Floating tweaks panel for the portal. Exposes role switching (the most
// important tweak — flips end-user/customer-admin/partner-admin views),
// plus theme/density/accent. Bottom-right corner, lives in default layout.
const { state, setTheme, setDensity, setAccent, setRole } = usePortalTweaks()
const partnerMode = usePartnerMode()
const router = useRouter()
const open = ref(false)
function changeRole(role: 'end-user' | 'customer-admin' | 'partner-admin') {
setRole(role)
// If switching out of partner-admin while in customer mode, exit it.
if (role !== 'partner-admin') partnerMode.exit()
// Route to a sensible default for the new role.
if (role === 'partner-admin') router.push('/partner')
else if (role === 'customer-admin') router.push('/admin')
else router.push('/')
}
const ACCENTS = [
{ key: 'signal', label: 'Signal', hex: '#D4FF3A' },
{ key: 'cobalt', label: 'Cobalt', hex: '#3F6BFF' },
{ key: 'coral', label: 'Coral', hex: '#FF6B4A' },
{ key: 'moss', label: 'Moss', hex: '#5B8C5A' },
] as const
</script>
<template>
<div class="tweaks-root">
<button class="trigger" :class="{ on: open }" type="button" :title="open ? 'Close tweaks' : 'Open tweaks'" @click="open = !open">
<UiIcon :name="open ? 'x' : 'shield'" :size="14" />
</button>
<Transition name="tweaks">
<div v-if="open" class="panel" role="dialog" aria-label="Tweaks">
<header>
<Eyebrow>Tweaks · prototype</Eyebrow>
<button class="x" type="button" aria-label="Close" @click="open = false">
<UiIcon name="x" :size="11" />
</button>
</header>
<section>
<label class="row-label">Role</label>
<div class="seg col">
<button :class="{ on: state.role === 'end-user' }" @click="changeRole('end-user')">End user</button>
<button :class="{ on: state.role === 'customer-admin' }" @click="changeRole('customer-admin')">Customer admin</button>
<button :class="{ on: state.role === 'partner-admin' }" @click="changeRole('partner-admin')">Partner admin</button>
</div>
</section>
<section>
<label class="row-label">Theme</label>
<div class="seg">
<button :class="{ on: state.theme === 'light' }" @click="setTheme('light')">Light</button>
<button :class="{ on: state.theme === 'dark' }" @click="setTheme('dark')">Dark</button>
</div>
</section>
<section>
<label class="row-label">Density</label>
<div class="seg">
<button :class="{ on: state.density === 'comfy' }" @click="setDensity('comfy')">Comfy</button>
<button :class="{ on: state.density === 'compact' }" @click="setDensity('compact')">Compact</button>
</div>
</section>
<section>
<label class="row-label">Accent · whitelabel</label>
<div class="swatches">
<button
v-for="a in ACCENTS"
:key="a.key"
:class="{ on: state.accent === a.key }"
:title="a.label"
:style="{ background: a.hex }"
@click="setAccent(a.key)"
/>
</div>
</section>
<footer>
<Mono dim>// saved to localStorage</Mono>
</footer>
</div>
</Transition>
</div>
</template>
<style scoped>
.tweaks-root {
position: fixed;
right: 16px;
bottom: 16px;
z-index: 50;
}
.trigger {
width: 34px;
height: 34px;
border-radius: 999px;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text-dim);
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.18);
}
.trigger:hover { color: var(--text); border-color: var(--border-hi); }
.trigger.on { background: var(--text); color: var(--bg); border-color: var(--text); }
.panel {
position: absolute;
right: 0;
bottom: 44px;
width: 280px;
background: var(--elevated);
border: 1px solid var(--border);
border-radius: 10px;
padding: 12px;
display: flex;
flex-direction: column;
gap: 12px;
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.32);
}
header { display: flex; justify-content: space-between; align-items: center; }
.x {
width: 22px;
height: 22px;
border: 0;
background: transparent;
color: var(--text-mute);
border-radius: 6px;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
}
.x:hover { background: var(--surface); color: var(--text); }
section { display: flex; flex-direction: column; gap: 6px; }
.row-label {
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--text-mute);
font-weight: 500;
}
.seg {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 4px;
padding: 3px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 7px;
}
.seg.col { grid-template-columns: 1fr; }
.seg button {
appearance: none;
border: 0;
background: transparent;
color: var(--text-dim);
font-family: inherit;
font-size: 12px;
font-weight: 500;
padding: 6px 8px;
border-radius: 5px;
cursor: pointer;
}
.seg button:hover { color: var(--text); }
.seg button.on { background: var(--text); color: var(--bg); }
.swatches { display: flex; gap: 6px; }
.swatches button {
width: 28px;
height: 28px;
border-radius: 6px;
border: 2px solid var(--border);
cursor: pointer;
}
.swatches button.on { border-color: var(--text); transform: scale(1.05); }
footer { padding-top: 4px; border-top: 1px dashed var(--border); }
.tweaks-enter-active, .tweaks-leave-active { transition: opacity 0.12s, transform 0.12s; }
.tweaks-enter-from, .tweaks-leave-to { opacity: 0; transform: translateY(4px); }
</style>
+251
View File
@@ -0,0 +1,251 @@
<script setup lang="ts">
// Avatar dropdown in the topbar. Click the avatar → menu with identity card,
// quick theme toggle, profile/devices/security/help links, and Sign out.
//
// During the prototype OIDC is bypassed (`definePageMeta({ oidcAuth: { enabled: false } })`),
// so `useOidcAuth().user` is empty — we fall back to a fixture identity. Once
// auth is wired, the real session populates name + email automatically.
const { state: tweaks, setTheme } = usePortalTweaks()
const toast = useToast()
const route = useRoute()
const router = useRouter()
// Try the real session first; fall back to fixture for prototype review.
let oidc: ReturnType<typeof useOidcAuth> | null = null
try { oidc = useOidcAuth() } catch { oidc = null }
const open = ref(false)
const rootRef = ref<HTMLElement | null>(null)
const displayName = computed(() => {
const u = oidc?.user?.value
return u?.userInfo?.name || u?.userName || 'Anne Hansen'
})
const email = computed(() => {
const u = oidc?.user?.value
return (u?.userInfo as { email?: string } | undefined)?.email || 'anne@baslund.dk'
})
function toggle() {
open.value = !open.value
}
function close() {
open.value = false
}
function flipTheme() {
const next = tweaks.value.theme === 'dark' ? 'light' : 'dark'
setTheme(next)
}
function pickTheme(v: 'light' | 'dark') {
setTheme(v)
}
async function signOut() {
close()
// Use our custom /api/auth/sign-out (see server/api/auth/sign-out.get.ts).
// It clears the local session and bounces to /signed-out which fires
// Authentik's end-session in a hidden iframe. Cleaner than nuxt-oidc-auth's
// RP-initiated chain because Authentik 2025 doesn't honor
// post_logout_redirect_uri reliably.
await navigateTo('/api/auth/sign-out', { external: true })
}
function go(path: string) {
close()
router.push(path)
}
function onDocClick(e: MouseEvent) {
if (!rootRef.value || !open.value) return
if (!rootRef.value.contains(e.target as Node)) close()
}
// Auto-close on route change so the menu doesn't linger after navigation.
watch(() => route.path, close)
onMounted(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape' && open.value) close()
}
document.addEventListener('keydown', onKey)
document.addEventListener('mousedown', onDocClick)
onBeforeUnmount(() => {
document.removeEventListener('keydown', onKey)
document.removeEventListener('mousedown', onDocClick)
})
})
</script>
<template>
<div ref="rootRef" class="usermenu">
<button class="trigger" :class="{ on: open }" type="button" :title="displayName" @click="toggle">
<Avatar :name="displayName" :size="28" />
</button>
<Transition name="menu">
<div v-if="open" class="menu" role="menu" aria-label="User menu">
<div class="ident">
<Avatar :name="displayName" :size="36" />
<div class="ident-meta">
<div class="ident-name">{{ displayName }}</div>
<Mono dim>{{ email }}</Mono>
</div>
</div>
<div class="divider" />
<!-- Theme segmented control -->
<div class="row-label"><Eyebrow>Theme</Eyebrow></div>
<div class="seg">
<button :class="{ on: tweaks.theme === 'light' }" @click="pickTheme('light')">
<UiIcon name="sun" :size="13" />
Light
</button>
<button :class="{ on: tweaks.theme === 'dark' }" @click="pickTheme('dark')">
<UiIcon name="moon" :size="13" />
Dark
</button>
</div>
<div class="divider" />
<!-- Personal links -->
<button class="item" role="menuitem" @click="go('/profile')">
<UiIcon name="users" :size="14" />
<span class="label">My profile</span>
<Mono dim>P</Mono>
</button>
<button class="item" role="menuitem" @click="go('/devices')">
<UiIcon name="device" :size="14" />
<span class="label">Devices &amp; sessions</span>
</button>
<button class="item" role="menuitem" @click="go('/security')">
<UiIcon name="shield" :size="14" />
<span class="label">Security</span>
</button>
<button class="item" role="menuitem" @click="go('/help')">
<UiIcon name="help" :size="14" />
<span class="label">Help &amp; support</span>
</button>
<div class="divider" />
<button class="item danger" role="menuitem" @click="signOut">
<UiIcon name="logout" :size="14" />
<span class="label">Sign out</span>
</button>
</div>
</Transition>
</div>
</template>
<style scoped>
.usermenu { position: relative; }
.trigger {
appearance: none;
background: transparent;
border: 1px solid transparent;
border-radius: 999px;
padding: 0;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
}
.trigger:hover { border-color: var(--border); }
.trigger.on { border-color: var(--border-hi); }
.menu {
position: absolute;
top: calc(100% + 8px);
right: 0;
z-index: 100;
width: 260px;
background: var(--elevated);
border: 1px solid var(--border);
border-radius: 10px;
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.32);
padding: 8px;
display: flex;
flex-direction: column;
gap: 2px;
}
.ident {
display: flex;
align-items: center;
gap: 10px;
padding: 10px;
}
.ident-meta { min-width: 0; flex: 1; }
.ident-name {
font-size: 13px;
font-weight: 600;
font-family: var(--font-display);
letter-spacing: -0.01em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.divider { height: 1px; background: var(--border); margin: 4px 0; }
.row-label { padding: 8px 10px 4px 10px; }
.seg {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 4px;
padding: 3px;
margin: 0 4px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 7px;
}
.seg button {
appearance: none;
border: 0;
background: transparent;
color: var(--text-dim);
font-family: inherit;
font-size: 12px;
font-weight: 500;
padding: 6px 8px;
border-radius: 5px;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
}
.seg button:hover { color: var(--text); }
.seg button.on { background: var(--text); color: var(--bg); }
.item {
appearance: none;
display: flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 8px 10px;
background: transparent;
border: 0;
border-radius: 6px;
color: var(--text);
font-family: inherit;
font-size: 13px;
text-align: left;
text-decoration: none;
cursor: pointer;
}
.item:hover { background: var(--surface); }
.item .label { flex: 1; }
.item.danger { color: var(--bad); }
.item.danger:hover { background: rgba(240, 88, 88, 0.08); }
.menu-enter-active, .menu-leave-active { transition: opacity 0.12s, transform 0.12s; }
.menu-enter-from, .menu-leave-to { opacity: 0; transform: translateY(-4px); }
</style>
+138
View File
@@ -0,0 +1,138 @@
<script setup lang="ts">
// Right-side slide-out panel. Used for user/group/customer details where the
// underlying list should remain visible behind a dimmed scrim. Closes on
// Escape or backdrop click.
const props = withDefaults(
defineProps<{
open: boolean
width?: 'sm' | 'md' | 'lg'
title?: string
eyebrow?: string
}>(),
{ width: 'md' },
)
const emit = defineEmits<{ close: [] }>()
const widthPx = computed(() => ({ sm: 400, md: 600, lg: 800 })[props.width || 'md'])
onMounted(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape' && props.open) emit('close')
}
document.addEventListener('keydown', onKey)
onBeforeUnmount(() => document.removeEventListener('keydown', onKey))
})
</script>
<template>
<Teleport to="body">
<Transition name="scrim">
<div v-if="open" class="scrim" @click="emit('close')" />
</Transition>
<Transition name="panel">
<aside v-if="open" class="panel" :style="{ width: widthPx + 'px' }">
<header>
<div class="lhs">
<Eyebrow v-if="eyebrow">{{ eyebrow }}</Eyebrow>
<h3 v-if="title">{{ title }}</h3>
<slot name="header" />
</div>
<button class="close" @click="emit('close')" aria-label="Close">
<UiIcon name="x" :size="18" />
</button>
</header>
<div v-if="$slots.tabs" class="tab-strip">
<slot name="tabs" />
</div>
<div class="body">
<slot />
</div>
<footer v-if="$slots.footer">
<slot name="footer" />
</footer>
</aside>
</Transition>
</Teleport>
</template>
<style scoped>
.scrim {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.35);
z-index: 70;
}
.panel {
position: fixed;
top: 0;
right: 0;
bottom: 0;
max-width: calc(100vw - 32px);
background: var(--bg);
border-left: 1px solid var(--border);
box-shadow: -24px 0 80px rgba(0, 0, 0, 0.32);
z-index: 80;
display: flex;
flex-direction: column;
}
header {
padding: 20px 24px 16px 24px;
border-bottom: 1px solid var(--border);
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
flex-shrink: 0;
}
.lhs { min-width: 0; flex: 1; }
h3 {
margin: 6px 0 0 0;
font-family: var(--font-display);
font-weight: 600;
font-size: 18px;
letter-spacing: -0.015em;
}
.close {
background: transparent;
border: none;
padding: 6px;
border-radius: 4px;
color: var(--text-dim);
cursor: pointer;
}
.close:hover { background: var(--surface); }
.tab-strip {
padding: 0 24px;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.body {
flex: 1;
overflow-y: auto;
padding: 22px 24px;
}
footer {
padding: 14px 24px;
border-top: 1px solid var(--border);
display: flex;
gap: 8px;
justify-content: flex-end;
background: var(--surface);
flex-shrink: 0;
}
.scrim-enter-active, .scrim-leave-active { transition: opacity 0.18s; }
.scrim-enter-from, .scrim-leave-to { opacity: 0; }
.panel-enter-active, .panel-leave-active { transition: transform 0.24s cubic-bezier(0.32, 0.72, 0, 1); }
.panel-enter-from, .panel-leave-to { transform: translateX(100%); }
</style>
+45
View File
@@ -0,0 +1,45 @@
<script setup lang="ts">
withDefaults(
defineProps<{
label: string
value: string | number
delta?: string
deltaTone?: 'up' | 'down'
hint?: string
}>(),
{ deltaTone: 'up' },
)
</script>
<template>
<div class="stat">
<Eyebrow>{{ label }}</Eyebrow>
<div class="value">{{ value }}</div>
<div v-if="delta || hint" class="meta">
<span v-if="delta" class="delta" :data-tone="deltaTone">{{ delta }}</span>
<Mono v-if="hint" dim>{{ hint }}</Mono>
</div>
</div>
</template>
<style scoped>
.stat { display: flex; flex-direction: column; gap: 6px; min-width: 0; }
.value {
font-family: var(--font-display);
font-weight: 600;
font-size: 26px;
letter-spacing: -0.02em;
line-height: 1;
font-variant-numeric: tabular-nums;
}
.meta { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.delta {
font-family: var(--font-mono);
font-size: 11px;
font-weight: 600;
padding: 2px 6px;
border-radius: 4px;
}
.delta[data-tone='up'] { background: rgba(31, 138, 91, 0.12); color: var(--ok); }
.delta[data-tone='down'] { background: rgba(226, 48, 48, 0.12); color: var(--bad); }
</style>
+22
View File
@@ -0,0 +1,22 @@
<script setup lang="ts">
withDefaults(
defineProps<{ color?: string; size?: number; glow?: boolean }>(),
{ color: 'var(--ok)', size: 8, glow: true },
)
</script>
<template>
<span
class="dot"
:style="{
width: size + 'px',
height: size + 'px',
background: color,
boxShadow: glow ? `0 0 0 3px ${color}22` : 'none',
}"
/>
</template>
<style scoped>
.dot { display: inline-block; border-radius: 999px; flex-shrink: 0; }
</style>
+63
View File
@@ -0,0 +1,63 @@
<script setup lang="ts">
interface TabItem {
value: string
label: string
count?: number
}
defineProps<{ items: TabItem[]; modelValue: string }>()
defineEmits<{ 'update:modelValue': [string] }>()
</script>
<template>
<div class="tabs">
<button
v-for="it in items"
:key="it.value"
:class="{ active: it.value === modelValue }"
@click="$emit('update:modelValue', it.value)"
>
{{ it.label }}
<span v-if="it.count !== undefined" class="count">{{ it.count }}</span>
</button>
</div>
</template>
<style scoped>
.tabs {
display: flex;
gap: 0;
border-bottom: 1px solid var(--border);
}
button {
background: transparent;
border: none;
padding: 10px 14px;
font-size: 13px;
font-weight: 500;
color: var(--text-mute);
border-bottom: 2px solid transparent;
margin-bottom: -1px;
cursor: pointer;
font-family: inherit;
display: inline-flex;
align-items: center;
gap: 6px;
}
button.active {
color: var(--text);
font-weight: 600;
border-bottom-color: var(--text);
}
.count {
font-family: var(--font-mono);
font-size: 11px;
background: var(--bg);
padding: 1px 6px;
border-radius: 3px;
color: var(--text-mute);
}
</style>
+74
View File
@@ -0,0 +1,74 @@
<script setup lang="ts">
// Toast stack mounted once in the default layout. Top-right.
const { toasts, dismiss } = useToast()
const TONE_COLOR: Record<string, string> = {
info: 'var(--info)',
ok: 'var(--ok)',
warn: 'var(--warn)',
bad: 'var(--bad)',
}
</script>
<template>
<div class="stack">
<TransitionGroup name="toast">
<div v-for="t in toasts" :key="t.id" class="toast" :data-tone="t.tone">
<span class="dot" :style="{ background: TONE_COLOR[t.tone] }" />
<div class="body">
<div class="msg">{{ t.message }}</div>
<div v-if="t.hint" class="hint">{{ t.hint }}</div>
</div>
<button class="x" @click="dismiss(t.id)">
<UiIcon name="x" :size="12" />
</button>
</div>
</TransitionGroup>
</div>
</template>
<style scoped>
.stack {
position: fixed;
top: 16px;
right: 16px;
display: flex;
flex-direction: column;
gap: 8px;
z-index: 200;
pointer-events: none;
}
.toast {
pointer-events: auto;
background: var(--elevated);
border: 1px solid var(--border);
border-radius: 8px;
padding: 10px 12px;
display: flex;
gap: 10px;
align-items: flex-start;
min-width: 280px;
max-width: 360px;
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.28);
}
.dot { width: 8px; height: 8px; border-radius: 999px; margin-top: 6px; flex-shrink: 0; }
.body { flex: 1; min-width: 0; }
.msg { font-size: 13px; font-weight: 500; }
.hint { font-family: var(--font-mono); font-size: 11px; color: var(--text-mute); margin-top: 2px; }
.x {
background: transparent;
border: 0;
padding: 4px;
border-radius: 4px;
color: var(--text-mute);
cursor: pointer;
}
.x:hover { background: var(--surface); color: var(--text); }
.toast-enter-active, .toast-leave-active { transition: opacity 0.18s, transform 0.18s; }
.toast-enter-from, .toast-leave-to { opacity: 0; transform: translateX(20px); }
</style>
+54
View File
@@ -0,0 +1,54 @@
<script setup lang="ts">
type Variant = 'primary' | 'secondary' | 'ghost' | 'dark' | 'danger'
type Size = 'sm' | 'md' | 'lg'
withDefaults(
defineProps<{ variant?: Variant; size?: Size; type?: 'button' | 'submit'; disabled?: boolean }>(),
{ variant: 'secondary', size: 'md', type: 'button', disabled: false },
)
</script>
<template>
<button :type="type" :disabled="disabled" :data-variant="variant" :data-size="size">
<slot name="leading" />
<slot />
<slot name="trailing" />
</button>
</template>
<style scoped>
button {
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 6px;
font-family: inherit;
font-weight: 500;
cursor: pointer;
white-space: nowrap;
transition: background 120ms ease, border-color 120ms ease, transform 60ms ease;
border: 1px solid;
}
button:active:not(:disabled) { transform: translateY(1px); }
button:disabled { opacity: 0.5; cursor: not-allowed; }
button[data-size='sm'] { height: 28px; padding: 0 10px; font-size: 12px; gap: 6px; }
button[data-size='md'] { height: 34px; padding: 0 14px; font-size: 13px; gap: 8px; }
button[data-size='lg'] { height: 42px; padding: 0 18px; font-size: 14px; gap: 10px; }
button[data-variant='primary'] { background: var(--accent); color: var(--accent-fg); border-color: var(--accent); font-weight: 600; }
button[data-variant='primary']:hover:not(:disabled) { filter: brightness(0.92); }
button[data-variant='secondary'] { background: var(--surface); color: var(--text); border-color: var(--border); }
button[data-variant='secondary']:hover:not(:disabled) { background: var(--elevated); }
button[data-variant='ghost'] { background: transparent; color: var(--text); border-color: transparent; }
button[data-variant='ghost']:hover:not(:disabled) { background: var(--surface); }
button[data-variant='dark'] { background: #0A0A0A; color: #F4F3EE; border-color: #0A0A0A; }
button[data-variant='dark']:hover:not(:disabled) { background: #1F1F1C; }
button[data-variant='danger'] { background: var(--surface); color: var(--bad); border-color: var(--border); }
button[data-variant='danger']:hover:not(:disabled) { background: rgba(226, 48, 48, 0.08); }
</style>
+59 -26
View File
@@ -1,9 +1,22 @@
<script setup lang="ts">
// Minimal Lucide-style line icons. Add more as needed from project/platform-tokens.jsx.
// Portal icon set. Lucide-style, single stroke, currentColor by default.
// Mirrors project/platform-tokens.jsx ICONS set so component code can use the
// same names as the design source.
const props = withDefaults(
export type IconName =
| 'home' | 'users' | 'globe' | 'building' | 'briefcase' | 'help'
| 'card' | 'database' | 'plug' | 'shield' | 'file' | 'mail'
| 'calendar' | 'folder' | 'video' | 'chat' | 'key'
| 'check' | 'x' | 'plus' | 'more'
| 'search' | 'bell' | 'logout' | 'brush' | 'device' | 'sun' | 'moon'
| 'chevDown' | 'chevRight' | 'chevLeft' | 'chevUpDown'
| 'arrowUp' | 'arrowDown' | 'arrowRight'
| 'external' | 'refresh' | 'copy' | 'upload' | 'download' | 'filter' | 'trash'
| 'waffle'
withDefaults(
defineProps<{
name: 'mail' | 'shield' | 'key' | 'check' | 'external' | 'arrowRight'
name: IconName
size?: number
stroke?: string
strokeWidth?: number
@@ -29,28 +42,48 @@ const props = withDefaults(
aria-hidden="true"
style="flex-shrink: 0"
>
<template v-if="name === 'mail'">
<rect x="2.5" y="5" width="19" height="14" rx="2" />
<path d="M3 7l9 6 9-6" />
</template>
<template v-else-if="name === 'shield'">
<path d="M12 3l8 3v6c0 5-3.5 8-8 9-4.5-1-8-4-8-9V6z" />
</template>
<template v-else-if="name === 'key'">
<circle cx="9" cy="14" r="4" />
<path d="M12.5 11L20 3.5l-2-2-2 2 2 2-2 2 2 2" />
</template>
<template v-else-if="name === 'check'">
<path d="M5 12l5 5L20 7" />
</template>
<template v-else-if="name === 'external'">
<path d="M14 4h6v6" />
<path d="M20 4l-9 9" />
<path d="M19 14v5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1h5" />
</template>
<template v-else-if="name === 'arrowRight'">
<path d="M5 12h14" />
<path d="M13 5l7 7-7 7" />
</template>
<template v-if="name === 'home'"><path d="M3 11l9-8 9 8" /><path d="M5 10v10h14V10" /></template>
<template v-else-if="name === 'users'"><circle cx="9" cy="8" r="3.5" /><path d="M2.5 20c0-3.6 2.9-6 6.5-6s6.5 2.4 6.5 6" /><circle cx="17" cy="9" r="2.5" /><path d="M21.5 19c0-2.6-2-4.5-4.5-4.5" /></template>
<template v-else-if="name === 'globe'"><circle cx="12" cy="12" r="9" /><path d="M3 12h18" /><path d="M12 3a14 14 0 0 1 0 18" /><path d="M12 3a14 14 0 0 0 0 18" /></template>
<template v-else-if="name === 'building'"><rect x="4" y="3" width="16" height="18" rx="1" /><path d="M8 7h2M14 7h2M8 11h2M14 11h2M8 15h2M14 15h2" /><path d="M10 21v-4h4v4" /></template>
<template v-else-if="name === 'briefcase'"><rect x="3" y="7" width="18" height="13" rx="2" /><path d="M8 7V5a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" /><path d="M3 13h18" /></template>
<template v-else-if="name === 'help'"><circle cx="12" cy="12" r="9" /><path d="M9.5 9a2.5 2.5 0 1 1 3.5 2.3c-1 .4-1 1.2-1 1.7" /><circle cx="12" cy="16.5" r="0.5" fill="currentColor" /></template>
<template v-else-if="name === 'card'"><rect x="2.5" y="5.5" width="19" height="13" rx="2" /><path d="M2.5 10h19" /></template>
<template v-else-if="name === 'database'"><ellipse cx="12" cy="5" rx="8" ry="3" /><path d="M4 5v6c0 1.7 3.6 3 8 3s8-1.3 8-3V5" /><path d="M4 11v6c0 1.7 3.6 3 8 3s8-1.3 8-3v-6" /></template>
<template v-else-if="name === 'plug'"><path d="M9 2v6" /><path d="M15 2v6" /><rect x="6" y="8" width="12" height="6" rx="2" /><path d="M12 14v3" /><path d="M9 21h6" /></template>
<template v-else-if="name === 'shield'"><path d="M12 3l8 3v6c0 5-3.5 8-8 9-4.5-1-8-4-8-9V6z" /></template>
<template v-else-if="name === 'file'"><path d="M14 3H6a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z" /><path d="M14 3v6h6" /></template>
<template v-else-if="name === 'mail'"><rect x="2.5" y="5" width="19" height="14" rx="2" /><path d="M3 7l9 6 9-6" /></template>
<template v-else-if="name === 'calendar'"><rect x="3" y="5" width="18" height="16" rx="2" /><path d="M3 10h18" /><path d="M8 3v4" /><path d="M16 3v4" /></template>
<template v-else-if="name === 'folder'"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" /></template>
<template v-else-if="name === 'video'"><rect x="2.5" y="6" width="13" height="12" rx="2" /><path d="M15.5 10l5-2.5v9l-5-2.5" /></template>
<template v-else-if="name === 'chat'"><path d="M21 12a8 8 0 1 1-3.2-6.4L21 4l-1 4.2A8 8 0 0 1 21 12z" /></template>
<template v-else-if="name === 'key'"><circle cx="9" cy="14" r="4" /><path d="M12.5 11L20 3.5l-2-2-2 2 2 2-2 2 2 2" /></template>
<template v-else-if="name === 'check'"><path d="M5 12l5 5L20 7" /></template>
<template v-else-if="name === 'x'"><path d="M6 6l12 12" /><path d="M18 6L6 18" /></template>
<template v-else-if="name === 'plus'"><path d="M12 5v14" /><path d="M5 12h14" /></template>
<template v-else-if="name === 'more'"><circle cx="5" cy="12" r="1.5" fill="currentColor" /><circle cx="12" cy="12" r="1.5" fill="currentColor" /><circle cx="19" cy="12" r="1.5" fill="currentColor" /></template>
<template v-else-if="name === 'search'"><circle cx="11" cy="11" r="7" /><path d="M20 20l-3.5-3.5" /></template>
<template v-else-if="name === 'bell'"><path d="M6 8a6 6 0 0 1 12 0c0 4 1.5 6 2 7H4c.5-1 2-3 2-7z" /><path d="M10 19a2 2 0 0 0 4 0" /></template>
<template v-else-if="name === 'logout'"><path d="M14 4h5a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-5" /><path d="M9 12h11" /><path d="M14 8l4 4-4 4" /></template>
<template v-else-if="name === 'brush'"><path d="M9 16l-3 3-3-3c2-1 3-2 3-3s1-2 3-2 3 1 3 3-2 4-3 5z" /><path d="M14 8l6-6 2 2-6 6" /><path d="M9 16l5-5" /></template>
<template v-else-if="name === 'device'"><rect x="6" y="3" width="12" height="18" rx="2" /><path d="M11 18h2" /></template>
<template v-else-if="name === 'sun'"><circle cx="12" cy="12" r="4" /><path d="M12 3v2M12 19v2M3 12h2M19 12h2M5.6 5.6l1.4 1.4M17 17l1.4 1.4M5.6 18.4l1.4-1.4M17 7l1.4-1.4" /></template>
<template v-else-if="name === 'moon'"><path d="M21 13A9 9 0 0 1 11 3a7 7 0 1 0 10 10z" /></template>
<template v-else-if="name === 'chevDown'"><path d="M6 9l6 6 6-6" /></template>
<template v-else-if="name === 'chevRight'"><path d="M9 6l6 6-6 6" /></template>
<template v-else-if="name === 'chevLeft'"><path d="M15 6l-6 6 6 6" /></template>
<template v-else-if="name === 'chevUpDown'"><path d="M8 10l4-4 4 4" /><path d="M8 14l4 4 4-4" /></template>
<template v-else-if="name === 'arrowUp'"><path d="M12 19V5" /><path d="M5 12l7-7 7 7" /></template>
<template v-else-if="name === 'arrowDown'"><path d="M12 5v14" /><path d="M19 12l-7 7-7-7" /></template>
<template v-else-if="name === 'arrowRight'"><path d="M5 12h14" /><path d="M13 5l7 7-7 7" /></template>
<template v-else-if="name === 'external'"><path d="M14 4h6v6" /><path d="M20 4l-9 9" /><path d="M19 14v5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1h5" /></template>
<template v-else-if="name === 'refresh'"><path d="M3 12a9 9 0 0 1 15-6.7L21 8" /><path d="M21 3v5h-5" /><path d="M21 12a9 9 0 0 1-15 6.7L3 16" /><path d="M3 21v-5h5" /></template>
<template v-else-if="name === 'copy'"><rect x="8" y="8" width="12" height="12" rx="2" /><path d="M16 8V5a1 1 0 0 0-1-1H5a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h3" /></template>
<template v-else-if="name === 'upload'"><path d="M12 16V4" /><path d="M7 9l5-5 5 5" /><path d="M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-2" /></template>
<template v-else-if="name === 'download'"><path d="M12 4v12" /><path d="M7 11l5 5 5-5" /><path d="M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-2" /></template>
<template v-else-if="name === 'filter'"><path d="M3 5h18l-7 9v6l-4-2v-4z" /></template>
<template v-else-if="name === 'trash'"><path d="M4 7h16" /><path d="M9 7V4h6v3" /><path d="M6 7l1 13h10l1-13" /></template>
<template v-else-if="name === 'waffle'"><circle cx="6" cy="6" r="1.5" fill="currentColor" /><circle cx="12" cy="6" r="1.5" fill="currentColor" /><circle cx="18" cy="6" r="1.5" fill="currentColor" /><circle cx="6" cy="12" r="1.5" fill="currentColor" /><circle cx="12" cy="12" r="1.5" fill="currentColor" /><circle cx="18" cy="12" r="1.5" fill="currentColor" /><circle cx="6" cy="18" r="1.5" fill="currentColor" /><circle cx="12" cy="18" r="1.5" fill="currentColor" /><circle cx="18" cy="18" r="1.5" fill="currentColor" /></template>
</svg>
</template>
@@ -0,0 +1,93 @@
<script setup lang="ts">
// Mirror of project/platform-screens.jsx `FilterChip` (line 770) — a chip-like
// button with a dropdown of options. Closes on outside click. Used in the
// users / audit / channels toolbars.
interface Opt { value: string; label: string }
const props = defineProps<{ label: string; modelValue: string; options: Opt[] }>()
const emit = defineEmits<{ 'update:modelValue': [string] }>()
const open = ref(false)
const current = computed(() => props.options.find((o) => o.value === props.modelValue))
function pick(v: string) {
emit('update:modelValue', v)
open.value = false
}
</script>
<template>
<div class="wrap">
<button class="chip" @click="open = !open">
<span class="lab">{{ label }}:</span>
<span class="val">{{ current?.label || 'All' }}</span>
<UiIcon name="chevDown" :size="12" stroke="var(--text-mute)" />
</button>
<template v-if="open">
<div class="scrim" @click="open = false" />
<div class="menu">
<button v-for="o in options" :key="o.value" class="row" @click="pick(o.value)">
{{ o.label }}
<UiIcon v-if="o.value === modelValue" name="check" :size="13" />
</button>
</div>
</template>
</div>
</template>
<style scoped>
.wrap { position: relative; }
.chip {
display: inline-flex;
align-items: center;
gap: 6px;
height: 36px;
padding: 0 12px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
font-size: 13px;
font-family: inherit;
color: var(--text);
cursor: pointer;
}
.lab {
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.18em;
text-transform: uppercase;
font-weight: 500;
color: var(--text-mute);
}
.val { font-weight: 500; }
.scrim { position: fixed; inset: 0; z-index: 40; }
.menu {
position: absolute;
top: 100%;
left: 0;
margin-top: 4px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 6px;
min-width: 160px;
padding: 4px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.18);
z-index: 50;
}
.row {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 8px 10px;
border-radius: 4px;
background: transparent;
border: none;
text-align: left;
font-size: 13px;
font-family: inherit;
color: var(--text);
cursor: pointer;
}
.row:hover { background: var(--surface); }
</style>
+165
View File
@@ -0,0 +1,165 @@
<script setup lang="ts">
// Reusable kebab "..." menu. Anchors a teleported popover to the trigger
// button's bottom-right corner — escapes overflow/clip on tables and cards.
// Same pattern as components/enduser/EnduserDeviceActions.vue, but generic.
//
// Pass items via the `items` prop. Each item fires a `select` event with its
// `id` so the parent can route the action (toast, open modal, etc.). Items
// marked `danger: true` render with red text; `disabled: true` are inert;
// `separator: true` renders as a divider (no other fields needed).
//
// Trigger is a small ghost UiButton with a "more" icon. To customize the
// trigger, override the `#trigger` slot — you'll receive a `toggle` fn.
import type { IconName } from '~/components/UiIcon.vue'
export interface KebabItem {
id: string
label?: string
icon?: IconName
danger?: boolean
disabled?: boolean
separator?: boolean
}
withDefaults(
defineProps<{
items: KebabItem[]
size?: number
iconSize?: number
align?: 'right' | 'left'
}>(),
{
size: 14,
iconSize: 14,
align: 'right',
},
)
const emit = defineEmits<{ select: [string] }>()
const open = ref(false)
const triggerRef = ref<HTMLElement | null>(null)
const menuRef = ref<HTMLElement | null>(null)
const pos = ref({ top: 0, right: 0, left: 0 })
function toggle(e: MouseEvent) {
e.stopPropagation()
if (!triggerRef.value) return
const r = triggerRef.value.getBoundingClientRect()
pos.value = {
top: r.bottom + 4,
right: window.innerWidth - r.right,
left: r.left,
}
open.value = !open.value
}
function pick(item: KebabItem) {
if (item.disabled || item.separator) return
open.value = false
emit('select', item.id)
}
function onDoc(e: MouseEvent) {
if (!open.value) return
const t = e.target as Node
if (menuRef.value?.contains(t) || triggerRef.value?.contains(t)) return
open.value = false
}
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape') open.value = false
}
function onScroll() {
open.value = false
}
onMounted(() => {
document.addEventListener('mousedown', onDoc)
document.addEventListener('keydown', onKey)
window.addEventListener('scroll', onScroll, true)
window.addEventListener('resize', onScroll)
})
onBeforeUnmount(() => {
document.removeEventListener('mousedown', onDoc)
document.removeEventListener('keydown', onKey)
window.removeEventListener('scroll', onScroll, true)
window.removeEventListener('resize', onScroll)
})
// Route changes also close.
const route = useRoute()
watch(() => route.fullPath, () => { open.value = false })
</script>
<template>
<span ref="triggerRef" class="more-wrap">
<slot name="trigger" :toggle="toggle">
<UiButton size="sm" variant="ghost" @click="toggle">
<UiIcon name="more" :size="iconSize" />
</UiButton>
</slot>
</span>
<Teleport to="body">
<Transition name="pop">
<div
v-if="open"
ref="menuRef"
class="menu"
:style="align === 'right'
? { top: pos.top + 'px', right: pos.right + 'px' }
: { top: pos.top + 'px', left: pos.left + 'px' }"
>
<template v-for="(it, i) in items" :key="it.id + '_' + i">
<span v-if="it.separator" class="sep" />
<button
v-else
:class="{ danger: it.danger }"
:disabled="it.disabled"
@click="pick(it)"
>
<UiIcon v-if="it.icon" :name="it.icon" :size="14" />
<span>{{ it.label }}</span>
</button>
</template>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
.more-wrap { display: inline-flex; }
.menu {
position: fixed;
min-width: 220px;
padding: 4px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.18);
z-index: 100;
}
.menu button {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 8px 10px;
border-radius: 5px;
background: transparent;
border: none;
cursor: pointer;
font-family: inherit;
font-size: 13px;
text-align: left;
color: var(--text);
}
.menu button:hover { background: var(--row-hover, var(--surface)); }
.menu button:disabled { opacity: 0.5; cursor: not-allowed; }
.menu .danger { color: var(--bad); }
.menu .sep { display: block; height: 1px; background: var(--border); margin: 4px 0; }
.pop-enter-active, .pop-leave-active { transition: opacity 0.12s, transform 0.12s; }
.pop-enter-from, .pop-leave-to { opacity: 0; transform: translateY(-4px); }
</style>
@@ -0,0 +1,121 @@
<script setup lang="ts">
// "..." menu for a single device row. The menu is teleported to <body> so it
// escapes any overflow/clip on the table — same pattern as the React design
// source. Closes on outside-click, Escape, or scroll.
interface DeviceLike {
id: string
current?: boolean
trusted?: boolean
}
const props = defineProps<{ device: DeviceLike }>()
const emit = defineEmits<{
rename: [DeviceLike]
trust: [DeviceLike]
history: [DeviceLike]
revoke: [DeviceLike]
}>()
const open = ref(false)
const triggerRef = ref<HTMLElement | null>(null)
const menuRef = ref<HTMLElement | null>(null)
const pos = ref({ top: 0, right: 0 })
function toggle(e: MouseEvent) {
e.stopPropagation()
if (!triggerRef.value) return
const r = triggerRef.value.getBoundingClientRect()
pos.value = { top: r.bottom + 4, right: window.innerWidth - r.right }
open.value = !open.value
}
function close() { open.value = false }
function onDoc(e: MouseEvent) {
if (!open.value) return
const t = e.target as Node
if (menuRef.value?.contains(t) || triggerRef.value?.contains(t)) return
open.value = false
}
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape') open.value = false
}
function onScroll() { open.value = false }
onMounted(() => {
document.addEventListener('mousedown', onDoc)
document.addEventListener('keydown', onKey)
window.addEventListener('scroll', onScroll, true)
window.addEventListener('resize', onScroll)
})
onBeforeUnmount(() => {
document.removeEventListener('mousedown', onDoc)
document.removeEventListener('keydown', onKey)
window.removeEventListener('scroll', onScroll, true)
window.removeEventListener('resize', onScroll)
})
</script>
<template>
<span ref="triggerRef" class="more-wrap">
<UiButton size="sm" variant="ghost" @click="toggle">
<UiIcon name="more" :size="14" />
</UiButton>
</span>
<Teleport to="body">
<Transition name="pop">
<div v-if="open" ref="menuRef" class="menu" :style="{ top: pos.top + 'px', right: pos.right + 'px' }">
<button @click="close(); emit('rename', device)"><UiIcon name="brush" :size="14" /> Rename device</button>
<button @click="close(); emit('trust', device)"><UiIcon name="shield" :size="14" /> {{ device.trusted ? 'Distrust device' : 'Trust this device · skip MFA for 30d' }}</button>
<button @click="close(); emit('history', device)"><UiIcon name="file" :size="14" /> View sign-in history</button>
<span class="sep" />
<button
class="danger"
:disabled="device.current"
@click="close(); !device.current && emit('revoke', device)"
>
<UiIcon name="logout" :size="14" />
{{ device.current ? 'Cannot revoke current device' : 'Revoke session' }}
</button>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
.more-wrap { display: inline-flex; }
.menu {
position: fixed;
min-width: 260px;
padding: 4px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.18);
z-index: 100;
}
.menu button {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 8px 10px;
border-radius: 5px;
background: transparent;
border: none;
cursor: pointer;
font-family: inherit;
font-size: 13px;
text-align: left;
color: var(--text);
}
.menu button:hover { background: var(--row-hover); }
.menu button:disabled { opacity: 0.5; cursor: not-allowed; }
.menu .danger { color: var(--bad); }
.menu .sep { display: block; height: 1px; background: var(--border); margin: 4px 0; }
.pop-enter-active, .pop-leave-active { transition: opacity 0.12s, transform 0.12s; }
.pop-enter-from, .pop-leave-to { opacity: 0; transform: translateY(-4px); }
</style>
@@ -0,0 +1,59 @@
<script setup lang="ts">
// Simple label + slot wrapper used across the profile/security pages.
defineProps<{ label: string; hint?: string }>()
</script>
<template>
<label class="field">
<span class="label">{{ label }}</span>
<slot />
<span v-if="hint" class="hint">{{ hint }}</span>
</label>
</template>
<style scoped>
.field { display: flex; flex-direction: column; gap: 6px; min-width: 0; }
.label {
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--text-mute);
font-weight: 500;
}
.hint {
font-family: var(--font-mono);
font-size: 10px;
color: var(--text-mute);
}
.field :deep(input),
.field :deep(textarea),
.field :deep(select) {
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;
width: 100%;
transition: border-color 0.12s, background 0.12s;
}
.field :deep(textarea) {
min-height: 110px;
padding: 10px 12px;
height: auto;
resize: vertical;
line-height: 1.55;
}
.field :deep(input:focus),
.field :deep(textarea:focus),
.field :deep(select:focus) {
border-color: var(--text);
background: var(--bg);
}
.field :deep(input:disabled) { background: var(--bg); color: var(--text-mute); cursor: not-allowed; }
</style>
@@ -0,0 +1,129 @@
<script setup lang="ts">
// Presence selector for the dashboard greeting strip. Drop-down trigger styled
// as a pill with a status dot, label, and short hint. Visual only — no API call.
type Presence = 'available' | 'meeting' | 'focus' | 'away'
interface PresenceOption {
value: Presence
label: string
hint: string
color: string
}
const opts: PresenceOption[] = [
{ value: 'available', label: 'Available', hint: 'visible to teammates', color: 'var(--ok)' },
{ value: 'meeting', label: 'In a meeting', hint: 'silenced · auto-clears', color: 'var(--warn)' },
{ value: 'focus', label: 'Focus', hint: 'no notifications', color: 'var(--info)' },
{ value: 'away', label: 'Away', hint: 'be right back', color: 'var(--text-mute)' },
]
const props = defineProps<{ modelValue: Presence }>()
const emit = defineEmits<{ 'update:modelValue': [Presence] }>()
const open = ref(false)
const rootRef = ref<HTMLElement | null>(null)
const current = computed(() => opts.find((o) => o.value === props.modelValue) ?? opts[0])
function pick(v: Presence) {
emit('update:modelValue', v)
open.value = false
}
function onDocClick(e: MouseEvent) {
if (!rootRef.value) return
if (!rootRef.value.contains(e.target as Node)) open.value = false
}
onMounted(() => document.addEventListener('mousedown', onDocClick))
onBeforeUnmount(() => document.removeEventListener('mousedown', onDocClick))
</script>
<template>
<div ref="rootRef" class="presence">
<button class="trigger" @click="open = !open" :aria-expanded="open">
<StatusDot :color="current.color" :size="8" />
<div class="trigger-text">
<span class="trigger-label">{{ current.label }}</span>
<span class="trigger-hint">{{ current.hint }}</span>
</div>
<UiIcon name="chevDown" :size="12" />
</button>
<Transition name="pop">
<div v-if="open" class="menu">
<button
v-for="o in opts"
:key="o.value"
class="opt"
:class="{ active: o.value === modelValue }"
@click="pick(o.value)"
>
<StatusDot :color="o.color" :size="8" />
<div class="opt-text">
<span class="opt-label">{{ o.label }}</span>
<span class="opt-hint">{{ o.hint }}</span>
</div>
<UiIcon v-if="o.value === modelValue" name="check" :size="13" />
</button>
</div>
</Transition>
</div>
</template>
<style scoped>
.presence { position: relative; }
.trigger {
display: inline-flex;
align-items: center;
gap: 10px;
padding: 8px 14px;
border-radius: 8px;
background: var(--surface);
border: 1px solid var(--border);
cursor: pointer;
font-family: inherit;
color: var(--text);
}
.trigger:hover { border-color: var(--border-hi); }
.trigger-text { display: flex; flex-direction: column; align-items: flex-start; gap: 1px; }
.trigger-label { font-size: 13px; font-weight: 500; }
.trigger-hint { font-family: var(--font-mono); font-size: 10px; color: var(--text-mute); }
.menu {
position: absolute;
top: calc(100% + 4px);
right: 0;
min-width: 220px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.18);
padding: 4px;
z-index: 50;
}
.opt {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 9px 10px;
background: transparent;
border: none;
border-radius: 6px;
cursor: pointer;
font-family: inherit;
color: var(--text);
text-align: left;
}
.opt:hover, .opt.active { background: var(--row-hover); }
.opt-text { display: flex; flex-direction: column; flex: 1; gap: 1px; }
.opt-label { font-size: 13px; font-weight: 500; }
.opt-hint { font-family: var(--font-mono); font-size: 10px; color: var(--text-mute); }
.pop-enter-active, .pop-leave-active { transition: opacity 0.12s, transform 0.12s; }
.pop-enter-from, .pop-leave-to { opacity: 0; transform: translateY(-4px); }
</style>
@@ -0,0 +1,83 @@
<script setup lang="ts">
// Sticky save bar — appears at bottom-center of the page when any field
// in the active tab is dirty. Dark pill with Discard + Save changes.
defineProps<{ dirty: boolean }>()
defineEmits<{ discard: []; save: [] }>()
</script>
<template>
<Teleport to="body">
<Transition name="lift">
<div v-if="dirty" class="save-bar">
<span class="pulse" />
<div class="text">
<span class="t1">You have unsaved changes</span>
<span class="t2">changes are queued · not yet applied</span>
</div>
<button class="discard" @click="$emit('discard')">Discard</button>
<button class="save" @click="$emit('save')">
<UiIcon name="check" :size="13" :stroke-width="2.4" />
Save changes
</button>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
.save-bar {
position: fixed;
bottom: 24px;
left: 50%;
transform: translateX(-50%);
display: flex;
align-items: center;
gap: 14px;
min-width: 420px;
padding: 12px 14px 12px 20px;
background: var(--text);
color: var(--bg);
border-radius: 12px;
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.28);
z-index: 70;
font-family: var(--font-sans);
}
.pulse {
width: 6px; height: 6px; border-radius: 999px;
background: var(--accent);
box-shadow: 0 0 0 4px rgba(212, 255, 58, 0.18);
}
.text { display: flex; flex-direction: column; gap: 2px; flex: 1; min-width: 0; }
.t1 { font-size: 13px; font-weight: 500; }
.t2 { font-family: var(--font-mono); font-size: 10px; letter-spacing: 0.04em; opacity: 0.55; }
.discard, .save {
border: none;
border-radius: 6px;
cursor: pointer;
font-family: inherit;
font-size: 13px;
padding: 8px 14px;
}
.discard { background: transparent; color: var(--bg); opacity: 0.7; }
.discard:hover { opacity: 1; }
.save {
background: var(--accent);
color: var(--accent-fg);
display: inline-flex;
align-items: center;
gap: 6px;
font-weight: 600;
padding: 8px 16px;
}
.save:hover { filter: brightness(0.94); }
.lift-enter-active, .lift-leave-active {
transition: transform 0.24s cubic-bezier(0.32, 0.72, 0, 1), opacity 0.18s;
}
.lift-enter-from, .lift-leave-to { transform: translate(-50%, 24px); opacity: 0; }
</style>
@@ -0,0 +1,47 @@
<script setup lang="ts">
// Compact toggle pill used inside profile/notification cards.
const props = defineProps<{ modelValue: boolean; disabled?: boolean }>()
const emit = defineEmits<{ 'update:modelValue': [boolean] }>()
function flip() {
if (props.disabled) return
emit('update:modelValue', !props.modelValue)
}
</script>
<template>
<button
class="toggle"
:data-on="modelValue"
:disabled="disabled"
:aria-pressed="modelValue"
@click="flip"
>
<span class="knob" />
</button>
</template>
<style scoped>
.toggle {
width: 38px; height: 22px;
background: var(--border);
border: 1px solid var(--border-hi);
border-radius: 999px;
position: relative;
padding: 0;
cursor: pointer;
transition: background 0.15s, border-color 0.15s;
}
.toggle[data-on='true'] { background: var(--text); border-color: var(--text); }
.toggle:disabled { opacity: 0.4; cursor: not-allowed; }
.knob {
position: absolute;
top: 2px; left: 2px;
width: 16px; height: 16px;
background: var(--bg);
border-radius: 999px;
transition: transform 0.18s cubic-bezier(0.32, 0.72, 0, 1);
}
.toggle[data-on='true'] .knob { transform: translateX(16px); }
</style>
@@ -0,0 +1,737 @@
<script setup lang="ts">
// 6-step wizard that walks the partner through provisioning a new customer org.
// The actual orchestration (Authentik tenant, Stalwart mailbox, OCIS space …)
// happens behind a single "Provision" action. Here we just collect input and
// give them a clear review summary.
defineProps<{ open: boolean }>()
const emit = defineEmits<{ close: []; done: [] }>()
// 5 steps. Branding was dropped because nothing on the backend persists it
// yet (no Tenant.branding field, no logo upload pipeline) — partners
// configure branding post-provisioning via /partner/branding once that
// surface gets wired to a real backend.
const STEPS = [
{ n: 1, label: 'Organization' },
{ n: 2, label: 'Domain' },
{ n: 3, label: 'First admin' },
{ n: 4, label: 'Plan' },
{ n: 5, label: 'Review' },
] as const
const LAST_STEP = STEPS.length
const step = ref(1)
// Default form state. Fields start empty so the partner fills in their
// real customer's details rather than editing pre-filled fixture data.
// `plan` defaults to Business + `cycle` to Monthly because those are the
// usual choice and the radio/dropdown render expects a value; both stay
// editable.
const form = reactive({
legalName: '',
displayName: '',
cvr: '',
country: '',
address: '',
domain: '',
preconfigureDns: true,
adminFirst: '',
adminLast: '',
adminEmail: '',
adminPhone: '',
sendWelcome: true,
plan: 'Business' as PlanLabel,
seats: 0,
cycle: 'Monthly' as 'Monthly' | 'Quarterly' | 'Yearly',
currency: 'DKK' as 'DKK' | 'EUR' | 'USD',
})
// Static plan metadata. Features stay hard-coded (they're product copy, not
// catalog data). Prices come from the live /api/prices catalog at render
// time — see `visiblePlans` below.
const plans = [
{ code: 'mvp', name: 'Starter', features: '10 GB mail · 100 GB drive · 5 video rooms' },
{ code: 'pro', name: 'Business', features: '50 GB mail · 1 TB drive · unlimited video · MFA', best: true },
{ code: 'enterprise', name: 'Enterprise', features: 'Custom quotas · SSO · audit log · 24/7 support' },
] as const
type PlanLabel = (typeof plans)[number]['name']
type PlanCode = (typeof plans)[number]['code']
// Live catalog. We fetch all active price rows once when the wizard mounts
// and look them up by (plan, cycle) as the user changes selectors. Empty
// catalog (no /api/prices configured) leaves all cards showing "Not set"
// — wizard still works, just no number displayed.
interface CatalogRow {
plan: PlanCode
cycle: 'monthly' | 'quarterly' | 'yearly'
amounts: { DKK?: number; EUR?: number; USD?: number }
active: boolean
}
const { data: catalog } = await useFetch<CatalogRow[]>('/api/prices', {
key: 'wizard-catalog',
default: () => [],
})
// Cycle label shown after the slash in "X DKK / seat / mo".
const CYCLE_SUFFIX: Record<'monthly' | 'quarterly' | 'yearly', string> = {
monthly: 'mo',
quarterly: 'quarter',
yearly: 'yr',
}
function reset() {
step.value = 1
// Clearing result here lets the modal reopen on a fresh "step 1" view
// rather than landing on the previous provisioning's done state.
result.value = null
submitError.value = null
}
function close() {
emit('close')
// reset on next tick so the closing animation isn't disturbed
setTimeout(reset, 200)
}
function next() {
if (step.value < LAST_STEP) step.value++
}
function back() {
if (step.value > 1) step.value--
}
// ── Provision (real backend call) ────────────────────────────────────────
const submitting = ref(false)
const submitError = ref<string | null>(null)
// Slug from display name: lowercase, alphanumeric, hyphen-joined, trimmed
// to 40 chars (matches CreateTenantDto.slug regex).
function slugFromName(name: string): string {
return name
.toLowerCase()
.normalize('NFKD')
.replace(/[̀-ͯ]/g, '')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 40) || 'tenant'
}
function planCode(label: PlanLabel): PlanCode {
return plans.find((p) => p.name === label)!.code
}
function cycleCode(label: 'Monthly' | 'Quarterly' | 'Yearly'): 'monthly' | 'quarterly' | 'yearly' {
return label.toLowerCase() as 'monthly' | 'quarterly' | 'yearly'
}
// Combines the static plan list with the live catalog: for each plan card,
// look up the price row for the user's currently-selected cycle, then pull
// the amount in their selected currency. Renders a friendly string ready to
// drop into the template.
const visiblePlans = computed(() =>
plans.map((p) => {
const row = catalog.value.find((r) => r.plan === p.code && r.cycle === cycleCode(form.cycle))
const minor = row?.amounts[form.currency]
const cycleSuffix = CYCLE_SUFFIX[cycleCode(form.cycle)]
let priceLabel: string
let available = true
if (p.code === 'enterprise' && minor === undefined) {
priceLabel = 'Custom'
} else if (minor === undefined) {
// Catalog row exists but no price in this currency, OR no row at all.
priceLabel = `Not sold in ${form.currency}`
available = false
} else {
priceLabel = `${(minor / 100).toLocaleString('da-DK')} ${form.currency} / seat / ${cycleSuffix}`
}
return { ...p, priceLabel, available }
}),
)
// Total per cycle in the chosen currency. Drives the "you'll pay" line.
const totalPerCycle = computed(() => {
const p = visiblePlans.value.find((x) => x.name === form.plan)
if (!p || !p.available) return null
const row = catalog.value.find((r) => r.plan === p.code && r.cycle === cycleCode(form.cycle))
const minor = row?.amounts[form.currency]
if (minor === undefined || !form.seats) return null
const total = (minor * form.seats) / 100
const cycleSuffix = CYCLE_SUFFIX[cycleCode(form.cycle)]
return `${total.toLocaleString('da-DK')} ${form.currency} / ${cycleSuffix}`
})
// Result we hand to the "Provisioned" view after submit succeeds. The
// tenant create always succeeds when we get here; the admin invite may
// have failed independently (caught server-side and returned as `error`).
interface AdminCredentials {
link?: string
tempPassword?: string
attached?: boolean
error?: string
}
const result = ref<{ tenantName: string; adminEmail: string; admin?: AdminCredentials } | null>(null)
const copied = ref(false)
async function copyToClipboard(value: string) {
try {
await navigator.clipboard.writeText(value)
copied.value = true
setTimeout(() => (copied.value = false), 2000)
} catch {
// Non-secure context — user selects the readonly input.
}
}
async function submit() {
submitError.value = null
const displayName = form.displayName.trim()
if (!displayName) {
submitError.value = 'Display name is required'
step.value = 1
return
}
submitting.value = true
try {
const adminName = `${form.adminFirst.trim()} ${form.adminLast.trim()}`.trim()
const adminEmail = form.adminEmail.trim()
const payload = {
slug: slugFromName(displayName),
name: displayName,
plan: planCode(form.plan),
cycle: form.cycle.toLowerCase() as 'monthly' | 'quarterly' | 'yearly',
currency: form.currency,
seats: form.seats,
...(form.domain.trim() && { domains: [form.domain.trim()] }),
billingInfo: {
...(form.legalName.trim() && { companyName: form.legalName.trim() }),
...(form.cvr.trim() && { vatId: form.cvr.trim() }),
...(form.country && { country: form.country }),
...(adminEmail && { contactEmail: adminEmail }),
},
// Only send admin info when both name + email are present. Backend
// skips the invite if either is missing; we mirror that on the
// client so the wizard never sends half-filled admin payloads.
...(adminName && adminEmail && { adminName, adminEmail }),
}
const res = await $fetch<{
tenant: { name: string }
adminInvite?: AdminCredentials | { error: string }
}>('/api/partner/tenants', { method: 'POST', body: payload })
result.value = {
tenantName: res.tenant.name,
adminEmail,
admin: res.adminInvite as AdminCredentials | undefined,
}
emit('done') // refresh the customers table + sidebar in the background
} catch (err: unknown) {
const e = err as { data?: { data?: { message?: string }; message?: string; statusMessage?: string } }
submitError.value =
e.data?.data?.message || e.data?.message || e.data?.statusMessage || 'Provisioning failed'
} finally {
submitting.value = false
}
}
function finish() {
result.value = null
close()
}
</script>
<template>
<Modal
:open="open"
size="lg"
:eyebrow="result ? 'Provisioned' : `Step ${step} of ${STEPS.length}`"
title="Provision new customer organization"
@close="close"
>
<!-- Step rail -->
<div v-if="!result" class="rail">
<template v-for="(s, idx) in STEPS" :key="s.n">
<div class="rail-step" :class="{ done: s.n < step, active: s.n === step }">
<div class="bubble">
<UiIcon v-if="s.n < step" name="check" :size="11" :stroke-width="2.6" />
<template v-else>{{ s.n }}</template>
</div>
<span class="lab">{{ s.label }}</span>
</div>
<div v-if="idx < STEPS.length - 1" class="rail-line" />
</template>
</div>
<!-- 1. Organization -->
<div v-if="step === 1 && !result" class="form">
<label class="field">
<Eyebrow>Legal name</Eyebrow>
<input v-model="form.legalName" />
</label>
<label class="field">
<Eyebrow>Display name · shown to users</Eyebrow>
<input v-model="form.displayName" />
</label>
<div class="row-2">
<label class="field">
<Eyebrow>CVR</Eyebrow>
<input v-model="form.cvr" />
</label>
<label class="field">
<Eyebrow>Country</Eyebrow>
<CountrySelect v-model="form.country" />
</label>
</div>
<label class="field">
<Eyebrow>Address</Eyebrow>
<input v-model="form.address" />
</label>
</div>
<!-- 2. Domain -->
<div v-if="step === 2 && !result" class="form">
<label class="field">
<Eyebrow>Primary domain</Eyebrow>
<input v-model="form.domain" />
</label>
<div class="info-box">
<Eyebrow>DNS verification</Eyebrow>
<p>
We'll send the customer their DNS records during onboarding. For now we just register
the intent. You can pre-fill MX/SPF if they've delegated DNS to you.
</p>
<label class="cb-row">
<input v-model="form.preconfigureDns" type="checkbox" />
Pre-configure DNS records on the customer's behalf (NordicMSP manages their DNS)
</label>
</div>
</div>
<!-- 3. First admin -->
<div v-if="step === 3 && !result" class="form">
<p class="hint">We'll send this person an invitation email. They become the first customer admin.</p>
<div class="row-2">
<label class="field">
<Eyebrow>First name</Eyebrow>
<input v-model="form.adminFirst" />
</label>
<label class="field">
<Eyebrow>Last name</Eyebrow>
<input v-model="form.adminLast" />
</label>
</div>
<label class="field">
<Eyebrow>Email</Eyebrow>
<input v-model="form.adminEmail" />
</label>
<label class="field">
<Eyebrow>Phone</Eyebrow>
<input v-model="form.adminPhone" />
</label>
<label class="cb-row">
<input v-model="form.sendWelcome" type="checkbox" />
Send welcome email immediately upon provisioning
</label>
</div>
<!-- 4. Plan -->
<div v-if="step === 4 && !result" class="form">
<!-- Seats / cycle / currency drive the plan-card prices below keep
them at the top so the user picks them first, then plans update
live. -->
<div class="row-3">
<label class="field">
<Eyebrow>Initial seats</Eyebrow>
<input v-model.number="form.seats" type="number" min="1" />
</label>
<label class="field">
<Eyebrow>Billing cycle</Eyebrow>
<select v-model="form.cycle">
<option value="Monthly">Monthly</option>
<option value="Quarterly">Quarterly</option>
<option value="Yearly">Yearly</option>
</select>
</label>
<label class="field">
<Eyebrow>Currency</Eyebrow>
<select v-model="form.currency">
<option value="DKK">DKK</option>
<option value="EUR">EUR</option>
<option value="USD">USD</option>
</select>
</label>
</div>
<label
v-for="p in visiblePlans"
:key="p.name"
class="plan"
:class="{ selected: form.plan === p.name, disabled: !p.available }"
@click="p.available && (form.plan = p.name as any)"
>
<span v-if="(p as any).best" class="rec">RECOMMENDED</span>
<span class="radio" :class="{ on: form.plan === p.name }">
<span v-if="form.plan === p.name" class="radio-inner" />
</span>
<div class="plan-body">
<div class="plan-head">
<span class="plan-name">{{ p.name }}</span>
<Mono dim>{{ p.priceLabel }}</Mono>
</div>
<Mono dim>{{ p.features }}</Mono>
</div>
</label>
<p v-if="totalPerCycle" class="total-line">
<Mono dim>Total · {{ form.seats }} {{ form.seats === 1 ? 'seat' : 'seats' }}</Mono>
<strong>{{ totalPerCycle }}</strong>
</p>
</div>
<!-- 5. Review -->
<div v-if="step === 5 && !result" class="form">
<div class="review-hero">
<Eyebrow>You're provisioning</Eyebrow>
<div class="review-name">{{ form.displayName }}</div>
<Mono dim>{{ form.domain }} · {{ form.plan }} · {{ form.seats }} seats</Mono>
</div>
<dl class="def">
<div class="def-row"><dt>Admin</dt><dd>{{ form.adminFirst }} {{ form.adminLast }} · {{ form.adminEmail }}</dd></div>
<div class="def-row"><dt>Plan</dt><dd>{{ form.plan }} · {{ form.seats }} seats · {{ form.cycle.toLowerCase() }}</dd></div>
<div class="def-row"><dt>Branding</dt><dd>Defaults · customize after provisioning</dd></div>
<div class="def-row"><dt>Onboarding</dt><dd>Welcome email sent on creation</dd></div>
</dl>
<div class="prov-note">
<Mono dim>// provisioning</Mono>
<p>
On confirm we'll create the tenant in Dezky and trigger the
background provisioner (Authentik tenant, OCIS space, Stalwart
mailboxes). {{ form.displayName }} will appear in your portfolio
as soon as the database write completes.
</p>
</div>
</div>
<p v-if="submitError" class="err">{{ submitError }}</p>
<!-- Provisioned: tenant + admin credentials handoff. Shown after
submit() completes, hides the wizard step content. -->
<div v-if="result" class="provisioned">
<Badge tone="ok" dot>provisioned</Badge>
<h3>{{ result.tenantName }} is live</h3>
<template v-if="result.admin && !result.admin.error">
<template v-if="result.admin.attached">
<p class="ok-msg">
<Mono>{{ result.adminEmail }}</Mono> already existed in Authentik
and was attached as an admin on this tenant. They sign in with
their existing credentials.
</p>
</template>
<template v-else-if="result.admin.link">
<p class="ok-msg">
Share this single-use link with the admin they'll set their
own password and enroll MFA.
</p>
<div class="cred-row">
<input :value="result.admin.link" readonly @focus="($event.target as HTMLInputElement).select()" />
<UiButton variant="secondary" @click="copyToClipboard(result.admin!.link!)">
{{ copied ? 'Copied' : 'Copy' }}
</UiButton>
</div>
</template>
<template v-else-if="result.admin.tempPassword">
<p class="ok-msg">
Authentik has no recovery flow configured, so we set a
temporary password — share it with the admin and they'll be
prompted to change it on first login.
</p>
<div class="cred-row">
<input :value="result.adminEmail" readonly @focus="($event.target as HTMLInputElement).select()" />
<UiButton variant="secondary" @click="copyToClipboard(result.adminEmail)">Copy</UiButton>
</div>
<div class="cred-row">
<input :value="result.admin.tempPassword" readonly @focus="($event.target as HTMLInputElement).select()" />
<UiButton variant="secondary" @click="copyToClipboard(result.admin!.tempPassword!)">
{{ copied ? 'Copied' : 'Copy' }}
</UiButton>
</div>
</template>
</template>
<template v-else-if="result.admin?.error">
<p class="warn-msg">
Tenant was created, but the admin invite failed:
<Mono>{{ result.admin.error }}</Mono>. Retry the invite from
<Mono>/partner/customers</Mono>.
</p>
</template>
<template v-else>
<p class="ok-msg">
No first-admin info was provided. Invite an admin later from
<Mono>/partner/customers</Mono>.
</p>
</template>
</div>
<template #footer>
<template v-if="!result">
<UiButton variant="ghost" :disabled="submitting" @click="close">Cancel</UiButton>
<div style="flex:1" />
<UiButton v-if="step > 1" variant="secondary" :disabled="submitting" @click="back">Back</UiButton>
<UiButton v-if="step < LAST_STEP" variant="primary" @click="next">Continue</UiButton>
<UiButton v-else variant="primary" :disabled="submitting" @click="submit">
<template #leading><UiIcon name="check" :size="14" /></template>
{{ submitting ? 'Provisioning…' : 'Provision customer' }}
</UiButton>
</template>
<template v-else>
<div style="flex:1" />
<UiButton variant="primary" @click="finish">Done</UiButton>
</template>
</template>
</Modal>
</template>
<style scoped>
.rail {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 24px;
}
.rail-step {
display: flex;
align-items: center;
gap: 8px;
opacity: 0.45;
}
.rail-step.active, .rail-step.done { opacity: 1; }
.bubble {
width: 22px;
height: 22px;
border-radius: 999px;
background: var(--surface);
color: var(--text-mute);
border: 1px solid var(--border);
display: inline-flex;
align-items: center;
justify-content: center;
font-family: var(--font-mono);
font-size: 11px;
font-weight: 600;
}
.rail-step.done .bubble { background: var(--text); color: var(--bg); border-color: var(--text); }
.rail-step.active .bubble { background: var(--accent); color: var(--accent-fg); border-color: var(--accent); }
.lab {
font-size: 12px;
font-weight: 500;
color: var(--text-mute);
white-space: nowrap;
}
.rail-step.active .lab { color: var(--text); font-weight: 600; }
.rail-step.done .lab { color: var(--text); }
.rail-line {
flex: 1;
height: 1px;
background: var(--border);
}
.form { display: flex; flex-direction: column; gap: 14px; }
.row-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.row-3 { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 12px; }
.field { display: flex; flex-direction: column; gap: 6px; }
.field input,
.field select {
padding: 9px 12px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
font-family: inherit;
font-size: 13px;
color: var(--text);
}
.field select {
appearance: none;
background-image: linear-gradient(45deg, transparent 50%, var(--text-mute) 50%),
linear-gradient(135deg, var(--text-mute) 50%, transparent 50%);
background-position: calc(100% - 16px) 50%, calc(100% - 12px) 50%;
background-size: 4px 4px;
background-repeat: no-repeat;
padding-right: 28px;
cursor: pointer;
}
.field input:focus,
.field select:focus { outline: none; border-color: var(--border-hi); }
.hint { font-size: 13px; color: var(--text-dim); margin: 0 0 4px 0; line-height: 1.5; }
.info-box {
padding: 14px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
}
.info-box p { font-size: 13px; color: var(--text-dim); margin: 8px 0 12px 0; line-height: 1.55; }
.cb-row {
display: flex;
align-items: center;
gap: 10px;
font-size: 13px;
color: var(--text);
}
.cb-row input[type='checkbox'] { width: 14px; height: 14px; accent-color: var(--text); }
.plan {
position: relative;
display: flex;
align-items: center;
gap: 14px;
padding: 16px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--surface);
cursor: pointer;
}
.plan.selected { border-color: var(--text); background: var(--bg); }
.plan.disabled { opacity: 0.45; cursor: not-allowed; }
.plan.disabled:hover { background: var(--surface); }
.total-line {
display: flex;
justify-content: space-between;
align-items: baseline;
margin: 4px 0 0;
padding: 12px 14px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
}
.total-line strong {
font-family: var(--font-display);
font-size: 16px;
font-weight: 600;
color: var(--text);
}
.rec {
position: absolute;
top: -8px;
right: 12px;
background: var(--accent);
color: var(--accent-fg);
font-family: var(--font-mono);
font-size: 10px;
font-weight: 700;
padding: 2px 8px;
border-radius: 3px;
letter-spacing: 0.06em;
}
.radio {
width: 18px;
height: 18px;
border-radius: 999px;
border: 2px solid var(--border-hi);
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.radio.on { border-color: var(--text); }
.radio-inner { width: 8px; height: 8px; border-radius: 999px; background: var(--text); }
.plan-body { flex: 1; }
.plan-head { display: flex; align-items: baseline; gap: 10px; }
.plan-name { font-family: var(--font-display); font-size: 17px; font-weight: 600; }
.brand-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; }
.def { display: flex; flex-direction: column; gap: 8px; margin: 0; }
.def-row { display: grid; grid-template-columns: 160px 1fr; gap: 12px; font-size: 13px; }
.def-row dt { color: var(--text-mute); font-family: var(--font-mono); font-size: 11px; padding-top: 1px; }
.def-row dd { margin: 0; color: var(--text); }
.color-row { display: flex; align-items: center; gap: 8px; }
.color-swatch { width: 14px; height: 14px; border-radius: 3px; display: inline-block; }
.review-hero {
padding: 16px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
margin-bottom: 16px;
}
.review-name {
font-family: var(--font-display);
font-size: 22px;
font-weight: 600;
letter-spacing: -0.02em;
margin-top: 6px;
}
.prov-note {
margin-top: 16px;
padding: 14px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
}
.prov-note p { font-size: 12px; color: var(--text-dim); line-height: 1.6; margin: 6px 0 0 0; }
.err {
margin: 12px 0 0;
padding: 10px 12px;
font-size: 12px;
color: var(--bad);
background: rgba(226, 48, 48, 0.08);
border: 1px solid rgba(226, 48, 48, 0.2);
border-radius: 6px;
}
/* Provisioned view — shown after submit() succeeds. */
.provisioned {
display: flex;
flex-direction: column;
gap: 14px;
padding: 4px 0;
}
.provisioned h3 {
font-family: var(--font-display);
font-weight: 600;
font-size: 22px;
letter-spacing: -0.02em;
margin: 0;
}
.ok-msg,
.warn-msg {
margin: 0;
font-size: 13px;
color: var(--text-dim);
line-height: 1.55;
}
.warn-msg {
padding: 10px 12px;
background: rgba(232, 154, 31, 0.08);
border: 1px solid rgba(232, 154, 31, 0.24);
border-radius: 6px;
color: var(--warn);
}
.cred-row {
display: flex;
align-items: center;
gap: 8px;
}
.cred-row input {
flex: 1;
padding: 9px 12px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
font-family: var(--font-mono);
font-size: 12px;
color: var(--text);
}
</style>
@@ -0,0 +1,246 @@
<script setup lang="ts">
// Side panel for Escalate / Check in tasks raised from a customer health row.
// Pre-fills notes from the health drivers and lets the partner tweak before
// creating the task.
import type { CustomerOrg } from '~/data/customers'
export interface TaskContext {
customer: CustomerOrg
score: number
mode: 'escalate' | 'checkin'
}
const props = defineProps<{ task: TaskContext | null }>()
const emit = defineEmits<{ close: []; save: [t: TaskContext] }>()
const assignee = ref('Anders Bjerregaard')
const due = ref('')
const severity = ref<'low' | 'medium' | 'high'>('high')
const snapshot = ref(true)
const notes = ref('')
watch(
() => props.task,
(t) => {
if (!t) return
assignee.value = 'Anders Bjerregaard'
due.value = t.mode === 'escalate' ? '2026-05-26' : '2026-05-31'
severity.value = t.mode === 'escalate' ? 'high' : 'medium'
snapshot.value = true
const drivers: string[] = []
if (t.customer.status === 'past_due') drivers.push('Invoice past-due — billing follow-up needed.')
if (t.customer.status === 'attention') drivers.push('Account flagged "attention" — investigate root cause.')
if (t.customer.seats.used / t.customer.seats.total > 0.85) {
drivers.push(`Seat usage at ${Math.round(t.customer.seats.used/t.customer.seats.total*100)}% — upsell opportunity.`)
}
if (t.mode === 'escalate') {
drivers.unshift(`${t.customer.name} dropped below 50 health. Suggested action: schedule a 30-min review with their primary contact this week.`)
} else {
drivers.unshift(`${t.customer.name} is on the watch list. Suggested action: a brief check-in to renew the relationship.`)
}
notes.value = drivers.join('\n\n')
},
{ immediate: true },
)
const isEscalate = computed(() => props.task?.mode === 'escalate')
function healthColor(h: number) {
if (h >= 75) return 'var(--ok)'
if (h >= 50) return 'var(--warn)'
return 'var(--bad)'
}
function drivers() {
if (!props.task) return []
const c = props.task.customer
return [
c.status === 'past_due' && { l: 'Invoice past-due', d: 'INV-2026-04204 · 21 days overdue', tone: 'bad' as const },
c.status === 'attention' && { l: 'Status flagged attention', d: 'manual flag · open support ticket', tone: 'warn' as const },
c.seats.used / c.seats.total > 0.85 && { l: 'Seat usage high', d: `${c.seats.used}/${c.seats.total} seats — approaching limit`, tone: 'warn' as const },
c.plan === 'starter' && { l: 'Plan trending low', d: 'Starter plan · no upgrade in 6 mo', tone: 'info' as const },
].filter(Boolean) as Array<{ l: string; d: string; tone: 'bad' | 'warn' | 'info' }>
}
</script>
<template>
<SidePanel
:open="!!task"
width="md"
:eyebrow="isEscalate ? 'Customer health · escalate' : 'Customer health · check in'"
:title="task ? (isEscalate ? `Escalate ${task.customer.name}` : `Check in with ${task.customer.name}`) : ''"
@close="emit('close')"
>
<div v-if="task">
<div class="head-card">
<div class="hc-row">
<div class="cust-swatch" :style="{ background: task.customer.brandColor }" />
<div class="hc-meta">
<div class="hc-name">{{ task.customer.name }}</div>
<Mono dim>{{ task.customer.domain }} · {{ task.customer.planLabel }}</Mono>
</div>
<div class="hc-score">
<Eyebrow>Health score</Eyebrow>
<div class="score-val" :style="{ color: healthColor(task.score) }">{{ task.score }}</div>
</div>
</div>
<div class="drivers-card">
<Eyebrow>Drivers · what pulled the score down</Eyebrow>
<div class="drivers-list">
<div v-for="d in drivers()" :key="d.l" class="driver-row">
<Badge :tone="d.tone" dot>{{ d.tone }}</Badge>
<span class="dr-label">{{ d.l }}</span>
<Mono dim>{{ d.d }}</Mono>
</div>
</div>
</div>
</div>
<div class="form">
<label class="field">
<Eyebrow>Assigned to</Eyebrow>
<div class="assignee">
<Avatar :name="assignee" :size="24" />
<span>{{ assignee }}</span>
<UiButton size="sm" variant="ghost">Change</UiButton>
</div>
</label>
<div class="row-2">
<label class="field">
<Eyebrow>Due date</Eyebrow>
<input v-model="due" type="date" />
</label>
<label class="field">
<Eyebrow>Severity</Eyebrow>
<div class="seg">
<button
v-for="s in (['low', 'medium', 'high'] as const)"
:key="s"
type="button"
:class="{ active: severity === s }"
@click="severity = s"
>{{ s }}</button>
</div>
</label>
</div>
<label class="field">
<Eyebrow>{{ isEscalate ? 'Escalation notes' : 'Check-in talking points' }}</Eyebrow>
<textarea v-model="notes" rows="8" />
<Mono dim>pre-filled from the health drivers edit before saving</Mono>
</label>
<label class="cb-row">
<input v-model="snapshot" type="checkbox" />
Attach a health snapshot to the task
</label>
<div v-if="isEscalate" class="warn">
<UiIcon name="shield" :size="14" />
<p>
Escalations notify the account owner immediately and appear at the top of their queue. Use sparingly.
</p>
</div>
</div>
</div>
<template #footer>
<UiButton variant="ghost" @click="emit('close')">Cancel</UiButton>
<div style="flex:1" />
<UiButton variant="secondary">
<template #leading><UiIcon name="mail" :size="14" /></template>
Save as draft
</UiButton>
<UiButton variant="primary" @click="task && emit('save', task); emit('close')">
<template #leading><UiIcon name="check" :size="14" /></template>
{{ isEscalate ? 'Create escalation' : 'Schedule check-in' }}
</UiButton>
</template>
</SidePanel>
</template>
<style scoped>
.head-card { margin-bottom: 20px; padding-bottom: 20px; border-bottom: 1px solid var(--border); }
.hc-row { display: flex; align-items: center; gap: 14px; }
.cust-swatch { width: 44px; height: 44px; border-radius: 8px; flex-shrink: 0; }
.hc-meta { flex: 1; min-width: 0; }
.hc-name { font-family: var(--font-display); font-weight: 600; font-size: 17px; letter-spacing: -0.015em; }
.hc-score { text-align: right; }
.score-val { font-family: var(--font-display); font-weight: 600; font-size: 24px; margin-top: 4px; line-height: 1; }
.drivers-card {
margin-top: 14px;
padding: 12px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
}
.drivers-list { display: flex; flex-direction: column; gap: 6px; margin-top: 8px; }
.driver-row { display: flex; align-items: center; gap: 8px; font-size: 12px; }
.dr-label { flex: 1; }
.form { display: flex; flex-direction: column; gap: 14px; }
.row-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.field { display: flex; flex-direction: column; gap: 6px; }
.field input, .field textarea {
padding: 9px 12px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
font-family: inherit;
font-size: 13px;
color: var(--text);
}
.field textarea { resize: vertical; line-height: 1.55; }
.field input:focus, .field textarea:focus { outline: none; border-color: var(--border-hi); }
.assignee {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
font-size: 13px;
}
.assignee span { flex: 1; }
.seg {
display: flex;
border: 1px solid var(--border);
border-radius: 6px;
padding: 2px;
background: var(--surface);
}
.seg button {
flex: 1;
padding: 6px 0;
border: none;
border-radius: 4px;
background: transparent;
color: var(--text);
font-size: 12px;
font-weight: 500;
cursor: pointer;
font-family: inherit;
text-transform: capitalize;
}
.seg button.active { background: var(--text); color: var(--bg); }
.cb-row { display: flex; align-items: center; gap: 10px; font-size: 13px; }
.cb-row input[type='checkbox'] { width: 14px; height: 14px; accent-color: var(--text); }
.warn {
padding: 12px;
background: rgba(226, 48, 48, 0.06);
border: 1px solid rgba(226, 48, 48, 0.22);
border-radius: 6px;
display: flex;
gap: 10px;
}
.warn :deep(svg) { color: var(--bad); flex-shrink: 0; margin-top: 2px; }
.warn p { font-size: 12px; color: var(--text-dim); line-height: 1.55; margin: 0; }
</style>
@@ -0,0 +1,194 @@
<script setup lang="ts">
// Edit modal for the partner's own brand identity. Includes a small live
// preview of how the partner topbar/header will look with the picked
// primary color + display name.
defineProps<{ open: boolean }>()
const emit = defineEmits<{ close: [] }>()
const name = ref('NordicMSP')
const color = ref('#3F6BFF')
const supportEmail = ref('support@nordicmsp.dk')
const supportPhone = ref('+45 70 70 12 34')
const website = ref('nordicmsp.dk')
const replyTo = ref('no-reply@nordicmsp.dk')
const SWATCHES = ['#3F6BFF', '#0A2540', '#0066CC', '#5B8C5A', '#D4FF3A']
</script>
<template>
<Modal
:open="open"
eyebrow="Partner · identity"
title="Edit NordicMSP identity"
size="md"
@close="emit('close')"
>
<div class="form">
<div class="info">
<UiIcon name="shield" :size="14" />
<p>
This identity appears in the partner console and on emails sent by your team. It is
<b>not</b> what your customers see they see their own branding (or the defaults you set below).
</p>
</div>
<label class="field">
<Eyebrow>Display name</Eyebrow>
<input v-model="name" />
</label>
<div>
<Eyebrow>Logo &amp; mark</Eyebrow>
<div class="upload-grid">
<div class="upload-row">
<div class="upload-pv" :style="{ background: color }">{{ name[0]?.toLowerCase() || 'n' }}</div>
<div class="upload-meta">
<div class="upload-l">Full logo</div>
<Mono dim>nordic-logo.svg · 24 KB</Mono>
</div>
<UiButton size="sm" variant="ghost">Replace</UiButton>
</div>
<div class="upload-row">
<div class="upload-pv" :style="{ background: color }">{{ name[0]?.toLowerCase() || 'n' }}</div>
<div class="upload-meta">
<div class="upload-l">Square mark</div>
<Mono dim>nordic-mark.svg · 8 KB</Mono>
</div>
<UiButton size="sm" variant="ghost">Replace</UiButton>
</div>
</div>
</div>
<div class="field">
<Eyebrow>Primary color</Eyebrow>
<div class="color-row">
<div class="swatches">
<button
v-for="c in SWATCHES"
:key="c"
type="button"
class="sw"
:class="{ selected: color === c }"
:style="{ background: c }"
@click="color = c"
/>
</div>
<input v-model="color" class="hex" />
</div>
</div>
<div class="row-2">
<label class="field"><Eyebrow>Support email</Eyebrow><input v-model="supportEmail" /></label>
<label class="field"><Eyebrow>Support phone</Eyebrow><input v-model="supportPhone" /></label>
<label class="field"><Eyebrow>Website</Eyebrow><input v-model="website" /></label>
<label class="field"><Eyebrow>Reply-to address</Eyebrow><input v-model="replyTo" /></label>
</div>
<div class="preview">
<div class="pv-mark" :style="{ background: color }">{{ name[0]?.toLowerCase() || 'n' }}</div>
<div class="pv-meta">
<div class="pv-name">{{ name }}</div>
<Mono dim>preview · partner console header + email signature</Mono>
</div>
</div>
</div>
<template #footer>
<UiButton variant="ghost" @click="emit('close')">Cancel</UiButton>
<UiButton variant="primary" @click="emit('close')">
<template #leading><UiIcon name="check" :size="14" /></template>
Save identity
</UiButton>
</template>
</Modal>
</template>
<style scoped>
.form { display: flex; flex-direction: column; gap: 16px; }
.info {
display: flex;
gap: 10px;
padding: 12px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
}
.info p { font-size: 12px; color: var(--text-dim); margin: 0; line-height: 1.55; }
.info :deep(svg) { color: var(--text-mute); margin-top: 2px; flex-shrink: 0; }
.field { display: flex; flex-direction: column; gap: 6px; }
.row-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.field input, .hex {
padding: 9px 12px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
font-family: inherit;
font-size: 13px;
color: var(--text);
}
.field input:focus, .hex:focus { outline: none; border-color: var(--border-hi); }
.upload-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 8px; }
.upload-row {
display: flex;
align-items: center;
gap: 10px;
padding: 12px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
}
.upload-pv {
width: 40px;
height: 40px;
border-radius: 6px;
color: #fff;
display: inline-flex;
align-items: center;
justify-content: center;
font-family: var(--font-mono);
font-weight: 700;
font-size: 16px;
}
.upload-meta { flex: 1; min-width: 0; }
.upload-l { font-size: 13px; font-weight: 500; }
.color-row { display: flex; align-items: center; gap: 10px; }
.swatches { display: flex; gap: 8px; }
.sw {
width: 32px;
height: 32px;
border-radius: 6px;
border: 1px solid var(--border);
cursor: pointer;
}
.sw.selected { border: 2px solid var(--text); }
.hex { flex: 1; font-family: var(--font-mono); }
.preview {
padding: 12px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
display: flex;
align-items: center;
gap: 10px;
}
.pv-mark {
width: 28px;
height: 28px;
border-radius: 6px;
color: #fff;
font-family: var(--font-mono);
font-weight: 700;
font-size: 13px;
display: inline-flex;
align-items: center;
justify-content: center;
}
.pv-name { font-size: 13px; font-weight: 500; }
</style>
@@ -0,0 +1,204 @@
<script setup lang="ts">
// Side-panel template editor. Subject + body + merge tags + live HTML preview
// wrapped in the partner's brand color. Used from the partner branding page
// "Customer email templates" list.
export interface EmailTemplate {
id: string
name: string
subject: string
body: string
edited: string
}
const props = defineProps<{ template: EmailTemplate | null; brandColor: string; brandName: string }>()
const emit = defineEmits<{ close: []; save: [t: EmailTemplate] }>()
const subject = ref('')
const body = ref('')
watch(
() => props.template?.id,
() => {
if (props.template) {
subject.value = props.template.subject
body.value = props.template.body
}
},
{ immediate: true },
)
const MERGE_TAGS = [
'{{user.first_name}}',
'{{workspace.name}}',
'{{partner.name}}',
'{{plan.name}}',
'{{invoice.id}}',
'{{support.email}}',
]
function insertTag(t: string) {
body.value += (body.value.endsWith(' ') || body.value === '' ? '' : ' ') + t
}
function onSave() {
if (!props.template) return
emit('save', { ...props.template, subject: subject.value, body: body.value })
}
</script>
<template>
<SidePanel
:open="!!template"
width="lg"
eyebrow="Email template"
:title="template?.name || 'Edit template'"
@close="emit('close')"
>
<div v-if="template" class="grid">
<div class="editor">
<label class="field">
<Eyebrow>Subject</Eyebrow>
<input v-model="subject" />
</label>
<label class="field">
<Eyebrow>Body</Eyebrow>
<textarea v-model="body" rows="14" />
</label>
<div class="tags">
<Eyebrow>Merge tags · click to insert</Eyebrow>
<div class="tag-chips">
<button v-for="t in MERGE_TAGS" :key="t" type="button" @click="insertTag(t)">
<Mono>{{ t }}</Mono>
</button>
</div>
</div>
</div>
<div class="preview-wrap">
<Eyebrow>Live preview</Eyebrow>
<div class="preview">
<div class="pv-header" :style="{ background: brandColor }">
<div class="pv-mark">{{ brandName[0]?.toLowerCase() }}</div>
<span class="pv-brand">{{ brandName }}</span>
</div>
<div class="pv-body">
<div class="pv-subject">{{ subject || '(empty subject)' }}</div>
<div class="pv-body-text">{{ body || '(empty body)' }}</div>
<div class="pv-cta-wrap">
<a class="pv-cta" :style="{ background: brandColor }">Open workspace</a>
</div>
<div class="pv-foot">
Sent by {{ brandName }} · support@nordicmsp.dk
</div>
</div>
</div>
</div>
</div>
<template #footer>
<UiButton variant="ghost" @click="emit('close')">Cancel</UiButton>
<div style="flex:1" />
<UiButton variant="secondary">Send test email</UiButton>
<UiButton variant="primary" @click="onSave">
<template #leading><UiIcon name="check" :size="14" /></template>
Save template
</UiButton>
</template>
</SidePanel>
</template>
<style scoped>
.grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
}
.editor { display: flex; flex-direction: column; gap: 14px; }
.field { display: flex; flex-direction: column; gap: 6px; }
.field input, .field textarea {
padding: 10px 12px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
font-family: inherit;
font-size: 13px;
color: var(--text);
}
.field textarea { font-family: var(--font-mono); font-size: 12px; resize: vertical; line-height: 1.6; }
.field input:focus, .field textarea:focus { outline: none; border-color: var(--border-hi); }
.tags { display: flex; flex-direction: column; gap: 8px; }
.tag-chips { display: flex; flex-wrap: wrap; gap: 6px; }
.tag-chips button {
padding: 4px 8px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 4px;
cursor: pointer;
font-family: inherit;
}
.tag-chips button:hover { background: var(--bg); }
.preview-wrap { display: flex; flex-direction: column; gap: 10px; position: sticky; top: 0; }
.preview {
background: #fff;
border: 1px solid var(--border);
border-radius: 8px;
overflow: hidden;
color: #111;
}
.pv-header {
padding: 14px 18px;
color: #fff;
display: flex;
align-items: center;
gap: 10px;
}
.pv-mark {
width: 28px;
height: 28px;
border-radius: 6px;
background: rgba(255, 255, 255, 0.16);
display: inline-flex;
align-items: center;
justify-content: center;
font-family: var(--font-mono);
font-weight: 700;
font-size: 14px;
}
.pv-brand { font-family: var(--font-display); font-size: 16px; font-weight: 600; letter-spacing: -0.015em; }
.pv-body { padding: 20px 18px; }
.pv-subject {
font-family: var(--font-display);
font-size: 17px;
font-weight: 600;
margin-bottom: 12px;
letter-spacing: -0.01em;
}
.pv-body-text { font-size: 13px; line-height: 1.65; white-space: pre-wrap; color: #333; }
.pv-cta-wrap { margin-top: 18px; }
.pv-cta {
display: inline-block;
padding: 9px 14px;
color: #fff;
font-size: 13px;
font-weight: 600;
border-radius: 6px;
text-decoration: none;
}
.pv-foot {
font-family: var(--font-mono);
font-size: 10px;
color: #888;
margin-top: 22px;
padding-top: 12px;
border-top: 1px solid #eee;
}
</style>
@@ -0,0 +1,116 @@
<script setup lang="ts">
// Shown when a partner clicks "Enter customer" anywhere in the partner UI.
// Forces the partner to acknowledge that every action they take inside the
// customer org will be logged under their partner identity, and prompts for
// an optional (but recommended) reason — captured into the customer audit log.
import type { CustomerOrg } from '~/data/customers'
const props = defineProps<{ customer: CustomerOrg | null }>()
const emit = defineEmits<{ close: []; confirm: [reason: string] }>()
const reason = ref('Quarterly account review')
watch(
() => props.customer?.id,
() => {
reason.value = 'Quarterly account review'
},
)
</script>
<template>
<Modal
:open="!!customer"
eyebrow="Partner action"
:title="customer ? `Enter ${customer.name} as partner` : 'Enter customer'"
size="sm"
@close="emit('close')"
>
<template v-if="customer">
<div class="cust-card">
<div class="swatch" :style="{ background: customer.brandColor }" />
<div class="cust-meta">
<div class="cust-name">{{ customer.name }}</div>
<Mono dim>{{ customer.domain }} · {{ customer.planLabel }}</Mono>
</div>
</div>
<p class="note">
You'll see this customer's admin console exactly as their own admins do. Any change
you make is logged as a <b>partner action</b>, visible in their audit log with your
name attached.
</p>
<label class="field">
<Eyebrow>Reason for entering · recommended</Eyebrow>
<textarea
v-model="reason"
placeholder="e.g. Investigating support ticket #841"
rows="3"
/>
</label>
</template>
<template #footer>
<UiButton variant="ghost" @click="emit('close')">Cancel</UiButton>
<UiButton variant="primary" @click="emit('confirm', reason)">
<template #leading><UiIcon name="arrowRight" :size="14" /></template>
Enter customer
</UiButton>
</template>
</Modal>
</template>
<style scoped>
.cust-card {
display: flex;
align-items: center;
gap: 14px;
padding: 14px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
margin-bottom: 16px;
}
.swatch {
width: 40px;
height: 40px;
border-radius: 6px;
flex-shrink: 0;
}
.cust-meta { min-width: 0; }
.cust-name {
font-family: var(--font-display);
font-weight: 600;
font-size: 16px;
letter-spacing: -0.015em;
}
.note {
font-size: 13px;
color: var(--text-dim);
line-height: 1.6;
margin: 0 0 16px 0;
}
.field { display: flex; flex-direction: column; gap: 8px; }
textarea {
width: 100%;
padding: 10px 12px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
font-size: 13px;
font-family: inherit;
color: var(--text);
resize: vertical;
min-height: 60px;
line-height: 1.5;
}
textarea:focus { outline: none; border-color: var(--border-hi); }
</style>
@@ -0,0 +1,295 @@
<script setup lang="ts">
// Invite a teammate to the partner organization. Role + customer-access
// scoping + require-MFA toggle + optional personal note. Invitations expire
// after 7 days — the design surfaces that explicitly.
import { customers } from '~/data/customers'
defineProps<{ open: boolean }>()
const emit = defineEmits<{ close: []; sent: [payload: { email: string; role: string }] }>()
const name = ref('')
const email = ref('')
const role = ref<'Partner admin' | 'Sales' | 'Support' | 'Billing'>('Sales')
const access = ref<'all' | 'specific' | 'none'>('all')
const specific = ref<string[]>([])
const requireMfa = ref(true)
const message = ref('')
const ROLE_OPTS = [
{ v: 'Partner admin', d: 'Full access · billing · settings · all customers' },
{ v: 'Sales', d: 'Customer orgs · provisioning · plan changes' },
{ v: 'Support', d: 'Enter customers · view tickets · no billing' },
{ v: 'Billing', d: 'Invoices · payouts · cannot enter customers' },
] as const
const ACCESS_OPTS = [
{ v: 'all', l: 'All customers', d: 'Including new ones added later' },
{ v: 'specific', l: 'Specific customers', d: 'Pick from the list below' },
{ v: 'none', l: 'No customer access', d: 'Partner-only console (for Billing role)' },
] as const
function toggleCustomer(id: string) {
if (specific.value.includes(id)) specific.value = specific.value.filter((x) => x !== id)
else specific.value = [...specific.value, id]
}
function planBadgeTone(p: string) {
return p === 'enterprise' ? 'invert' : 'neutral'
}
</script>
<template>
<Modal
:open="open"
eyebrow="Partner team · invite"
title="Invite teammate"
size="md"
@close="emit('close')"
>
<div class="form">
<div class="row-2">
<label class="field">
<Eyebrow>Full name</Eyebrow>
<input v-model="name" placeholder="Anne Baslund" />
</label>
<label class="field">
<Eyebrow>Email</Eyebrow>
<input v-model="email" placeholder="name@nordicmsp.dk" />
</label>
</div>
<div>
<Eyebrow>Role</Eyebrow>
<div class="role-grid">
<button
v-for="o in ROLE_OPTS"
:key="o.v"
type="button"
class="role-card"
:class="{ selected: role === o.v }"
@click="role = o.v as any"
>
<div class="rc-top">
<span class="rc-name">{{ o.v }}</span>
<Badge v-if="o.v === 'Partner admin'" tone="invert">all access</Badge>
</div>
<Mono dim>{{ o.d }}</Mono>
</button>
</div>
</div>
<div>
<Eyebrow>Customer access</Eyebrow>
<div class="access-list">
<button
v-for="o in ACCESS_OPTS"
:key="o.v"
type="button"
class="access-row"
:class="{ selected: access === o.v }"
@click="access = o.v as any"
>
<span class="radio" :class="{ on: access === o.v }">
<span v-if="access === o.v" class="radio-inner" />
</span>
<div class="ar-meta">
<div class="ar-label">{{ o.l }}</div>
<Mono dim>{{ o.d }}</Mono>
</div>
</button>
</div>
<div v-if="access === 'specific'" class="picker">
<div class="picker-head">
<Mono dim>{{ specific.length }} of {{ customers.length }} selected</Mono>
</div>
<div class="picker-list">
<label v-for="c in customers" :key="c.id" class="picker-row">
<input
type="checkbox"
:checked="specific.includes(c.id)"
@change="toggleCustomer(c.id)"
/>
<div class="cust-swatch" :style="{ background: c.brandColor }" />
<span class="cust-name">{{ c.name }}</span>
<Badge :tone="planBadgeTone(c.plan)">{{ c.planLabel }}</Badge>
</label>
</div>
</div>
</div>
<div class="mfa-row">
<div>
<div class="mfa-label">Require MFA on first sign-in</div>
<Mono dim>recommended for any partner role with customer access</Mono>
</div>
<button class="switch" :class="{ on: requireMfa }" @click="requireMfa = !requireMfa">
<span class="thumb" />
</button>
</div>
<label class="field">
<Eyebrow>Personal note · optional</Eyebrow>
<textarea
v-model="message"
rows="3"
placeholder="Welcome to the team — looking forward to working together."
/>
</label>
<div class="warn">
<UiIcon name="shield" :size="14" />
<p>
Invitations expire after <b>7 days</b>. The teammate will create their own password and
complete MFA enrolment before getting access.
</p>
</div>
</div>
<template #footer>
<UiButton variant="ghost" @click="emit('close')">Cancel</UiButton>
<UiButton
variant="primary"
:disabled="!email"
@click="emit('sent', { email, role }); emit('close')"
>
<template #leading><UiIcon name="mail" :size="14" /></template>
Send invitation
</UiButton>
</template>
</Modal>
</template>
<style scoped>
.form { display: flex; flex-direction: column; gap: 16px; }
.row-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.field { display: flex; flex-direction: column; gap: 6px; }
.field input, .field textarea {
padding: 9px 12px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
font-family: inherit;
font-size: 13px;
color: var(--text);
}
.field textarea { resize: vertical; line-height: 1.55; }
.field input:focus, .field textarea:focus { outline: none; border-color: var(--border-hi); }
.role-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
margin-top: 8px;
}
.role-card {
padding: 12px;
border-radius: 6px;
border: 1px solid var(--border);
background: var(--surface);
text-align: left;
cursor: pointer;
font-family: inherit;
}
.role-card.selected { border-color: var(--text); background: var(--bg); }
.rc-top { display: flex; justify-content: space-between; align-items: center; margin-bottom: 4px; }
.rc-name { font-size: 13px; font-weight: 500; }
.access-list { display: flex; flex-direction: column; gap: 8px; margin-top: 8px; }
.access-row {
display: flex;
align-items: center;
gap: 10px;
padding: 10px;
border-radius: 6px;
border: 1px solid var(--border);
background: var(--surface);
font-family: inherit;
cursor: pointer;
text-align: left;
}
.access-row.selected { border-color: var(--text); background: var(--bg); }
.radio {
width: 14px;
height: 14px;
border-radius: 999px;
border: 1.5px solid var(--border-hi);
background: var(--bg);
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
}
.radio.on { border: 4px solid var(--text); }
.ar-meta { flex: 1; }
.ar-label { font-size: 13px; font-weight: 500; }
.picker {
margin-top: 10px;
padding: 12px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
max-height: 240px;
overflow-y: auto;
}
.picker-head { margin-bottom: 8px; }
.picker-list { display: flex; flex-direction: column; gap: 6px; }
.picker-row {
display: flex;
align-items: center;
gap: 10px;
cursor: pointer;
font-size: 13px;
}
.picker-row input[type='checkbox'] { width: 14px; height: 14px; accent-color: var(--text); }
.cust-swatch { width: 16px; height: 16px; border-radius: 3px; flex-shrink: 0; }
.cust-name { flex: 1; }
.mfa-row {
padding: 12px 14px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.mfa-label { font-size: 13px; font-weight: 500; }
.switch {
width: 36px;
height: 20px;
border-radius: 999px;
background: var(--border);
border: none;
padding: 2px;
display: inline-flex;
align-items: center;
cursor: pointer;
transition: background 150ms;
flex-shrink: 0;
}
.switch.on { background: var(--text); }
.thumb {
width: 16px;
height: 16px;
border-radius: 999px;
background: var(--bg);
transition: transform 150ms;
}
.switch.on .thumb { transform: translateX(16px); }
.warn {
padding: 12px;
background: rgba(232, 154, 31, 0.08);
border: 1px solid rgba(232, 154, 31, 0.24);
border-radius: 6px;
display: flex;
gap: 10px;
}
.warn :deep(svg) { color: var(--warn); margin-top: 2px; flex-shrink: 0; }
.warn p { font-size: 12px; color: var(--text-dim); line-height: 1.55; margin: 0; }
</style>
@@ -0,0 +1,297 @@
<script setup lang="ts">
// Two-column modal for building a partner custom report. Left: name +
// description + metric picker + filters + group-by. Right: schedule cards +
// recipients + format + live summary.
defineProps<{ open: boolean }>()
const emit = defineEmits<{ close: []; created: [name: string] }>()
const METRICS = [
{ id: 'mrr', label: 'MRR', group: 'Revenue' },
{ id: 'arr', label: 'ARR', group: 'Revenue' },
{ id: 'margin', label: 'Partner margin', group: 'Revenue' },
{ id: 'arpu', label: 'ARPU', group: 'Revenue' },
{ id: 'health', label: 'Avg health score', group: 'Health' },
{ id: 'nps', label: 'NPS', group: 'Health' },
{ id: 'seats', label: 'Seats used', group: 'Usage' },
{ id: 'storage', label: 'Storage used', group: 'Usage' },
{ id: 'tickets', label: 'Tickets opened', group: 'Usage' },
{ id: 'churn', label: 'Churn rate', group: 'Retention' },
{ id: 'retention', label: 'Net retention', group: 'Retention' },
{ id: 'tenure', label: 'Avg tenure', group: 'Retention' },
] as const
const SCHEDULES = [
{ v: 'weekly', l: 'Weekly', d: 'Mondays · 09:00 CET' },
{ v: 'monthly', l: 'Monthly', d: '1st of the month · 09:00 CET' },
{ v: 'quarterly', l: 'Quarterly', d: '1st of Jan / Apr / Jul / Oct' },
{ v: 'ondemand', l: 'On-demand', d: 'No automatic schedule' },
] as const
const name = ref('Quarterly board — Q3 2026')
const description = ref('')
const metrics = ref<string[]>(['mrr', 'margin', 'churn', 'health'])
const filterPlan = ref('all')
const filterStatus = ref('all')
const groupBy = ref<'plan' | 'region' | 'owner' | 'none'>('plan')
const schedule = ref<'weekly' | 'monthly' | 'quarterly' | 'ondemand'>('quarterly')
const recipients = ref('board@dezky.com')
const format = ref<'pdf' | 'csv' | 'xlsx'>('pdf')
const grouped = computed(() => {
const out: Record<string, typeof METRICS[number][]> = {}
for (const m of METRICS) {
out[m.group] = out[m.group] || []
out[m.group].push(m)
}
return out
})
function toggle(id: string) {
if (metrics.value.includes(id)) metrics.value = metrics.value.filter((x) => x !== id)
else metrics.value = [...metrics.value, id]
}
</script>
<template>
<Modal
:open="open"
eyebrow="Partner reports · custom"
title="New custom report"
size="lg"
@close="emit('close')"
>
<div class="grid">
<!-- Left -->
<div class="col">
<label class="field">
<Eyebrow>Report name</Eyebrow>
<input v-model="name" />
</label>
<label class="field">
<Eyebrow>Description · optional</Eyebrow>
<input v-model="description" placeholder="What's this report for?" />
</label>
<div>
<Eyebrow>Metrics · pick what to include</Eyebrow>
<div class="metric-card">
<div v-for="(items, group) in grouped" :key="group" class="metric-group">
<Mono dim>{{ group }}</Mono>
<div class="chips">
<button
v-for="m in items"
:key="m.id"
type="button"
class="chip"
:class="{ on: metrics.includes(m.id) }"
@click="toggle(m.id)"
>
<UiIcon v-if="metrics.includes(m.id)" name="check" :size="11" :stroke-width="2.6" />
{{ m.label }}
</button>
</div>
</div>
</div>
</div>
<div class="row-2">
<label class="field">
<Eyebrow>Filter · plan</Eyebrow>
<select v-model="filterPlan">
<option value="all">All plans</option>
<option value="starter">Starter</option>
<option value="business">Business</option>
<option value="enterprise">Enterprise</option>
</select>
</label>
<label class="field">
<Eyebrow>Filter · status</Eyebrow>
<select v-model="filterStatus">
<option value="all">All statuses</option>
<option value="healthy">Healthy</option>
<option value="attention">Attention</option>
<option value="past_due">Past-due</option>
<option value="trial">Trial</option>
</select>
</label>
</div>
<div>
<Eyebrow>Group by</Eyebrow>
<div class="seg">
<button v-for="o in ['plan','region','owner','none'] as const" :key="o" :class="{ active: groupBy === o }" @click="groupBy = o">
{{ o === 'owner' ? 'Account owner' : o }}
</button>
</div>
</div>
</div>
<!-- Right -->
<div class="col">
<div>
<Eyebrow>Schedule</Eyebrow>
<div class="schedule-list">
<button
v-for="o in SCHEDULES"
:key="o.v"
type="button"
class="sched-card"
:class="{ selected: schedule === o.v }"
@click="schedule = o.v as any"
>
<span class="radio" :class="{ on: schedule === o.v }" />
<div>
<div class="sc-label">{{ o.l }}</div>
<Mono dim>{{ o.d }}</Mono>
</div>
</button>
</div>
</div>
<label v-if="schedule !== 'ondemand'" class="field">
<Eyebrow>Email to</Eyebrow>
<input v-model="recipients" placeholder="email, email, …" />
<Mono dim>comma-separated</Mono>
</label>
<div>
<Eyebrow>Format</Eyebrow>
<div class="seg">
<button v-for="f in ['pdf','csv','xlsx'] as const" :key="f" :class="{ active: format === f }" @click="format = f">
{{ f.toUpperCase() }}
</button>
</div>
</div>
<div class="summary">
<Eyebrow>Summary</Eyebrow>
<dl>
<div><Mono dim>name</Mono><span>{{ name || '—' }}</span></div>
<div><Mono dim>metrics</Mono><span>{{ metrics.length }} selected</span></div>
<div><Mono dim>grouped by</Mono><span>{{ groupBy }}</span></div>
<div><Mono dim>delivery</Mono><span>{{ schedule }} · {{ format.toUpperCase() }}</span></div>
</dl>
</div>
</div>
</div>
<template #footer>
<UiButton variant="ghost" @click="emit('close')">Cancel</UiButton>
<div style="flex:1" />
<UiButton variant="secondary">Save as draft</UiButton>
<UiButton
variant="primary"
:disabled="!name || metrics.length === 0"
@click="emit('created', name); emit('close')"
>
<template #leading><UiIcon name="check" :size="14" /></template>
Create report
</UiButton>
</template>
</Modal>
</template>
<style scoped>
.grid { display: grid; grid-template-columns: 1.4fr 1fr; gap: 20px; }
.col { display: flex; flex-direction: column; gap: 16px; }
.field { display: flex; flex-direction: column; gap: 6px; }
.field input, .field select {
padding: 9px 12px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
font-family: inherit;
font-size: 13px;
color: var(--text);
}
.field input:focus, .field select:focus { outline: none; border-color: var(--border-hi); }
.row-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.metric-card {
margin-top: 8px;
padding: 12px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
display: flex;
flex-direction: column;
gap: 12px;
}
.metric-group { display: flex; flex-direction: column; gap: 6px; }
.chips { display: flex; flex-wrap: wrap; gap: 6px; }
.chip {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 10px;
border-radius: 4px;
background: var(--bg);
border: 1px solid var(--border);
color: var(--text);
font-family: inherit;
font-size: 12px;
cursor: pointer;
}
.chip.on { background: var(--text); color: var(--bg); border-color: var(--text); }
.seg {
display: flex;
border: 1px solid var(--border);
border-radius: 6px;
padding: 2px;
background: var(--surface);
}
.seg button {
flex: 1;
padding: 6px 0;
border: none;
border-radius: 4px;
background: transparent;
color: var(--text);
font-size: 12px;
font-weight: 500;
cursor: pointer;
font-family: inherit;
text-transform: capitalize;
}
.seg button.active { background: var(--text); color: var(--bg); }
.schedule-list { display: flex; flex-direction: column; gap: 6px; margin-top: 8px; }
.sched-card {
display: flex;
align-items: center;
gap: 10px;
padding: 10px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
cursor: pointer;
font-family: inherit;
text-align: left;
}
.sched-card.selected { border-color: var(--text); background: var(--bg); }
.radio {
width: 14px;
height: 14px;
border-radius: 999px;
border: 1.5px solid var(--border-hi);
background: var(--bg);
flex-shrink: 0;
}
.radio.on { border: 4px solid var(--text); }
.sc-label { font-size: 13px; font-weight: 500; }
.summary {
padding: 12px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
}
.summary dl { display: flex; flex-direction: column; gap: 6px; margin: 8px 0 0; }
.summary dl div { display: flex; justify-content: space-between; font-size: 12px; }
.summary dl span { color: var(--text); }
</style>
@@ -0,0 +1,50 @@
<script setup lang="ts">
// Tiny inline SVG sparkline. Takes a series of numbers and renders a stroked
// polyline plus a faint area fill underneath. Used on the partner dashboard
// (90-day MRR trend) and on the reports/revenue tab.
const props = withDefaults(
defineProps<{
values: number[]
width?: number
height?: number
stroke?: string
fill?: string
strokeWidth?: number
showDot?: boolean
}>(),
{
width: 420,
height: 64,
stroke: 'var(--text)',
fill: 'var(--row-hover)',
strokeWidth: 1.4,
showDot: true,
},
)
const geometry = computed(() => {
const data = props.values
if (!data.length) return { line: '', area: '', last: { x: 0, y: 0 }, min: 0, max: 0 }
const min = Math.min(...data)
const max = Math.max(...data)
const range = max - min || 1
const pts = data.map((v, i) => {
const x = (i / (data.length - 1)) * props.width
const y = props.height - ((v - min) / range) * (props.height - 6) - 3
return [x, y] as const
})
const line = pts.map(([x, y], i) => `${i === 0 ? 'M' : 'L'} ${x.toFixed(1)} ${y.toFixed(1)}`).join(' ')
const area = `${line} L ${props.width} ${props.height} L 0 ${props.height} Z`
const last = { x: pts[pts.length - 1][0], y: pts[pts.length - 1][1] }
return { line, area, last, min, max }
})
</script>
<template>
<svg :width="width" :height="height" :viewBox="`0 0 ${width} ${height}`" preserveAspectRatio="none" style="display:block">
<path :d="geometry.area" :fill="fill" />
<path :d="geometry.line" fill="none" :stroke="stroke" :stroke-width="strokeWidth" stroke-linecap="round" stroke-linejoin="round" />
<circle v-if="showDot" :cx="geometry.last.x" :cy="geometry.last.y" :r="3" :fill="stroke" />
</svg>
</template>
@@ -0,0 +1,358 @@
<script setup lang="ts">
// Right-side panel with full detail on a partner teammate. Three tabs:
// • Access & role — what they can do, which customers they can enter
// • Activity — last 5 partner actions with timestamps + IPs
// • Security — MFA card, active sessions, API tokens, suspend callout
import { customers } from '~/data/customers'
export interface TeamMember {
id: string
name: string
email: string
role: string
access: 'all' | 'specific' | 'none' | string
mfa: string
lastSeen: string
isOwner?: boolean
}
const props = defineProps<{ member: TeamMember | null }>()
const emit = defineEmits<{ close: [] }>()
const tab = ref<'access' | 'activity' | 'security'>('access')
watch(
() => props.member?.id,
() => { tab.value = 'access' },
)
const tabs = computed(() => [
{ value: 'access', label: 'Access & role' },
{ value: 'activity', label: 'Activity', count: 5 },
{ value: 'security', label: 'Security' },
])
const recentActions = [
{ when: '12 min ago', action: 'entered customer', target: 'Acme Industries', ip: '92.43.118.4 · København' },
{ when: '1 h ago', action: 'invited user', target: 'magnus@acme.dk', ip: '92.43.118.4 · København' },
{ when: 'Yesterday', action: 'changed plan', target: 'Bygherre · Business → Business+', ip: '92.43.118.4 · København' },
{ when: '3 days ago', action: 'signed in', target: 'partner console', ip: '78.32.4.91 · København' },
{ when: '1 week ago', action: 'provisioned', target: 'Henriksen Revision · new customer', ip: '92.43.118.4 · København' },
]
function permissionsFor(role: string) {
return [
{ l: 'View customer dashboards', allowed: true },
{ l: 'Enter customer as partner', allowed: role !== 'Billing' },
{ l: 'Provision new customers', allowed: role === 'Partner admin' || role === 'Sales' },
{ l: 'Change customer plans', allowed: role === 'Partner admin' || role === 'Sales' },
{ l: 'Manage partner billing', allowed: role === 'Partner admin' || role === 'Billing' },
{ l: 'Manage partner team', allowed: role === 'Partner admin' },
{ l: 'Edit partner branding', allowed: role === 'Partner admin' },
]
}
const isOwner = computed(() => !!props.member?.isOwner)
const accessText = computed(() => {
if (!props.member) return ''
if (props.member.access === 'all') return `all (${customers.length})`
if (props.member.access === 'none') return 'no access'
// Specific: just say first N customers
return `${customers.length - 5} of ${customers.length}`
})
</script>
<template>
<SidePanel
:open="!!member"
width="lg"
eyebrow="Partner teammate"
:title="member?.name || ''"
@close="emit('close')"
>
<template #header>
<!-- header handled by SidePanel slot defaults -->
</template>
<div v-if="member" class="profile-head">
<Avatar :name="member.name" :size="48" />
<div class="ph-meta">
<div class="ph-name">{{ member.name }}</div>
<Mono dim>{{ member.email }}</Mono>
</div>
<Badge :tone="member.role === 'Partner admin' ? 'invert' : 'neutral'">{{ member.role }}</Badge>
</div>
<div v-if="member" class="profile-stats">
<div>
<Eyebrow>Customer access</Eyebrow>
<div class="ps-val">{{ accessText }}</div>
</div>
<div>
<Eyebrow>MFA</Eyebrow>
<div class="ps-val"><Badge tone="ok" dot>enabled</Badge></div>
</div>
<div>
<Eyebrow>Last seen</Eyebrow>
<div class="ps-val">{{ member.lastSeen }}</div>
</div>
</div>
<div class="tabs-wrap">
<Tabs v-model="tab" :items="tabs" />
</div>
<div v-if="member && tab === 'access'" class="tab-body">
<div class="field">
<Eyebrow>Role</Eyebrow>
<div class="role-grid">
<div
v-for="r in ['Partner admin', 'Sales', 'Support', 'Billing']"
:key="r"
class="role-card"
:class="{ selected: member.role === r }"
>
<span>{{ r }}</span>
<Badge v-if="member.role === r" tone="invert">current</Badge>
</div>
</div>
</div>
<div>
<Eyebrow>Customer access</Eyebrow>
<div class="access-card">
<div class="ac-head">
<Mono dim>{{ accessText }}</Mono>
<UiButton size="sm" variant="ghost">Change</UiButton>
</div>
<div class="ac-list">
<div
v-for="c in customers.slice(0, member.access === 'all' ? customers.length : 3)"
:key="c.id"
class="ac-row"
>
<UiIcon name="check" :size="11" :stroke-width="2.5" />
<div class="cust-swatch" :style="{ background: c.brandColor }" />
<span class="cust-name">{{ c.name }}</span>
<Mono dim>{{ c.planLabel }}</Mono>
</div>
</div>
</div>
</div>
<div>
<Eyebrow>Permissions in {{ member.role }}</Eyebrow>
<div class="perm-list">
<div v-for="p in permissionsFor(member.role)" :key="p.l" class="perm-row">
<UiIcon :name="p.allowed ? 'check' : 'x'" :size="12" :stroke-width="p.allowed ? 2.5 : 2" />
<span :class="{ muted: !p.allowed }">{{ p.l }}</span>
</div>
</div>
</div>
</div>
<div v-if="member && tab === 'activity'" class="tab-body">
<div class="activity-list">
<div v-for="(a, i) in recentActions" :key="i" class="activity-row">
<div class="activity-icon">
<UiIcon
:name="a.action.startsWith('signed') ? 'shield' : a.action.startsWith('entered') ? 'arrowRight' : a.action.startsWith('invited') ? 'users' : a.action.startsWith('provisioned') ? 'plus' : 'brush'"
:size="12"
/>
</div>
<div class="activity-meta">
<div class="ar-top">
<Mono dim>{{ a.action }}</Mono>
<span>{{ a.target }}</span>
</div>
<Mono dim>{{ a.ip }}</Mono>
</div>
<Mono dim>{{ a.when }}</Mono>
</div>
</div>
</div>
<div v-if="member && tab === 'security'" class="tab-body">
<div class="sec-row">
<UiIcon name="shield" :size="16" />
<div class="sec-meta">
<div class="sec-label">MFA enabled</div>
<Mono dim>TOTP · enrolled 12 Jan 2026</Mono>
</div>
<UiButton size="sm" variant="ghost">Reset</UiButton>
</div>
<div class="sec-row">
<UiIcon name="device" :size="16" />
<div class="sec-meta">
<div class="sec-label">3 active sessions</div>
<Mono dim>Chrome · macOS · København</Mono>
</div>
<UiButton size="sm" variant="ghost">View · sign out</UiButton>
</div>
<div class="sec-row">
<UiIcon name="key" :size="16" />
<div class="sec-meta">
<div class="sec-label">API tokens</div>
<Mono dim>1 personal token · last used 2 d ago</Mono>
</div>
<UiButton size="sm" variant="ghost">Manage</UiButton>
</div>
<div class="danger-callout">
<UiIcon name="shield" :size="14" />
<div class="dc-meta">
<div class="dc-label">Suspend account</div>
<p>Immediately revoke access. Sessions are terminated and the teammate cannot sign back in. Reversible.</p>
</div>
<UiButton size="sm" variant="secondary" :disabled="isOwner">Suspend</UiButton>
</div>
</div>
<template #footer>
<UiButton variant="danger" :disabled="isOwner">
<template #leading><UiIcon name="trash" :size="14" /></template>
Remove from team
</UiButton>
<div style="flex:1" />
<UiButton variant="secondary">
<template #leading><UiIcon name="refresh" :size="14" /></template>
Reset password
</UiButton>
<UiButton variant="primary" @click="emit('close')">
<template #leading><UiIcon name="check" :size="14" /></template>
Save
</UiButton>
</template>
</SidePanel>
</template>
<style scoped>
.profile-head {
display: flex;
align-items: center;
gap: 14px;
margin-bottom: 16px;
}
.ph-meta { flex: 1; min-width: 0; }
.ph-name {
font-family: var(--font-display);
font-weight: 600;
font-size: 18px;
letter-spacing: -0.015em;
}
.profile-stats {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
padding-bottom: 22px;
border-bottom: 1px solid var(--border);
}
.ps-val { font-size: 13px; font-weight: 500; margin-top: 4px; }
.tabs-wrap { margin: -2px -24px 0; padding: 0 24px; border-bottom: 1px solid var(--border); }
.tab-body { padding-top: 22px; display: flex; flex-direction: column; gap: 16px; }
.field { display: flex; flex-direction: column; gap: 8px; }
.role-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
.role-card {
padding: 10px 12px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--surface);
font-size: 13px;
font-weight: 500;
display: flex;
align-items: center;
justify-content: space-between;
}
.role-card.selected { border-color: var(--text); background: var(--bg); }
.access-card {
padding: 12px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
margin-top: 8px;
}
.ac-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; }
.ac-list { display: flex; flex-direction: column; gap: 6px; }
.ac-row {
display: flex;
align-items: center;
gap: 10px;
font-size: 13px;
}
.ac-row :deep(svg) { color: var(--ok); }
.cust-swatch { width: 14px; height: 14px; border-radius: 3px; flex-shrink: 0; }
.cust-name { flex: 1; }
.perm-list { display: flex; flex-direction: column; gap: 4px; margin-top: 8px; }
.perm-row {
display: flex;
align-items: center;
gap: 10px;
padding: 6px 8px;
font-size: 13px;
}
.perm-row :deep(svg) { color: var(--ok); }
.perm-row .muted { color: var(--text-mute); }
.perm-row :deep(svg.muted) { color: var(--text-mute); }
.activity-list { display: flex; flex-direction: column; gap: 8px; }
.activity-row {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 12px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
}
.activity-icon {
width: 26px;
height: 26px;
border-radius: 999px;
background: var(--bg);
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--text-mute);
flex-shrink: 0;
}
.activity-meta { flex: 1; min-width: 0; }
.ar-top { display: flex; gap: 8px; align-items: baseline; flex-wrap: wrap; }
.ar-top span { font-size: 13px; }
.sec-row {
padding: 12px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
display: flex;
align-items: center;
gap: 10px;
}
.sec-row :deep(svg) { color: var(--text-mute); flex-shrink: 0; }
.sec-meta { flex: 1; }
.sec-label { font-size: 13px; font-weight: 500; }
.danger-callout {
margin-top: 8px;
padding: 14px;
background: rgba(226, 48, 48, 0.06);
border: 1px solid rgba(226, 48, 48, 0.22);
border-radius: 6px;
display: flex;
gap: 10px;
align-items: flex-start;
}
.danger-callout :deep(svg) { color: var(--bad); margin-top: 2px; flex-shrink: 0; }
.dc-meta { flex: 1; }
.dc-label { font-size: 13px; font-weight: 600; color: var(--bad); }
.dc-meta p { font-size: 12px; color: var(--text-dim); line-height: 1.5; margin: 4px 0 0; }
</style>
+17
View File
@@ -0,0 +1,17 @@
// Shared open state for the waffle app launcher. The launcher is mounted once
// in the default layout; the topbar trigger opens it.
const open = ref(false)
export const useAppLauncher = () => ({
open,
toggle: () => {
open.value = !open.value
},
show: () => {
open.value = true
},
hide: () => {
open.value = false
},
})
+51
View File
@@ -0,0 +1,51 @@
// Cached fetch of the signed-in user's profile from platform-api (proxied
// via /api/me on the portal server). One state slot per render so all
// callers share the same payload — middleware fetches once, pages read
// from cache, no per-component re-fetch.
interface MeProfile {
_id: string
authentikSubjectId: string
email: string
name: string
role: string
active: boolean
platformAdmin: boolean
tenantIds: string[]
partnerId?: string
partner?: { _id: string; slug: string; name: string; status: string }
lastLoginAt?: string
}
interface MeResponse {
profile: MeProfile
tenants: unknown[]
subscriptions: unknown[]
}
export function useMe() {
const state = useState<MeResponse | null>('portal-me', () => null)
async function fetchMe(force = false): Promise<MeResponse | null> {
if (state.value && !force) return state.value
try {
// useRequestFetch on SSR forwards the incoming request's headers
// (including the nuxt-oidc-auth session cookie) when calling the
// Nitro route. Bare $fetch on SSR has no cookie context, so /api/me
// would 401, the middleware would skip the redirect, and the end-user
// page would flash before client-side rehydration finally redirects.
const fetcher = useRequestFetch()
state.value = await fetcher<MeResponse>('/api/me')
} catch {
state.value = null
}
return state.value
}
const profile = computed<MeProfile | null>(() => state.value?.profile ?? null)
const partner = computed(() => profile.value?.partner ?? null)
const isPartnerStaff = computed(() => !!profile.value?.partnerId)
const isPlatformAdmin = computed(() => !!profile.value?.platformAdmin)
return { state, profile, partner, isPartnerStaff, isPlatformAdmin, fetchMe }
}
@@ -0,0 +1,29 @@
// Notification drawer open state + unread count. The drawer is mounted once
// in the default layout; the topbar bell button toggles it.
import { notifications as fixture } from '~/data/notifications'
const open = ref(false)
const items = ref(fixture)
export const useNotificationDrawer = () => {
const unreadCount = computed(() => items.value.filter((n) => !n.read).length)
return {
open,
items,
unreadCount,
show: () => {
open.value = true
},
hide: () => {
open.value = false
},
toggle: () => {
open.value = !open.value
},
markAllRead: () => {
items.value = items.value.map((n) => ({ ...n, read: true }))
},
}
}
+41
View File
@@ -0,0 +1,41 @@
// Partner admin's "acting as a customer admin" state. When a partner clicks
// into a customer org, the sidebar reshapes to that customer's admin nav and
// a persistent banner indicates the partner context.
//
// In real use, every action while in this mode is logged with the partner's
// identity (not the customer's) — the design spec is explicit about this for
// trust. For the prototype we just hold the customer id.
import type { CustomerOrg } from '~/data/customers'
const activeCustomerId = ref<string | null>(null)
export const usePartnerMode = () => {
function enter(customerId: string) {
activeCustomerId.value = customerId
if (import.meta.client) {
sessionStorage.setItem('dezky-partner-active-customer', customerId)
}
}
function exit() {
activeCustomerId.value = null
if (import.meta.client) {
sessionStorage.removeItem('dezky-partner-active-customer')
}
}
function hydrate() {
if (!import.meta.client || activeCustomerId.value) return
const stored = sessionStorage.getItem('dezky-partner-active-customer')
if (stored) activeCustomerId.value = stored
}
return {
activeCustomerId,
isActive: computed(() => activeCustomerId.value !== null),
enter,
exit,
hydrate,
setCustomer: (c: CustomerOrg | null) => {
activeCustomerId.value = c?.id ?? null
},
}
}
@@ -0,0 +1,88 @@
// Cosmetic + role tweaks for the portal shell. Persisted in localStorage so
// reloads stay coherent during prototyping. Applied to <html> as data-*
// attributes; tokens.css picks them up via selector overrides.
//
// `role` is the most important tweak — it switches which sidebar nav + which
// pages are visible (end user / customer admin / partner admin). For real use
// the role would come from Authentik group claims; the tweak lets us preview
// all three views without standing up three orgs.
export type ThemeMode = 'dark' | 'light'
export type Density = 'comfy' | 'compact'
export type Accent = 'signal' | 'cobalt' | 'coral' | 'moss'
export type PortalRole = 'end-user' | 'customer-admin' | 'partner-admin'
interface TweakState {
theme: ThemeMode
density: Density
accent: Accent
role: PortalRole
}
const STORAGE_KEY = 'dezky-portal-tweaks'
const DEFAULTS: TweakState = {
theme: 'light',
density: 'comfy',
accent: 'signal',
role: 'customer-admin',
}
const state = ref<TweakState>({ ...DEFAULTS })
const hydrated = ref(false)
function apply() {
if (!import.meta.client) return
const root = document.documentElement
root.setAttribute('data-theme', state.value.theme)
root.setAttribute('data-density', state.value.density)
root.setAttribute('data-accent', state.value.accent)
root.setAttribute('data-role', state.value.role)
}
function persist() {
if (!import.meta.client) return
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(state.value))
} catch {
// localStorage can throw in private mode; tweaks are cosmetic so swallow.
}
}
function hydrate() {
if (!import.meta.client || hydrated.value) return
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (raw) {
const parsed = JSON.parse(raw) as Partial<TweakState>
state.value = {
theme: parsed.theme ?? DEFAULTS.theme,
density: parsed.density ?? DEFAULTS.density,
accent: parsed.accent ?? DEFAULTS.accent,
role: parsed.role ?? DEFAULTS.role,
}
}
} catch {
// ignore corrupt JSON
}
apply()
hydrated.value = true
}
export const usePortalTweaks = () => {
if (import.meta.client) hydrate()
function set<K extends keyof TweakState>(key: K, value: TweakState[K]) {
state.value = { ...state.value, [key]: value }
apply()
persist()
}
return {
state,
setTheme: (v: ThemeMode) => set('theme', v),
setDensity: (v: Density) => set('density', v),
setAccent: (v: Accent) => set('accent', v),
setRole: (v: PortalRole) => set('role', v),
}
}
+11
View File
@@ -0,0 +1,11 @@
// Shared sidebar collapse state. Used by layouts/default.vue and the layout's
// keyboard shortcut handler so ⌘[ from anywhere flips the same thing.
const collapsed = ref(false)
export const useSidebar = () => ({
collapsed,
toggle: () => {
collapsed.value = !collapsed.value
},
})
+37
View File
@@ -0,0 +1,37 @@
// Lightweight toast stack. Used by buttons/actions that want to confirm
// they fired. Rendered by components/ToastStack.vue in the default layout.
export type ToastTone = 'info' | 'ok' | 'warn' | 'bad'
export interface Toast {
id: number
tone: ToastTone
message: string
hint?: string
}
const toasts = ref<Toast[]>([])
let counter = 0
export const useToast = () => {
function push(tone: ToastTone, message: string, hint?: string) {
const id = ++counter
toasts.value = [...toasts.value, { id, tone, message, hint }]
const ttl = tone === 'bad' ? 7000 : 4000
setTimeout(() => {
toasts.value = toasts.value.filter((t) => t.id !== id)
}, ttl)
}
function dismiss(id: number) {
toasts.value = toasts.value.filter((t) => t.id !== id)
}
return {
toasts,
push,
info: (m: string, h?: string) => push('info', m, h),
ok: (m: string, h?: string) => push('ok', m, h),
warn: (m: string, h?: string) => push('warn', m, h),
bad: (m: string, h?: string) => push('bad', m, h),
dismiss,
}
}
+100
View File
@@ -0,0 +1,100 @@
// Partner-admin portfolio fixtures. The partner (NordicMSP) manages 8 customer
// orgs. Numbers seeded to match partner-screens.jsx (the canonical design
// source) line for line: same customer set, same MRR, seats, status, mark.
export type CustomerStatus = 'healthy' | 'attention' | 'past_due' | 'trial' | 'suspended'
export interface CustomerOrg {
id: string
name: string
domain: string
plan: 'starter' | 'business' | 'enterprise'
planLabel: 'Starter' | 'Business' | 'Enterprise'
seats: { used: number; total: number }
health: number
status: CustomerStatus
mrrDkk: number
brandColor: string
industry: string
createdOn: string
since: string
}
export const partner = {
id: 'p-nordicmsp',
name: 'NordicMSP',
domain: 'nordicmsp.dk',
contact: 'Anne Baslund',
email: 'partners@nordicmsp.dk',
marginPct: 20,
customers: 8,
brandColor: '#3F6BFF',
founded: '2024',
}
// Customer set mirrors partner-screens.jsx line 16-25 exactly.
// Health values derived from status + seat utilization (lower for past-due / attention).
export const customers: CustomerOrg[] = [
{ id: 'c-acme', name: 'Acme Workspace', domain: 'acme.dk', plan: 'business', planLabel: 'Business', seats: { used: 24, total: 50 }, health: 88, status: 'healthy', mrrDkk: 4840, brandColor: '#3F6BFF', industry: 'SaaS', createdOn: '2026-02-04', since: 'Feb 2026' },
{ id: 'c-bygherre', name: 'Bygherre Cloud', domain: 'bygherre.dk', plan: 'business', planLabel: 'Business', seats: { used: 12, total: 15 }, health: 38, status: 'past_due', mrrDkk: 2940, brandColor: '#E89A1F', industry: 'Construction', createdOn: '2026-03-12', since: 'Mar 2026' },
{ id: 'c-vester', name: 'Vester Foods', domain: 'vesterfoods.dk', plan: 'starter', planLabel: 'Starter', seats: { used: 8, total: 10 }, health: 82, status: 'healthy', mrrDkk: 980, brandColor: '#5B8C5A', industry: 'Food', createdOn: '2026-04-08', since: 'Apr 2026' },
{ id: 'c-aalborg', name: 'Aalborg Logistik', domain: 'aalborg-log.dk', plan: 'enterprise', planLabel: 'Enterprise', seats: { used: 87, total: 100 }, health: 78, status: 'healthy', mrrDkk: 14500, brandColor: '#0A0A0A', industry: 'Logistics', createdOn: '2025-09-04', since: 'Sep 2025' },
{ id: 'c-norrebro', name: 'Nørrebro Studio', domain: 'nbstudio.dk', plan: 'business', planLabel: 'Business', seats: { used: 6, total: 15 }, health: 68, status: 'trial', mrrDkk: 0, brandColor: '#FF6B4A', industry: 'Creative', createdOn: '2026-05-12', since: '12 May 2026' },
{ id: 'c-vsk', name: 'Vestsjælland Kommune', domain: 'vsk.dk', plan: 'enterprise', planLabel: 'Enterprise', seats: { used: 142, total: 200 }, health: 91, status: 'healthy', mrrDkk: 28400, brandColor: '#5B3F7A', industry: 'Public sector', createdOn: '2024-11-20', since: 'Nov 2024' },
{ id: 'c-broson', name: 'Bro & Søn ApS', domain: 'broson.dk', plan: 'starter', planLabel: 'Starter', seats: { used: 4, total: 10 }, health: 86, status: 'healthy', mrrDkk: 490, brandColor: '#3D3D38', industry: 'Retail', createdOn: '2025-06-15', since: 'Jun 2025' },
{ id: 'c-henriksen', name: 'Henriksen Revision', domain: 'h-revision.dk', plan: 'business', planLabel: 'Business', seats: { used: 18, total: 25 }, health: 58, status: 'attention', mrrDkk: 3600, brandColor: '#B85C38', industry: 'Accounting', createdOn: '2026-01-08', since: 'Jan 2026' },
]
// PARTNER_TEAM mirrors partner-screens.jsx line 27-32.
export const partnerTeam = [
{ id: 'pt-anne', name: 'Anne Baslund', email: 'anne@nordicmsp.dk', role: 'Partner admin', access: 'all', mfa: 'totp', lastSeen: '2 min ago' },
{ id: 'pt-mikkel', name: 'Mikkel Nørgaard', email: 'mikkel@nordicmsp.dk', role: 'Sales', access: 'specific', mfa: 'totp', lastSeen: '12 min ago' },
{ id: 'pt-sofie', name: 'Sofie Lindberg', email: 'sofie@nordicmsp.dk', role: 'Support', access: 'all', mfa: 'webauthn', lastSeen: '1 h ago' },
{ id: 'pt-oliver', name: 'Oliver Schmidt', email: 'oliver@nordicmsp.dk', role: 'Support', access: 'specific', mfa: 'totp', lastSeen: '4 h ago' },
]
// Customer invoices · partner-screens.jsx line 770-776.
export const partnerInvoices = [
{ id: 'INV-0521', customer: 'Acme Workspace', number: 'INV-0521', date: '01 May 2026', amount: 4840, status: 'paid' },
{ id: 'INV-0522', customer: 'Bygherre Cloud', number: 'INV-0522', date: '01 May 2026', amount: 2940, status: 'past_due' },
{ id: 'INV-0523', customer: 'Vester Foods', number: 'INV-0523', date: '01 May 2026', amount: 980, status: 'paid' },
{ id: 'INV-0524', customer: 'Aalborg Logistik', number: 'INV-0524', date: '01 May 2026', amount: 14500, status: 'paid' },
{ id: 'INV-0525', customer: 'Vestsjælland K.', number: 'INV-0525', date: '01 May 2026', amount: 28400, status: 'paid' },
{ id: 'INV-0526', customer: 'Bro & Søn ApS', number: 'INV-0526', date: '01 May 2026', amount: 490, status: 'paid' },
{ id: 'INV-0527', customer: 'Henriksen Revision', number: 'INV-0527', date: '01 May 2026', amount: 3600, status: 'sent' },
]
// 90-day MRR sparkline · matches the synthetic generator at partner-screens.jsx:198.
// Deterministic seeded values (no Math.random calls each render).
export const partnerMrrSparkline = [
38580, 38980, 39520, 40180, 40720, 41080, 41420, 41820, 42100, 42460,
42820, 43240, 43620, 43980, 44320, 44660, 44960, 45280, 45680, 46020,
46300, 46580, 46880, 47220, 47540, 47820, 48160, 48420, 48760, 49080,
49420, 49680, 50020, 50360, 50640, 50920, 51180, 51460, 51720, 51980,
52220, 52480, 52720, 52960, 53180, 53420, 53620, 53860, 54080, 54260,
54440, 54620, 54780, 54940, 55080, 55180, 55280, 55360, 55440, 55510,
55570, 55610, 55650, 55670, 55690, 55700, 55710, 55720, 55730, 55740,
55745, 55748, 55750, 55750, 55750, 55750, 55750, 55750, 55750, 55750,
55750, 55750, 55750, 55750, 55750, 55750, 55750, 55750, 55750, 55750,
]
// Recent partner activity · partner-screens.jsx line 332-336.
export const partnerActivity = [
{ id: 'pa-1', when: '14:02', cust: 'Acme Workspace', who: 'Anne Baslund', action: 'invited 3 users', tone: 'info' as const },
{ id: 'pa-2', when: '12:18', cust: 'Bygherre Cloud', who: 'system', action: 'invoice marked past-due', tone: 'bad' as const },
{ id: 'pa-3', when: '11:44', cust: 'Aalborg Logistik', who: 'Sofie Lindberg', action: 'upgraded to Enterprise', tone: 'ok' as const },
{ id: 'pa-4', when: '10:08', cust: 'Nørrebro Studio', who: 'NordicMSP', action: 'created new customer org', tone: 'info' as const },
{ id: 'pa-5', when: '09:34', cust: 'Henriksen Revision', who: 'system', action: 'DNS health alert · SPF', tone: 'warn' as const },
]
// Partner audit · platform-partner-depth.jsx line 8-17.
export const partnerAudit = [
{ id: 'pa1', when: '15:02', actor: 'Anne Baslund', customer: 'Acme Workspace', action: 'user.invited', target: 'magnus@acme.dk', tone: 'info' as const },
{ id: 'pa2', when: '13:48', actor: 'Mikkel Nørgaard', customer: 'Bygherre Cloud', action: 'billing.followup_sent', target: 'INV-0522 · past-due', tone: 'warn' as const },
{ id: 'pa3', when: '11:21', actor: 'Anne Baslund', customer: 'Acme Workspace', action: 'partner.entered_customer', target: 'reason: Q2 review', tone: 'info' as const },
{ id: 'pa4', when: '10:08', actor: 'Sofie Lindberg', customer: 'Aalborg Logistik', action: 'tenant.plan_changed', target: 'Business → Enterprise', tone: 'ok' as const },
{ id: 'pa5', when: '09:44', actor: 'Anne Baslund', customer: '—', action: 'partner.customer_created', target: 'Lyngby Bilcenter', tone: 'info' as const },
{ id: 'pa6', when: 'Yest', actor: 'Sofie Lindberg', customer: 'Henriksen Revision', action: 'support.ticket_created', target: 'DNS SPF missing', tone: 'warn' as const },
{ id: 'pa7', when: 'Yest', actor: 'Anne Baslund', customer: 'Acme Workspace', action: 'branding.published', target: 'accent #3F6BFF', tone: 'info' as const },
{ id: 'pa8', when: '2 d', actor: 'Mikkel Nørgaard', customer: 'Vester Foods', action: 'tenant.exit', target: 'session: 14 min', tone: 'info' as const },
]
+108
View File
@@ -0,0 +1,108 @@
// Fixtures for the end-user dashboard, profile, devices, security, etc.
export const currentUser = {
name: 'Anne Hansen',
email: 'anne@baslund.dk',
role: 'Customer admin',
title: 'CEO',
department: 'Leadership',
manager: '—',
timezone: 'Europe/Copenhagen',
language: 'da-DK',
joined: '2024-09-12',
}
// App tiles for the end-user dashboard. Source design has 4 fixed tiles with
// a short monospace badge line below the name (no separate hrefs row).
export const appTiles = [
{ key: 'mail', name: 'Mail', badge: '12 unread' },
{ key: 'drev', name: 'Drev', badge: '5 shared' },
{ key: 'moder', name: 'Møder', badge: '3 today' },
{ key: 'chat', name: 'Chat', badge: '2 mentions' },
]
// Single-time meeting rows. Source design uses a single time + a relative
// "in X" countdown + a "with" line.
export const todayAgenda = [
{ id: 't-1', time: '10:30', title: 'Sprint planning', with: 'Engineering · 6 people', in: '14 min' },
{ id: 't-2', time: '13:00', title: 'Customer demo · Novo', with: 'Frederik + Anne', in: '2 h 44 min' },
{ id: 't-3', time: '15:30', title: '1:1 with Mikkel', with: 'Mikkel Nørgaard', in: '5 h 14 min' },
]
export const recentFiles = [
{ id: 'f-1', name: 'Q3 board deck.key', path: 'Drev · /Board', updated: '12 min ago', size: '24 MB' },
{ id: 'f-2', name: 'Pricing v3.xlsx', path: 'Drev · /Finance', updated: '1 h ago', size: '482 KB' },
{ id: 'f-3', name: 'Brand guide.pdf', path: 'Drev · /Brand', updated: '3 h ago', size: '8.1 MB' },
{ id: 'f-4', name: 'Customer interviews.docx', path: 'Drev · /Research', updated: 'Yesterday', size: '120 KB' },
{ id: 'f-5', name: 'Roadmap 2026.fig', path: 'Drev · /Design', updated: '2 d ago', size: '34 MB' },
]
// 6 hardcoded pending items — source design's exact list. Tone determines
// the icon-tint + whether the CTA is primary (only 'bad' gets primary).
export const needsAttention = [
{ id: 't1', icon: 'shield', tone: 'bad', title: 'Complete MFA enrolment', hint: 'overdue · required by 1 Jun', cta: 'Set up', target: 'security' },
{ id: 't2', icon: 'chat', tone: 'info', title: 'Sofie mentioned you in #design', hint: '2 h ago', cta: 'Reply', target: 'chat' },
{ id: 't3', icon: 'folder', tone: 'info', title: 'Frederik shared "Q3 forecast.xlsx"', hint: 'yesterday', cta: 'Review', target: 'file' },
{ id: 't4', icon: 'card', tone: 'warn', title: 'Approve expense report · 1.840 DKK', hint: '3 days', cta: 'Approve', target: 'expense' },
{ id: 't5', icon: 'users', tone: 'info', title: 'Schedule 1:1 with Mikkel', hint: 'overdue by 5 days', cta: 'Schedule', target: 'meeting' },
{ id: 't6', icon: 'file', tone: 'info', title: 'Take Q2 customer feedback survey', hint: 'open until Friday', cta: 'Open', target: 'survey' },
] as const
// Devices match source `DEVICES` from platform-enduser.jsx · grouped into
// laptop / phone / tablet ("desktop" in source maps to our `laptop` kind).
export const devices = [
{ id: 'd1', label: 'MacBook Pro · 14"', kind: 'laptop', os: 'macOS 14.4', app: 'Chrome 132', location: 'Copenhagen, DK', ip: '92.43.118.4', lastActive: 'now', current: true, trusted: false, stale: false },
{ id: 'd2', label: 'iPhone 15 Pro', kind: 'phone', os: 'iOS 18.1', app: 'dezky Mail · 2.4', location: 'Copenhagen, DK', ip: '92.43.118.4', lastActive: '14 min ago', current: false, trusted: false, stale: false },
{ id: 'd3', label: 'iPhone 15 Pro', kind: 'phone', os: 'iOS 18.1', app: 'dezky Drev · 2.1', location: 'Copenhagen, DK', ip: '92.43.118.4', lastActive: '14 min ago', current: false, trusted: false, stale: false },
{ id: 'd4', label: 'MacBook Air · 13"', kind: 'laptop', os: 'macOS 14.4', app: 'Safari 17.4', location: 'Aarhus, DK', ip: '78.110.4.92', lastActive: '2 d ago', current: false, trusted: false, stale: false },
{ id: 'd5', label: 'iPad Pro · 11"', kind: 'tablet', os: 'iPadOS 18', app: 'dezky · web', location: 'Copenhagen, DK', ip: '92.43.118.4', lastActive: '5 d ago', current: false, trusted: false, stale: false },
{ id: 'd6', label: 'Linux workstation', kind: 'laptop', os: 'Ubuntu 24.04', app: 'Firefox 134', location: 'Copenhagen, DK', ip: '10.0.4.18', lastActive: '11 d ago', current: false, trusted: false, stale: true },
]
// MFA methods mirror source `MFA_METHODS`. Source has TouchID as primary
// (first webauthn), YubiKey, and 1Password TOTP.
export const mfaMethods = [
{ id: 'm1', kind: 'webauthn' as const, label: 'MacBook Pro · Touch ID', enrolledOn: '14 Jan 2026', lastUsed: '2 min ago', primary: true },
{ id: 'm2', kind: 'webauthn' as const, label: 'YubiKey 5C · work', enrolledOn: '14 Jan 2026', lastUsed: '3 d ago', primary: false },
{ id: 'm3', kind: 'totp' as const, label: '1Password', enrolledOn: '02 Feb 2026', lastUsed: '4 d ago', primary: false },
]
export const recoveryCodes = [
'whkz-86p4', 'jt2m-c98n', 'q4dx-r58y', 'b1nv-7gpx',
'fm8w-22qr', 'k5xt-pa9c', 'd91j-8mwz', 'rv6t-3hyn',
'snk2-9b8d', '7zfp-q4mv',
]
// Sign-in history mirrors source `SIGNIN_HISTORY` — includes the failed
// attempt from 203.0.113.4 used for the warning callout.
export const signInHistory = [
{ id: 'si-1', when: '14:02 today', ip: '92.43.118.4', location: 'Copenhagen, DK', ua: 'Chrome 132 · macOS', method: 'webauthn', result: 'ok' },
{ id: 'si-2', when: '11:48 today', ip: '92.43.118.4', location: 'Copenhagen, DK', ua: 'dezky Mail · iOS', method: 'session', result: 'ok' },
{ id: 'si-3', when: '09:21 today', ip: '92.43.118.4', location: 'Copenhagen, DK', ua: 'Chrome 132 · macOS', method: 'webauthn', result: 'ok' },
{ id: 'si-4', when: 'Yesterday 22:14', ip: '78.110.4.92', location: 'Aarhus, DK', ua: 'Safari 17.4 · macOS', method: 'totp', result: 'ok' },
{ id: 'si-5', when: 'Yesterday 18:02', ip: '203.0.113.4', location: 'Unknown', ua: 'Chrome 132 · Windows', method: 'password', result: 'failed', reason: 'Wrong password · 3 attempts' },
{ id: 'si-6', when: '2 d ago 14:22', ip: '78.110.4.92', location: 'Aarhus, DK', ua: 'Safari 17.4 · macOS', method: 'webauthn', result: 'ok' },
{ id: 'si-7', when: '4 d ago 09:08', ip: '92.43.118.4', location: 'Copenhagen, DK', ua: 'Firefox 134 · Linux', method: 'totp', result: 'ok' },
]
// KB articles mirror source `KB_ARTICLES` exactly. `read` is the read-time
// string ("4 min"), `popular` flags the items rendered in the Popular row.
export const helpArticles = [
{ id: 'kb1', title: 'Setting up MFA for your workspace', category: 'Getting started', read: '4 min', popular: true },
{ id: 'kb2', title: 'Migrating from Microsoft 365 to Dezky', category: 'Migration', read: '12 min', popular: true },
{ id: 'kb3', title: 'Configuring SAML SSO for external apps', category: 'Identity', read: '8 min', popular: false },
{ id: 'kb4', title: 'Custom domain (CNAME) setup', category: 'Branding', read: '6 min', popular: false },
{ id: 'kb5', title: 'Understanding storage quotas', category: 'Drev', read: '5 min', popular: false },
{ id: 'kb6', title: 'OIOUBL invoicing for Danish customers', category: 'Billing', read: '7 min', popular: false },
{ id: 'kb7', title: 'API tokens and webhooks', category: 'Developers', read: '10 min', popular: false },
{ id: 'kb8', title: 'GDPR data export requests', category: 'Compliance', read: '5 min', popular: false },
]
// Tickets mirror source `TICKETS`. Status uses the source's three-bucket
// vocabulary ("awaiting customer" / "in progress" / "resolved").
export const myTickets = [
{ id: 'TKT-2841', title: 'Cannot share folder externally', status: 'awaiting customer', severity: 'P3', age: '2 d', last: '2 h ago', updates: 4 },
{ id: 'TKT-2832', title: 'Mobile app missing meeting recordings', status: 'in progress', severity: 'P3', age: '5 d', last: '1 d ago', updates: 8 },
{ id: 'TKT-2701', title: 'How do I configure SAML for Notion?', status: 'resolved', severity: 'P3', age: '12 d', last: '8 d ago', updates: 6 },
{ id: 'TKT-2650', title: 'Bulk export users to Active Directory', status: 'resolved', severity: 'P3', age: '18 d', last: '14 d ago', updates: 11 },
]
+63
View File
@@ -0,0 +1,63 @@
// Mock notifications shown in the topbar drawer. Tones map to icon tints.
export type NotifTone = 'info' | 'ok' | 'warn' | 'bad'
export interface PortalNotification {
id: string
tone: NotifTone
title: string
body: string
when: string
read: boolean
}
export const notifications: PortalNotification[] = [
{
id: 'n-1',
tone: 'warn',
title: 'MFA reminder',
body: 'Set up TOTP or a hardware key before 31 May.',
when: '12 min ago',
read: false,
},
{
id: 'n-2',
tone: 'info',
title: 'Sofie mentioned you',
body: 'In #design — “Did you see the new Stat card?”',
when: '38 min ago',
read: false,
},
{
id: 'n-3',
tone: 'ok',
title: 'Invoice paid',
body: 'May invoice 2026-001247 · 1.940 DKK · MobilePay',
when: '2 hours ago',
read: false,
},
{
id: 'n-4',
tone: 'info',
title: 'Frederik shared a file',
body: 'Q3 forecast.xlsx · in Drev / Finance',
when: '4 hours ago',
read: true,
},
{
id: 'n-5',
tone: 'warn',
title: 'DMARC record missing',
body: 'baslund.dk · add the recommended policy in Domains',
when: 'Yesterday',
read: true,
},
{
id: 'n-6',
tone: 'info',
title: 'Mikkel updated retention',
body: 'Mail retention policy changed to 7 years',
when: '2 days ago',
read: true,
},
]
+256
View File
@@ -0,0 +1,256 @@
// The "current workspace" for prototype purposes. Replaced by platform-api
// lookup in production. Holds the brand identity that the BrandingScreen
// lives in front of.
export interface Workspace {
id: string
name: string
productName: string
domain: string
plan: 'starter' | 'business' | 'enterprise'
seats: { used: number; total: number }
storage: { usedGb: number; totalGb: number }
mailFlow: { deliveredPct: number; trend: 'up' | 'down' | 'flat' }
monthlySpendDkk: number
status: 'active' | 'trial' | 'past_due' | 'suspended'
}
export const workspace: Workspace = {
id: 'ws-baslund',
name: 'Baslund',
productName: 'Baslund Workspace',
domain: 'baslund.dk',
plan: 'business',
seats: { used: 11, total: 25 },
storage: { usedGb: 142, totalGb: 500 },
mailFlow: { deliveredPct: 99.4, trend: 'up' },
monthlySpendDkk: 1940,
status: 'active',
}
export const sampleUsers = [
{ id: 'u-1', name: 'Anne Hansen', email: 'anne@baslund.dk', role: 'owner', groups: ['Leadership', 'Finance'], mfa: 'totp', lastLogin: '2 min ago', status: 'active', storageMb: 8200, license: 'business' },
{ id: 'u-2', name: 'Mikkel Sørensen', email: 'mikkel@baslund.dk', role: 'admin', groups: ['Engineering'], mfa: 'webauthn', lastLogin: '14 min ago', status: 'active', storageMb: 14210, license: 'business' },
{ id: 'u-3', name: 'Sofie Lund', email: 'sofie@baslund.dk', role: 'admin', groups: ['Design'], mfa: 'totp', lastLogin: '1 hour ago', status: 'active', storageMb: 6804, license: 'business' },
{ id: 'u-4', name: 'Frederik Holm', email: 'frederik@baslund.dk', role: 'user', groups: ['Finance'], mfa: 'totp', lastLogin: '3 hours ago', status: 'active', storageMb: 2401, license: 'business' },
{ id: 'u-5', name: 'Caroline Bjerg', email: 'caroline@baslund.dk', role: 'user', groups: ['Sales'], mfa: 'none', lastLogin: 'Yesterday', status: 'active', storageMb: 1880, license: 'business' },
{ id: 'u-6', name: 'Johan Olesen', email: 'johan@baslund.dk', role: 'user', groups: ['Engineering'], mfa: 'webauthn', lastLogin: '2 days ago', status: 'active', storageMb: 4250, license: 'business' },
{ id: 'u-7', name: 'Maria Petersen', email: 'maria@baslund.dk', role: 'user', groups: ['Design'], mfa: 'totp', lastLogin: '5 days ago', status: 'active', storageMb: 3120, license: 'business' },
{ id: 'u-8', name: 'Henrik Schmidt', email: 'henrik@baslund.dk', role: 'user', groups: ['Engineering'], mfa: 'totp', lastLogin: '1 week ago', status: 'active', storageMb: 9540, license: 'business' },
{ id: 'u-9', name: 'Trine Madsen', email: 'trine@baslund.dk', role: 'user', groups: ['Sales'], mfa: 'none', lastLogin: 'never', status: 'invited', storageMb: 0, license: 'business' },
{ id: 'u-10', name: 'Lars Engelbrecht', email: 'lars@baslund.dk', role: 'user', groups: ['Engineering'], mfa: 'totp', lastLogin: '12 days ago', status: 'suspended', storageMb: 5102, license: 'business' },
{ id: 'u-11', name: 'Bo Christensen', email: 'bo@baslund.dk', role: 'user', groups: ['Sales'], mfa: 'totp', lastLogin: '3 hours ago', status: 'active', storageMb: 1242, license: 'starter' },
]
export const sampleGroups = [
{ id: 'g-1', name: 'Leadership', description: 'Owners + admins', members: 3, owner: 'Anne Hansen', resources: ['leadership@', 'Drev/Leadership'] },
{ id: 'g-2', name: 'Engineering', description: 'Backend, frontend, infrastructure', members: 4, owner: 'Mikkel Sørensen', resources: ['eng@', 'Drev/Engineering', '#eng'] },
{ id: 'g-3', name: 'Design', description: 'Brand, product, marketing visuals', members: 2, owner: 'Sofie Lund', resources: ['design@', 'Drev/Design', '#design'] },
{ id: 'g-4', name: 'Finance', description: 'Bookkeeping, AP/AR, payroll', members: 2, owner: 'Frederik Holm', resources: ['finance@', 'Drev/Finance'] },
{ id: 'g-5', name: 'Sales', description: 'Outbound + customer success', members: 3, owner: 'Bo Christensen', resources: ['sales@', 'Drev/Sales', '#sales'] },
]
export const sampleDomains = [
{
id: 'd-1',
domain: 'baslund.dk',
primary: true,
status: 'partial',
records: [
{ type: 'MX', status: 'ok', value: '10 mail.dezky.com' },
{ type: 'SPF', status: 'warn', value: 'v=spf1 ~all', expected: 'v=spf1 include:_spf.dezky.com ~all' },
{ type: 'DKIM', status: 'ok', value: 'k=rsa; p=MIGfMA0GCSq...' },
{ type: 'DMARC', status: 'bad', value: '— not found —', expected: 'v=DMARC1; p=quarantine; rua=mailto:dmarc@baslund.dk' },
],
addedOn: '2025-12-04',
},
{
id: 'd-2',
domain: 'baslund.shop',
primary: false,
status: 'healthy',
records: [
{ type: 'MX', status: 'ok', value: '10 mail.dezky.com' },
{ type: 'SPF', status: 'ok', value: 'v=spf1 include:_spf.dezky.com ~all' },
{ type: 'DKIM', status: 'ok', value: 'k=rsa; p=MIGfMA0G...' },
{ type: 'DMARC', status: 'ok', value: 'v=DMARC1; p=quarantine' },
],
addedOn: '2026-02-11',
},
{
id: 'd-3',
domain: 'baslund.io',
primary: false,
status: 'verifying',
records: [
{ type: 'TXT', status: 'warn', value: 'dezky-site-verification=…', hint: 'Awaiting propagation · ~10 min remaining' },
],
addedOn: '2026-05-22',
},
]
export const sampleInvoices = [
{ id: 'inv-2026-001247', date: '2026-05-01', period: '2026-05', amount: 1940, status: 'paid', method: 'MobilePay' },
{ id: 'inv-2026-001112', date: '2026-04-01', period: '2026-04', amount: 1940, status: 'paid', method: 'MobilePay' },
{ id: 'inv-2026-000984', date: '2026-03-01', period: '2026-03', amount: 1940, status: 'paid', method: 'MobilePay' },
{ id: 'inv-2026-000855', date: '2026-02-01', period: '2026-02', amount: 1940, status: 'paid', method: 'MobilePay' },
{ id: 'inv-2026-000721', date: '2026-01-01', period: '2026-01', amount: 1820, status: 'paid', method: 'Card · VISA 4242' },
]
// Audit events — strict port of project/platform-screens.jsx SAMPLE_AUDIT
// (line 29). Same 8 rows, same wording, same tones. The AdminDashboard slices
// the first 6, the SecurityScreen audit log uses all of them.
export const sampleAudit = [
{ id: 'a1', when: '14:02:11', actor: 'Anne Baslund', action: 'user.invited', target: 'magnus@dezky.com', ip: '92.43.118.4', tone: 'info' as const },
{ id: 'a2', when: '13:48:02', actor: 'Mikkel Nørgaard', action: 'billing.plan_changed', target: 'Team → Business', ip: '92.43.118.4', tone: 'info' as const },
{ id: 'a3', when: '13:21:55', actor: 'system', action: 'domain.dns_check', target: 'baslund.dk', ip: '—', tone: 'warn' as const },
{ id: 'a4', when: '12:09:30', actor: 'Sofie Lindberg', action: 'user.suspended', target: 'tina@dezky.com', ip: '78.110.4.92', tone: 'warn' as const },
{ id: 'a5', when: '11:44:00', actor: 'Anne Baslund', action: 'mfa.policy_updated', target: 'require for admins', ip: '92.43.118.4', tone: 'info' as const },
{ id: 'a6', when: '10:12:08', actor: 'Lars Holm', action: 'session.terminated', target: 'iPad — Safari', ip: '78.110.4.10', tone: 'info' as const },
{ id: 'a7', when: '09:55:41', actor: 'system', action: 'auth.failed_login', target: 'oliver@dezky.com', ip: '203.0.113.4', tone: 'bad' as const },
{ id: 'a8', when: '09:30:00', actor: 'Anne Baslund', action: 'branding.color_set', target: '#D4FF3A', ip: '92.43.118.4', tone: 'info' as const },
]
// Org-wide mail aliases — strict port of platform-admin.jsx ORG_ALIASES (line 9)
export const orgAliases = [
{ alias: 'info@dezky.com', dest: 'Distribution · Everyone', active: true, created: '14 Jan 2026' },
{ alias: 'sales@dezky.com', dest: 'frederik@dezky.com', active: true, created: '22 Jan 2026' },
{ alias: 'support@dezky.com', dest: 'sofie@dezky.com', active: true, created: '22 Jan 2026' },
{ alias: 'no-reply@dezky.com', dest: '(discard)', active: true, created: '14 Jan 2026' },
{ alias: 'careers@dezky.com', dest: 'anne@dezky.com', active: true, created: '04 Mar 2026' },
{ alias: 'legal@dezky.com', dest: 'mikkel@dezky.com', active: false, created: '12 Apr 2026' },
]
// Forwarding rules — strict port of platform-admin.jsx FORWARDING_RULES (line 18)
export const forwardingRules = [
{ name: 'Out-of-hours to on-call', match: 'support@dezky.com · 18:0008:00 CET', fwd: 'oncall@dezky.com', enabled: true },
{ name: 'Vendor invoices', match: 'subject: "invoice" · from: *@vendors', fwd: 'finance@dezky.com', enabled: true },
{ name: 'Legal threads', match: 'cc: legal@*', fwd: 'mikkel@dezky.com', enabled: false },
]
// Distribution lists — strict port of platform-admin.jsx DISTRIBUTION_LISTS (line 24)
export const distributionLists = [
{ name: 'Everyone', alias: 'everyone@dezky.com', members: 11, owner: 'Anne Baslund', moderation: 'open' as const, external: false },
{ name: 'Engineering', alias: 'eng@dezky.com', members: 4, owner: 'Anne Baslund', moderation: 'closed' as const, external: false },
{ name: 'Leadership', alias: 'leads@dezky.com', members: 3, owner: 'Anne Baslund', moderation: 'closed' as const, external: false },
{ name: 'Customers VIP', alias: 'vip-customers@dezky.com', members: 0, owner: 'Frederik Madsen', moderation: 'closed' as const, external: true },
]
// Anti-spam content filters — strict port of platform-admin.jsx ANTI_SPAM_FILTERS (line 31)
export const antiSpamFilters = [
{ name: 'Block executable attachments', match: 'attachment ext in (.exe, .scr, .bat, .cmd)', action: 'reject' as const, enabled: true },
{ name: 'Quarantine cryptocurrency mail', match: 'body contains "wallet address"', action: 'quarantine' as const, enabled: true },
{ name: 'Tag external mail', match: 'from outside @dezky.com', action: 'add tag' as const, enabled: true },
]
// Full groups list — strict port of platform-admin.jsx GROUPS_FULL (line 64)
export const groupsFull = [
{ id: 'g_eng', name: 'Engineering', alias: 'engineering@dezky.com', members: 4, description: 'Product engineering team', created: '14 Jan 2026', owner: 'Anne Baslund' },
{ id: 'g_des', name: 'Design', alias: 'design@dezky.com', members: 2, description: 'Brand and product designers', created: '02 Feb 2026', owner: 'Sofie Lindberg' },
{ id: 'g_ops', name: 'Operations', alias: 'ops@dezky.com', members: 2, description: 'Operations and on-call', created: '14 Jan 2026', owner: 'Mikkel Nørgaard' },
{ id: 'g_fin', name: 'Finance', alias: 'finance@dezky.com', members: 2, description: 'Finance and accounting', created: '22 Jan 2026', owner: 'Astrid Vinther' },
{ id: 'g_sales', name: 'Sales', alias: 'sales@dezky.com', members: 2, description: 'Customer sales', created: '14 Jan 2026', owner: 'Frederik Madsen' },
{ id: 'g_all', name: 'All-hands', alias: 'everyone@dezky.com', members: 11, description: 'Everyone in the workspace', created: '14 Jan 2026', owner: 'Anne Baslund' },
]
// Source-fidelity users (project/platform-screens.jsx SAMPLE_USERS line 8) —
// flat shape used by UsersScreen, BillingScreen seats math and StorageScreen
// "top users" list. Kept in sync with the visual mock — these have `last`,
// `group` (singular), `storage` (GB) rather than the multi-group `sampleUsers`
// above.
export const sampleUsersFlat = [
{ id: 'u_1ph2', name: 'Anne Baslund', email: 'anne@dezky.com', role: 'Owner', status: 'active', last: '2 min ago', group: 'Engineering', storage: 12.4 },
{ id: 'u_4kx9', name: 'Mikkel Nørgaard', email: 'mikkel@dezky.com', role: 'Admin', status: 'active', last: '14 min ago', group: 'Operations', storage: 8.1 },
{ id: 'u_88aw', name: 'Sofie Lindberg', email: 'sofie@dezky.com', role: 'Admin', status: 'active', last: '1 h ago', group: 'Finance', storage: 3.2 },
{ id: 'u_d12k', name: 'Lars Holm', email: 'lars@dezky.com', role: 'Member', status: 'active', last: '3 h ago', group: 'Engineering', storage: 22.7 },
{ id: 'u_9rqo', name: 'Emma Skov', email: 'emma@dezky.com', role: 'Member', status: 'invited', last: '—', group: 'Design', storage: 0 },
{ id: 'u_3vbn', name: 'Jonas Berg', email: 'jonas@dezky.com', role: 'Member', status: 'active', last: '2 d ago', group: 'Engineering', storage: 14.0 },
{ id: 'u_g51e', name: 'Tina Falkenberg', email: 'tina@dezky.com', role: 'Member', status: 'suspended', last: '11 d ago', group: 'Finance', storage: 5.6 },
{ id: 'u_kk7n', name: 'Oliver Schmidt', email: 'oliver@dezky.com', role: 'Member', status: 'active', last: '4 h ago', group: 'Design', storage: 9.3 },
{ id: 'u_pp01', name: 'Frederik Madsen', email: 'frederik@dezky.com', role: 'Member', status: 'active', last: 'Yesterday', group: 'Sales', storage: 6.8 },
{ id: 'u_qq22', name: 'Astrid Vinther', email: 'astrid@dezky.com', role: 'Admin', status: 'active', last: '32 min ago', group: 'Operations', storage: 4.1 },
{ id: 'u_rr44', name: 'Magnus Eriksen', email: 'magnus@dezky.com', role: 'Member', status: 'invited', last: '—', group: 'Engineering', storage: 0 },
{ id: 'u_tt55', name: 'Clara Bjerre', email: 'clara@dezky.com', role: 'Member', status: 'active', last: '5 d ago', group: 'Sales', storage: 2.0 },
]
// Source-fidelity domains (platform-screens.jsx SAMPLE_DOMAINS line 23) — flat
// shape with per-record-type status used by DomainsScreen / DomainCard.
export const sampleDomainsFlat = [
{ domain: 'dezky.com', status: 'ok' as const, mx: 'ok' as const, spf: 'ok' as const, dkim: 'ok' as const, dmarc: 'ok' as const, users: 11 },
{ domain: 'dezky.io', status: 'ok' as const, mx: 'ok' as const, spf: 'ok' as const, dkim: 'ok' as const, dmarc: 'warn' as const, users: 0 },
{ domain: 'baslund.dk', status: 'warn' as const, mx: 'ok' as const, spf: 'warn' as const, dkim: 'ok' as const, dmarc: 'bad' as const, users: 2 },
]
// Meeting rooms — strict port of platform-collab.jsx MEETING_ROOMS (line 8)
export const meetingRooms = [
{ id: 'r_eng', name: 'Engineering standup', alias: 'eng-standup', type: 'recurring' as const, when: 'Daily · 09:30', owner: 'Mikkel Nørgaard', members: 4, recording: 'auto' as const, protected: false },
{ id: 'r_lead', name: 'Leadership weekly', alias: 'leadership', type: 'recurring' as const, when: 'Mondays · 14:00', owner: 'Anne Baslund', members: 3, recording: 'manual' as const, protected: true },
{ id: 'r_allh', name: 'All-hands', alias: 'allhands', type: 'recurring' as const, when: '2nd Friday · 15:00', owner: 'Anne Baslund', members: 11, recording: 'auto' as const, protected: false },
{ id: 'r_inter', name: 'Interview room A', alias: 'interview-a', type: 'ad-hoc' as const, when: 'Persistent', owner: 'Sofie Lindberg', members: 2, recording: 'off' as const, protected: true },
{ id: 'r_cust', name: 'Customer demos', alias: 'demo', type: 'shared' as const, when: 'On-demand', owner: 'Frederik Madsen', members: 2, recording: 'manual' as const, protected: false },
{ id: 'r_focus', name: 'Focus · pair', alias: 'pair', type: 'ad-hoc' as const, when: 'Persistent', owner: 'Lars Holm', members: 1, recording: 'off' as const, protected: false },
]
// Meeting recordings — strict port of platform-collab.jsx MEETING_RECORDINGS (line 17)
export const meetingRecordings = [
{ id: 'rec1', title: 'All-hands · May edition', date: '12 May 2026', dur: '52 min', size: '184 MB', host: 'Anne Baslund', views: 11, retention: '365 d', legal: false },
{ id: 'rec2', title: 'Customer demo · Novo Holding', date: '11 May 2026', dur: '38 min', size: '142 MB', host: 'Frederik Madsen', views: 4, retention: '90 d', legal: false },
{ id: 'rec3', title: 'Engineering · Sprint review 24', date: '09 May 2026', dur: '46 min', size: '168 MB', host: 'Mikkel Nørgaard', views: 6, retention: '365 d', legal: false },
{ id: 'rec4', title: 'Board update · Q1 close', date: '02 May 2026', dur: '28 min', size: '102 MB', host: 'Anne Baslund', views: 3, retention: 'forever', legal: true },
{ id: 'rec5', title: 'New hire onboarding · Magnus', date: '29 Apr 2026', dur: '24 min', size: '88 MB', host: 'Sofie Lindberg', views: 2, retention: '90 d', legal: false },
{ id: 'rec6', title: 'Engineering · Sprint review 23', date: '25 Apr 2026', dur: '41 min', size: '154 MB', host: 'Mikkel Nørgaard', views: 5, retention: '365 d', legal: false },
{ id: 'rec7', title: 'Customer call · Aalborg Logistik', date: '22 Apr 2026', dur: '34 min', size: '128 MB', host: 'Frederik Madsen', views: 3, retention: '90 d', legal: false },
]
// Chat workspaces — strict port of platform-collab.jsx CHAT_WORKSPACES (line 27)
export const chatWorkspaces = [
{ id: 'cw_main', name: 'baslund · main', url: 'baslund.chat.dezky.com', members: 11, channels: 24, messages30d: 8420, status: 'active' as const, primary: true },
{ id: 'cw_eng', name: 'engineering', url: 'eng.chat.dezky.com', members: 4, channels: 11, messages30d: 12480, status: 'active' as const, primary: false },
]
// Chat channels — strict port of platform-collab.jsx CHAT_CHANNELS (line 32)
export const chatChannels = [
{ name: 'general', type: 'public' as const, members: 11, messages30d: 1840, owner: 'Anne Baslund', topic: 'Workspace-wide announcements' },
{ name: 'engineering', type: 'public' as const, members: 4, messages30d: 3220, owner: 'Mikkel Nørgaard', topic: 'Eng standups, deploys, code questions' },
{ name: 'design', type: 'public' as const, members: 2, messages30d: 480, owner: 'Sofie Lindberg', topic: 'Brand, product design, reviews' },
{ name: 'sales-customers', type: 'private' as const, members: 3, messages30d: 720, owner: 'Frederik Madsen', topic: 'Customer accounts and pipeline' },
{ name: 'random', type: 'public' as const, members: 9, messages30d: 1240, owner: 'Anne Baslund', topic: 'Off-topic' },
{ name: 'incidents', type: 'public' as const, members: 5, messages30d: 84, owner: 'Mikkel Nørgaard', topic: 'Service alerts and incident response' },
{ name: 'dezky-roadmap', type: 'private' as const, members: 4, messages30d: 410, owner: 'Anne Baslund', topic: 'Product planning, not for everyone' },
{ name: 'finance', type: 'private' as const, members: 2, messages30d: 96, owner: 'Astrid Vinther', topic: 'Invoicing, accounting' },
]
// Integrations marketplace — strict port of platform-collab.jsx INTEGRATIONS (line 43)
export interface Integration {
id: string
name: string
cat: 'Productivity' | 'Storage' | 'CRM' | 'Accounting' | 'Operations'
desc: string
connected: boolean
users?: number
kind: string
icon: string
color: string
accent: string
danish?: boolean
}
export const integrations: Integration[] = [
{ id: 'notion', name: 'Notion', cat: 'Productivity', desc: 'Docs and wikis. Sign in with dezky.', connected: true, users: 11, kind: 'SSO', icon: '◧', color: '#000000', accent: '#FFFFFF' },
{ id: 'figma', name: 'Figma', cat: 'Productivity', desc: 'Design files with single sign-on.', connected: true, users: 6, kind: 'SSO', icon: 'F', color: '#F24E1E', accent: '#FFFFFF' },
{ id: 'linear', name: 'Linear', cat: 'Productivity', desc: 'Issue tracking for engineering.', connected: true, users: 4, kind: 'SSO + provisioning', icon: 'L', color: '#5E6AD2', accent: '#FFFFFF' },
{ id: 'github', name: 'GitHub', cat: 'Productivity', desc: 'Source control. SAML enforcement.', connected: false, kind: 'SSO', icon: '⌬', color: '#181717', accent: '#FFFFFF' },
{ id: 'sketch', name: 'Sketch', cat: 'Productivity', desc: 'Design tool with team libraries.', connected: false, kind: 'SSO', icon: '◆', color: '#FDB300', accent: '#0A0A0A' },
{ id: 'gdrive', name: 'Google Drive', cat: 'Storage', desc: 'Mount Google Drive folders inside Drev.', connected: false, kind: 'OAuth', icon: 'G', color: '#1A73E8', accent: '#FFFFFF' },
{ id: 'dropbox', name: 'Dropbox', cat: 'Storage', desc: 'Mirror Dropbox into Drev. Read-only.', connected: false, kind: 'OAuth', icon: 'D', color: '#0061FF', accent: '#FFFFFF' },
{ id: 'hubspot', name: 'HubSpot', cat: 'CRM', desc: 'Customer pipeline + email sync.', connected: true, users: 3, kind: 'API', icon: 'H', color: '#FF7A59', accent: '#FFFFFF' },
{ id: 'pipedrive', name: 'Pipedrive', cat: 'CRM', desc: 'Sales CRM. Sign in with dezky.', connected: false, kind: 'SSO', icon: 'P', color: '#1A1A1A', accent: '#FFFFFF' },
{ id: 'economic', name: 'e-conomic', cat: 'Accounting', desc: 'Sync invoices to e-conomic. Danish CVR aware.', connected: true, users: 2, kind: 'API', icon: 'e', color: '#0091CD', accent: '#FFFFFF', danish: true },
{ id: 'billy', name: 'Billy', cat: 'Accounting', desc: 'Danish accounting platform. Auto-bookkeeping.', connected: false, kind: 'API', icon: 'B', color: '#0EBC81', accent: '#FFFFFF', danish: true },
{ id: 'dinero', name: 'Dinero', cat: 'Accounting', desc: 'Send invoices, sync ledger.', connected: false, kind: 'API', icon: 'D', color: '#FFCB05', accent: '#0A0A0A', danish: true },
{ id: 'pleo', name: 'Pleo', cat: 'Accounting', desc: 'Company cards. Auto-reconcile receipts.', connected: false, kind: 'API', icon: 'p', color: '#1B1B1B', accent: '#D4FF3A', danish: true },
{ id: 'pagerduty', name: 'PagerDuty', cat: 'Operations', desc: 'Page on-call from dezky incidents.', connected: false, kind: 'Webhook', icon: 'P', color: '#06AC38', accent: '#FFFFFF' },
{ id: 'slack', name: 'Slack', cat: 'Operations', desc: 'Mirror Chat to Slack (read-only).', connected: false, kind: 'OAuth', icon: 'S', color: '#4A154B', accent: '#FFFFFF' },
]
export const integrationCategories = ['All', 'Productivity', 'Storage', 'CRM', 'Accounting', 'Operations'] as const
+3
View File
@@ -0,0 +1,3 @@
<template>
<slot />
</template>
+69
View File
@@ -0,0 +1,69 @@
<script setup lang="ts">
// Default portal chrome: persistent sidebar + topbar + scrollable content.
// Pages that should render without chrome (auth pages) set
// `definePageMeta({ layout: 'blank' })`.
const { toggle } = useSidebar()
const launcher = useAppLauncher()
const drawer = useNotificationDrawer()
// Touch tweaks composable so persisted theme/density/accent/role hydrate on
// first paint.
usePortalTweaks()
const partnerMode = usePartnerMode()
onMounted(() => partnerMode.hydrate())
// Keyboard shortcuts
onMounted(() => {
const onKey = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === '[') {
e.preventDefault()
toggle()
}
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
e.preventDefault()
launcher.toggle()
}
if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key.toLowerCase() === 'n') {
e.preventDefault()
drawer.toggle()
}
}
document.addEventListener('keydown', onKey)
onBeforeUnmount(() => document.removeEventListener('keydown', onKey))
})
</script>
<template>
<div class="shell">
<CustomerModeBanner />
<div class="cols">
<PortalSidebar />
<main>
<PortalTopbar />
<div class="content">
<slot />
</div>
</main>
</div>
<AppLauncher />
<NotificationDrawer />
<PortalTweaksPanel />
<ToastStack />
</div>
</template>
<style scoped>
.shell {
display: flex;
flex-direction: column;
min-height: 100vh;
background: var(--bg);
color: var(--text);
}
.cols { display: flex; flex: 1; min-height: 0; }
main { flex: 1; min-width: 0; display: flex; flex-direction: column; }
.content { flex: 1; min-width: 0; overflow-y: auto; }
</style>
@@ -0,0 +1,25 @@
// Routes signed-in users to the surface that matches their role:
// - partner staff (User.partnerId set) on '/' → /partner
// - non-partner-staff hitting /partner/* → /
//
// Runs after the OIDC global middleware (00.auth.global from nuxt-oidc-auth)
// so we know the user is authenticated by the time we get here. /me is
// fetched lazily via useMe() and cached in useState — first nav after sign-in
// pays one round-trip, subsequent navs read from cache.
//
// Auth pages (/auth/*, /signed-out) are skipped because they're public.
export default defineNuxtRouteMiddleware(async (to) => {
if (to.path.startsWith('/auth/') || to.path === '/signed-out') return
const { fetchMe, isPartnerStaff } = useMe()
const me = await fetchMe()
if (!me) return // Not signed in yet — OIDC middleware handles the bounce
if (to.path === '/' && isPartnerStaff.value) {
return navigateTo('/partner')
}
if (to.path.startsWith('/partner') && !isPartnerStaff.value) {
return navigateTo('/')
}
})
+44 -5
View File
@@ -9,6 +9,18 @@ export default defineNuxtConfig({
css: ['~/assets/styles/tokens.css', '~/assets/styles/base.css'],
// Auto-import from the shared packages/ui workspace in addition to the
// app's own components/. /shared-packages is bind-mounted in
// docker-compose.yml — outside containers the same files live at
// <repo>/packages/ui/components/. The local dir keeps the default
// directory-based prefix (so components/partner/InviteTeammateModal.vue
// stays <PartnerInviteTeammateModal>); the shared dir uses no prefix so
// CountrySelect.vue is just <CountrySelect>.
components: [
'~/components',
{ path: '/shared-packages/ui/components', pathPrefix: false },
],
app: {
head: {
link: [
@@ -39,19 +51,35 @@ export default defineNuxtConfig({
},
middleware: {
globalMiddlewareEnabled: true,
customLoginPage: true,
// Unauthenticated users land directly on the Authentik login flow.
// Authentik is Dezky-branded and serves as the single sign-on entry
// point for every Dezky app (portal, OCIS files, mail, chat). Direct
// navigation to auth.dezky.local or the post-login dashboard
// (/if/user/) is short-circuited by a Traefik middleware on the
// authentik service that redirects to app.dezky.local — see
// infrastructure/docker-compose/docker-compose.yml.
customLoginPage: false,
},
providers: {
// Generic OIDC against our Authentik instance (provider preset key MUST be one of
// apple, auth0, cognito, entra, github, keycloak, logto, microsoft, oidc, paypal, zitadel).
oidc: {
clientId: process.env.NUXT_OIDC_CLIENT_ID || '',
clientSecret: process.env.NUXT_OIDC_CLIENT_SECRET || '',
redirectUri: process.env.NUXT_OIDC_REDIRECT_URI || '',
// The root .env uses PORTAL_OIDC_* (operator uses OPERATOR_OIDC_*) so
// both apps can share one .env. docker-compose remaps these to
// NUXT_OIDC_* per-container; locally we just read them directly.
clientId: process.env.PORTAL_OIDC_CLIENT_ID || process.env.NUXT_OIDC_CLIENT_ID || '',
clientSecret: process.env.PORTAL_OIDC_CLIENT_SECRET || process.env.NUXT_OIDC_CLIENT_SECRET || '',
redirectUri: process.env.NUXT_OIDC_REDIRECT_URI || 'https://app.dezky.local/auth/oidc/callback',
authorizationUrl: 'https://auth.dezky.local/application/o/authorize/',
tokenUrl: 'https://auth.dezky.local/application/o/token/',
userInfoUrl: 'https://auth.dezky.local/application/o/userinfo/',
logoutUrl: 'https://auth.dezky.local/application/o/dezky-portal/end-session/',
// Logout is handled by our custom /api/auth/sign-out endpoint, not the
// module's RP-initiated chain. Authentik 2025.10 doesn't reliably
// honor `post_logout_redirect_uri` from the provider invalidation
// flow, so we end the local session ourselves and bounce to a
// Dezky-branded /signed-out page that fires Authentik's end-session
// in a hidden iframe for a clean IdP logout in the background.
logoutUrl: '',
// Discovery URL — used by id_token validation to fetch JWKS + issuer
openIdConfiguration:
'https://auth.dezky.local/application/o/dezky-portal/.well-known/openid-configuration',
@@ -65,6 +93,11 @@ export default defineNuxtConfig({
// Expose access token in the server-side session so Nitro route handlers can
// forward it to platform-api. Token never reaches the browser.
exposeAccessToken: true,
// ALSO expose the id_token — needed so the logout handler can populate
// `id_token_hint` on the RP-initiated logout URL. Without it Authentik
// can't verify the request comes from a known session and falls back
// to its "You've logged out" confirmation page.
exposeIdToken: true,
},
},
},
@@ -85,5 +118,11 @@ export default defineNuxtConfig({
routeRules: {
'/api/**': { cors: true },
},
// Persist nuxt-oidc-auth's session store on disk so HMR / restarts don't
// sign out everyone in dev. Memory driver (the default) is fine in prod
// when there's one long-running container per instance.
storage: {
oidc: { driver: 'fs', base: '.nuxt/oidc-store' },
},
},
})
+1 -1
View File
@@ -4,7 +4,7 @@
"private": true,
"description": "Dezky customer-facing portal — Nuxt 3",
"scripts": {
"dev": "nuxt dev --host 0.0.0.0 --port 3000",
"dev": "nuxt dev --host 0.0.0.0 --port 3000 --dotenv ../../.env",
"build": "nuxt build",
"preview": "nuxt preview",
"typecheck": "nuxt typecheck",
+569
View File
@@ -0,0 +1,569 @@
<script setup lang="ts">
// Strict port of project/platform-screens.jsx `BillingScreen` (lines 1134-1257)
// with UpdatePaymentMethodModal (1262), EditBillingDetailsModal (1357) and
// AddSeatsModal (1415). Hero plan card on a 1.4fr/1fr split with the payment
// + business sub-cards on the right.
const toast = useToast()
const paymentOpen = ref(false)
const detailsOpen = ref(false)
const seatsOpen = ref(false)
const pauseOpen = ref(false)
const planOpen = ref(false)
// AddSeats math
const used = 11
const current = 25
const pricePerSeat = 78
const daysUntilRenewal = 96
const extra = ref(5)
const totalSeats = computed(() => current + extra.value)
const monthly = computed(() => extra.value * pricePerSeat)
const prorated = computed(() => Math.round(monthly.value * (daysUntilRenewal / 30)))
// UpdatePaymentMethod modal state
type Method = 'card' | 'invoice' | 'sepa'
const method = ref<Method>('card')
const card = reactive({ number: '', name: 'Anne Baslund', exp: '', cvc: '', country: 'DK', zip: '1620' })
// Edit billing details state
const det = reactive({
company: 'Baslund ApS',
cvr: '42 18 09 33',
contact: 'Anne Baslund',
email: 'billing@dezky.com',
addr1: 'Vesterbrogade 14',
addr2: '',
zip: '1620',
city: 'København V',
country: 'DK',
vat: 'DK 42 18 09 33',
currency: 'DKK',
})
const invoices = [
{ id: 'INV-2026-005', date: '01 May 2026', amount: '1.940,00 DKK', status: 'Paid' },
{ id: 'INV-2026-004', date: '01 Apr 2026', amount: '1.940,00 DKK', status: 'Paid' },
{ id: 'INV-2026-003', date: '01 Mar 2026', amount: '1.560,00 DKK', status: 'Paid' },
{ id: 'INV-2026-002', date: '01 Feb 2026', amount: '1.560,00 DKK', status: 'Paid' },
{ id: 'INV-2026-001', date: '01 Jan 2026', amount: '1.560,00 DKK', status: 'Paid' },
]
const payMethods = [
{ v: 'card' as const, l: 'Card', d: 'Visa · MC · Amex' },
{ v: 'invoice' as const, l: 'Invoice (EAN)', d: 'Net 14 · DK B2B' },
{ v: 'sepa' as const, l: 'SEPA · MobilePay', d: 'Direct debit · DK' },
]
function quickSet(n: number) { extra.value = n }
function exportInvoices(format: 'OIOUBL' | 'CSV') {
toast.info(`Exporting invoices as ${format}`, format === 'OIOUBL' ? 'B2B · Nemhandel' : 'comma-separated · UTF-8')
}
function downloadInvoice(id: string) {
toast.info('Downloading invoice…', id)
}
function viewInvoice(id: string) {
toast.info('Opening invoice', id)
}
function confirmPause() {
pauseOpen.value = false
toast.ok('Subscription paused', 'Resumes automatically on 28 Aug 2026')
}
</script>
<template>
<div>
<PageHeader
eyebrow="Billing"
title="Subscription & invoices"
subtitle="Manage your plan, payment method, and tax-compliant invoices."
>
<template #actions>
<UiButton variant="secondary" @click="toast.info('Bundling invoice ZIP…')">
<template #leading><UiIcon name="download" :size="14" /></template>
Download all (.zip)
</UiButton>
</template>
</PageHeader>
<div class="content">
<div class="top-row">
<!-- Hero plan card -->
<Card :pad="0">
<div class="hero">
<div class="hero-head">
<div>
<div class="kicker">// current plan</div>
<div class="hero-title">Business</div>
<div class="hero-sub">25 seats · invoiced monthly</div>
</div>
<Badge tone="accent">Renews 28 Aug 2026</Badge>
</div>
<div class="hero-stats">
<div><div class="hero-label">Seats used</div><div class="hero-num">11 / 25</div></div>
<div><div class="hero-label">This month</div><div class="hero-num">1.940 DKK</div></div>
<div><div class="hero-label">Next invoice</div><div class="hero-num">01 Jun</div></div>
</div>
</div>
<div class="hero-actions">
<UiButton variant="primary" @click="planOpen = true">Change plan</UiButton>
<UiButton variant="secondary" @click="seatsOpen = true">Add seats</UiButton>
<div class="spacer" />
<UiButton variant="ghost" @click="pauseOpen = true">Pause subscription</UiButton>
</div>
</Card>
<!-- Payment + business details -->
<Card>
<div class="card-head">
<div>
<Eyebrow>Payment</Eyebrow>
<div class="card-title">Payment method</div>
</div>
<UiButton size="sm" variant="ghost" @click="paymentOpen = true">Update</UiButton>
</div>
<div class="visa-row">
<div class="visa">VISA</div>
<div class="visa-meta">
<div class="visa-num"> 4242</div>
<div class="visa-sub">Expires 11/2028 · Anne Baslund</div>
</div>
</div>
<div class="card-head">
<div>
<Eyebrow>Business</Eyebrow>
<div class="card-title">Billing details</div>
</div>
<UiButton size="sm" variant="ghost" @click="detailsOpen = true">Edit</UiButton>
</div>
<dl class="def">
<div><dt>Company</dt><dd>Baslund ApS</dd></div>
<div><dt>CVR</dt><dd>42 18 09 33</dd></div>
<div><dt>Address</dt><dd>Vesterbrogade 14, 1620 København V</dd></div>
<div><dt>VAT</dt><dd>DK 42 18 09 33</dd></div>
<div><dt>Currency</dt><dd>DKK · EUR available</dd></div>
</dl>
</Card>
</div>
<!-- Invoices table -->
<Card :pad="0">
<div class="invoices-head">
<div>
<Eyebrow>History</Eyebrow>
<div class="card-title">Invoices</div>
</div>
<div class="invoices-actions">
<UiButton size="sm" variant="secondary" @click="exportInvoices('OIOUBL')">OIOUBL (B2B)</UiButton>
<UiButton size="sm" variant="secondary" @click="exportInvoices('CSV')">CSV</UiButton>
</div>
</div>
<table class="inv-table">
<thead>
<tr>
<th>Invoice</th><th>Date</th><th>Amount</th><th>Status</th><th></th>
</tr>
</thead>
<tbody>
<tr v-for="inv in invoices" :key="inv.id">
<td><Mono>{{ inv.id }}</Mono></td>
<td>{{ inv.date }}</td>
<td><span class="amount">{{ inv.amount }}</span></td>
<td><Badge tone="ok" dot>{{ inv.status.toLowerCase() }}</Badge></td>
<td class="right">
<UiButton size="sm" variant="ghost" @click="downloadInvoice(inv.id)"><template #leading><UiIcon name="download" :size="13" /></template>PDF</UiButton>
<UiButton size="sm" variant="ghost" @click="viewInvoice(inv.id)"><template #leading><UiIcon name="external" :size="13" /></template>View</UiButton>
</td>
</tr>
</tbody>
</table>
</Card>
</div>
<!-- Update payment method modal -->
<Modal :open="paymentOpen" eyebrow="Billing · payment method" title="Update payment method" size="md" @close="paymentOpen = false">
<div class="pay">
<div>
<Eyebrow>Pay by</Eyebrow>
<div class="pay-options">
<button v-for="o in payMethods" :key="o.v" :class="{ active: method === o.v }" @click="method = o.v">
<div class="po-label">{{ o.l }}</div>
<Mono dim>{{ o.d }}</Mono>
</button>
</div>
</div>
<template v-if="method === 'card'">
<label class="field"><Eyebrow>Card number</Eyebrow>
<div class="input-row">
<UiIcon name="card" :size="15" stroke="var(--text-mute)" />
<input v-model="card.number" placeholder="4242 4242 4242 4242" />
<Mono dim>VISA · MC · AMEX</Mono>
</div>
</label>
<label class="field"><Eyebrow>Name on card</Eyebrow><input class="input" v-model="card.name" /></label>
<div class="grid-2">
<label class="field"><Eyebrow>Expiry</Eyebrow><input class="input" v-model="card.exp" placeholder="MM / YY" /></label>
<label class="field"><Eyebrow>CVC</Eyebrow><input class="input" v-model="card.cvc" placeholder="3 digits" /></label>
</div>
<div class="grid-14-1">
<label class="field"><Eyebrow>Country</Eyebrow><CountrySelect v-model="card.country" /></label>
<label class="field"><Eyebrow>Postal code</Eyebrow><input class="input" v-model="card.zip" /></label>
</div>
</template>
<template v-else-if="method === 'invoice'">
<label class="field"><Eyebrow>EAN number</Eyebrow><input class="input" placeholder="5790000000000" /></label>
<label class="field"><Eyebrow>Purchase order reference (optional)</Eyebrow><input class="input" placeholder="Internal PO # to include on invoice" /></label>
<div class="note">
<Mono dim>// OIOUBL · Nemhandel</Mono>
<div class="note-body">Invoices are delivered to your EAN via the Nemhandel network. Payment terms are <b>net 14 days</b> from invoice date. The first invoice arrives on the next billing cycle.</div>
</div>
</template>
<template v-else>
<label class="field"><Eyebrow>IBAN</Eyebrow><input class="input" placeholder="DK00 0000 0000 0000 00" /></label>
<label class="field"><Eyebrow>Account holder name</Eyebrow><input class="input" value="Baslund ApS" /></label>
<label class="check"><input type="checkbox" checked /> I authorise Dezky to debit this account by SEPA Direct Debit</label>
</template>
<div class="trust">
<UiIcon name="shield" :size="14" stroke="var(--ok)" />
<div>Payment details are tokenised by our processor. Dezky never sees raw card numbers, IBANs, or CVC codes. PCI DSS Level 1.</div>
</div>
</div>
<template #footer>
<UiButton variant="ghost" @click="paymentOpen = false">Cancel</UiButton>
<UiButton variant="primary" @click="paymentOpen = false; toast.ok('Payment method saved')">
<template #leading><UiIcon name="check" :size="13" /></template>
Save payment method
</UiButton>
</template>
</Modal>
<!-- Edit billing details modal -->
<Modal :open="detailsOpen" eyebrow="Billing · business details" title="Edit billing details" size="lg" @close="detailsOpen = false">
<div class="details">
<div class="grid-co">
<label class="field"><Eyebrow>Company name</Eyebrow><input class="input" v-model="det.company" /></label>
<label class="field"><Eyebrow>CVR / org. number</Eyebrow><input class="input" v-model="det.cvr" /></label>
</div>
<div class="grid-2">
<label class="field"><Eyebrow>Billing contact</Eyebrow><input class="input" v-model="det.contact" /></label>
<label class="field"><Eyebrow>Invoice email</Eyebrow><input class="input" v-model="det.email" /></label>
</div>
<label class="field"><Eyebrow>Address line 1</Eyebrow><input class="input" v-model="det.addr1" /></label>
<label class="field"><Eyebrow>Address line 2 (optional)</Eyebrow><input class="input" v-model="det.addr2" placeholder="Floor, suite, c/o…" /></label>
<div class="grid-zip">
<label class="field"><Eyebrow>Postal code</Eyebrow><input class="input" v-model="det.zip" /></label>
<label class="field"><Eyebrow>City</Eyebrow><input class="input" v-model="det.city" /></label>
<label class="field"><Eyebrow>Country</Eyebrow><CountrySelect v-model="det.country" /></label>
</div>
<div class="grid-co">
<label class="field"><Eyebrow>VAT number</Eyebrow><input class="input" v-model="det.vat" /></label>
<label class="field"><Eyebrow>Currency</Eyebrow><input class="input" v-model="det.currency" /></label>
</div>
<div class="note">
<Mono dim>// VAT</Mono>
<div class="note-body">For Danish customers, the CVR + VAT must match. Reverse-charge applies for EU B2B customers outside Denmark (we won't charge VAT, you self-account).</div>
</div>
</div>
<template #footer>
<UiButton variant="ghost" @click="detailsOpen = false">Cancel</UiButton>
<UiButton variant="primary" @click="detailsOpen = false; toast.ok('Billing details saved')">
<template #leading><UiIcon name="check" :size="13" /></template>
Save details
</UiButton>
</template>
</Modal>
<!-- Pause subscription confirmation -->
<ConfirmDialog
:open="pauseOpen"
eyebrow="Billing · subscription"
title="Pause subscription?"
confirm-label="Pause subscription"
tone="danger"
@close="pauseOpen = false"
@confirm="confirmPause"
>
Members keep access until the end of the current billing cycle (28 Aug 2026), after
which sign-ins are blocked and data is held in cold storage. You can resume any time
to restore full access.
</ConfirmDialog>
<!-- Plan-change flow stub -->
<Modal :open="planOpen" eyebrow="Billing · plan" title="Change plan" size="md" @close="planOpen = false">
<div class="plan-stack">
<div class="lead">Pick a new tier. We'll prorate the difference and apply it on your next invoice.</div>
<div class="plan-options">
<button v-for="p in [
{ id: 'basic', name: 'Basic', price: '49 DKK / seat / mo', d: 'Mail · Drev · 50 GB' },
{ id: 'business', name: 'Business · current', price: '78 DKK / seat / mo', d: 'Everything in Basic + Møder + Chat · 200 GB', current: true },
{ id: 'enterprise', name: 'Enterprise', price: 'from 140 DKK / seat / mo', d: 'SSO contracts · audit log retention · 1 TB' },
]" :key="p.id" :class="['plan-card', { active: p.current }]">
<div class="plan-name">{{ p.name }}</div>
<Mono dim>{{ p.price }}</Mono>
<div class="plan-d">{{ p.d }}</div>
</button>
</div>
</div>
<template #footer>
<UiButton variant="ghost" @click="planOpen = false">Cancel</UiButton>
<UiButton variant="primary" @click="planOpen = false; toast.ok('Plan change scheduled', 'Takes effect on next invoice')">
<template #leading><UiIcon name="check" :size="13" /></template>
Schedule change
</UiButton>
</template>
</Modal>
<!-- Add seats modal -->
<Modal :open="seatsOpen" eyebrow="Billing · seats" title="Add seats" size="md" @close="seatsOpen = false">
<div class="seats">
<div class="seats-3col">
<div><Eyebrow>Active users</Eyebrow><div class="big">{{ used }}</div></div>
<div><Eyebrow>Current seats</Eyebrow><div class="big">{{ current }}</div></div>
<div><Eyebrow>After change</Eyebrow><div class="big ok">{{ totalSeats }}</div></div>
</div>
<div>
<Eyebrow>How many seats to add</Eyebrow>
<div class="stepper">
<button class="step-btn" @click="extra = Math.max(1, extra - 1)"><span class="minus" /></button>
<input type="number" :value="extra" @input="(e) => (extra = Math.max(1, Math.min(500, parseInt((e.target as HTMLInputElement).value || '0') || 1)))" />
<button class="step-btn" @click="extra = Math.min(500, extra + 1)"><UiIcon name="plus" :size="14" /></button>
</div>
<div class="presets">
<button v-for="n in [5, 10, 25, 50]" :key="n" :class="{ active: extra === n }" @click="quickSet(n)">+{{ n }}</button>
</div>
</div>
<div class="bill-box">
<Eyebrow>What you'll pay</Eyebrow>
<div class="bb-row"><span>{{ extra }} new seat{{ extra === 1 ? '' : 's' }} × {{ pricePerSeat }} DKK / month</span><Mono>{{ monthly.toLocaleString('da-DK') }} DKK / mo</Mono></div>
<div class="bb-row sep"><span class="dim">Prorated for current cycle ({{ daysUntilRenewal }} days until renewal)</span><Mono dim>{{ prorated.toLocaleString('da-DK') }} DKK</Mono></div>
<div class="bb-row total"><span>Charged today</span><span class="hero-amount">{{ prorated.toLocaleString('da-DK') }} DKK</span></div>
<div class="bb-row"><span class="dim">Next invoice on 01 Jun 2026</span><Mono dim>{{ (1940 + monthly).toLocaleString('da-DK') }} DKK</Mono></div>
</div>
<div class="trust">
<UiIcon name="card" :size="14" stroke="var(--text-mute)" />
<div>Charged to <Mono>Visa •••• 4242</Mono>. Seats are added instantly — invitations can be sent right away.</div>
</div>
</div>
<template #footer>
<UiButton variant="ghost" @click="seatsOpen = false">Cancel</UiButton>
<UiButton variant="primary" @click="seatsOpen = false; toast.ok(`${extra} seats added · charged ${prorated.toLocaleString('da-DK')} DKK`)">
<template #leading><UiIcon name="plus" :size="13" /></template>
Add {{ extra }} seat{{ extra === 1 ? '' : 's' }}
</UiButton>
</template>
</Modal>
</div>
</template>
<style scoped>
.content { padding: 24px 40px 64px 40px; max-width: 1200px; }
.top-row { display: grid; grid-template-columns: 1.4fr 1fr; gap: 16px; margin-bottom: 16px; }
/* Hero plan card */
.hero { padding: 28px; background: var(--text); color: var(--bg); border-radius: 8px 8px 0 0; }
.hero-head { display: flex; justify-content: space-between; align-items: flex-start; }
.kicker { font-family: var(--font-mono); font-size: 11px; color: var(--accent); letter-spacing: 0.1em; }
.hero-title {
font-family: var(--font-display);
font-size: 38px;
font-weight: 600;
letter-spacing: -0.025em;
margin-top: 10px;
}
.hero-sub { font-size: 13px; opacity: 0.6; margin-top: 4px; }
.hero-stats {
display: grid;
grid-template-columns: repeat(3, 1fr);
margin-top: 28px;
padding-top: 24px;
border-top: 1px solid rgba(255, 255, 255, 0.12);
}
.hero-label { font-family: var(--font-mono); font-size: 10px; opacity: 0.5; letter-spacing: 0.12em; text-transform: uppercase; }
.hero-num { font-family: var(--font-display); font-size: 28px; font-weight: 600; margin-top: 6px; }
.hero-actions { padding: 24px; display: flex; gap: 8px; align-items: center; }
.spacer { flex: 1; }
/* Payment + business sub-cards inside one Card */
.card-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; margin-bottom: 12px; }
.card-head + .card-head { margin-top: 16px; }
.card-title {
font-family: var(--font-display);
font-weight: 600;
font-size: 18px;
letter-spacing: -0.01em;
margin-top: 4px;
}
.visa-row {
display: flex;
align-items: center;
gap: 12px;
padding: 14px;
background: var(--bg);
border-radius: 6px;
margin-bottom: 16px;
}
.visa {
width: 40px;
height: 28px;
border-radius: 4px;
background: var(--text);
color: var(--bg);
display: inline-flex;
align-items: center;
justify-content: center;
font-family: var(--font-mono);
font-size: 9px;
font-weight: 700;
letter-spacing: 0.05em;
}
.visa-meta { flex: 1; }
.visa-num { font-family: var(--font-mono); font-size: 13px; }
.visa-sub { font-size: 11px; color: var(--text-mute); margin-top: 2px; }
.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); }
/* Invoices table */
.invoices-head {
padding: 20px 24px;
border-bottom: 1px solid var(--border);
display: flex;
justify-content: space-between;
align-items: center;
}
.invoices-actions { display: flex; gap: 8px; }
.inv-table { width: 100%; border-collapse: collapse; }
.inv-table 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;
}
.inv-table td { padding: 12px 16px; border-bottom: 1px solid var(--border); font-size: 13px; }
.inv-table tr:last-child td { border-bottom: none; }
.inv-table .right { text-align: right; display: flex; gap: 6px; justify-content: flex-end; }
.amount { font-family: var(--font-mono); font-size: 13px; font-weight: 500; }
/* Modal forms */
.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); }
.input-row {
display: flex;
align-items: center;
gap: 8px;
padding: 0 12px;
height: 36px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
}
.input-row input { flex: 1; border: none; outline: none; background: transparent; font-family: var(--font-mono); font-size: 13px; color: var(--text); letter-spacing: 0.05em; }
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.grid-14-1 { display: grid; grid-template-columns: 1.4fr 1fr; gap: 12px; }
.grid-co { display: grid; grid-template-columns: 1fr 200px; gap: 12px; }
.grid-zip { display: grid; grid-template-columns: 120px 1fr 1fr; gap: 12px; }
.pay { display: flex; flex-direction: column; gap: 16px; }
.pay-options { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin-top: 8px; }
.pay-options button { padding: 12px; border-radius: 6px; text-align: left; font-family: inherit; cursor: pointer; border: 1px solid var(--border); background: var(--surface); }
.pay-options button.active { border-color: var(--text); background: var(--bg); }
.po-label { font-size: 13px; font-weight: 500; }
.details { display: flex; flex-direction: column; gap: 14px; }
.note { padding: 12px; background: var(--bg); border-radius: 6px; border: 1px solid var(--border); font-size: 12px; color: var(--text-dim); line-height: 1.55; }
.note-body { margin-top: 6px; }
.check { display: flex; align-items: center; gap: 8px; font-size: 13px; }
.trust {
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;
}
/* Add seats */
.seats { display: flex; flex-direction: column; gap: 18px; }
.seats-3col {
padding: 16px;
background: var(--bg);
border-radius: 8px;
border: 1px solid var(--border);
display: grid;
grid-template-columns: repeat(3, 1fr);
}
.seats-3col > div { padding: 0 12px; border-right: 1px solid var(--border); }
.seats-3col > div:first-child { padding-left: 0; }
.seats-3col > div:last-child { padding-right: 0; border-right: none; }
.big { font-family: var(--font-display); font-weight: 600; font-size: 24px; margin-top: 4px; }
.big.ok { color: var(--ok); }
.stepper { 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; display: inline-flex; align-items: center; justify-content: center; }
.step-btn .minus { width: 12px; height: 2px; background: var(--text); display: block; }
.stepper input {
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;
}
.presets { display: flex; gap: 6px; flex-wrap: wrap; }
.presets 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; }
.presets button.active { background: var(--text); color: var(--bg); border-color: var(--text); }
.bill-box { padding: 16px; background: var(--surface); border-radius: 8px; border: 1px solid var(--border); display: flex; flex-direction: column; gap: 8px; }
.bb-row { display: flex; justify-content: space-between; font-size: 13px; align-items: baseline; }
.bb-row.sep { padding-bottom: 8px; border-bottom: 1px solid var(--border); }
.bb-row.total { font-weight: 600; }
.bb-row .dim { color: var(--text-mute); }
.hero-amount { font-family: var(--font-display); font-size: 18px; letter-spacing: -0.01em; }
/* Plan-change modal */
.plan-stack { display: flex; flex-direction: column; gap: 14px; }
.lead { font-size: 13px; color: var(--text-mute); line-height: 1.55; }
.plan-options { display: flex; flex-direction: column; gap: 8px; }
.plan-card {
padding: 14px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
text-align: left;
font-family: inherit;
color: var(--text);
cursor: pointer;
}
.plan-card.active { border-color: var(--text); background: var(--bg); }
.plan-name { font-size: 14px; font-weight: 500; }
.plan-d { font-size: 12px; color: var(--text-mute); margin-top: 6px; }
</style>
+784
View File
@@ -0,0 +1,784 @@
<script setup lang="ts">
// Strict port of project/platform-screens.jsx `BrandingScreen` (lines 1542-1668)
// with BrandingPreview (1669), UploadAssetModal (1733), EditEmailTemplatePanel
// (1903), PublishBrandingModal (2031) and ResetBrandingModal (2148). Two-column
// layout — controls on the left (420px), live preview on the right.
const toast = useToast()
const color = ref('#D4FF3A')
const name = ref('Acme Workspace')
const uploadAsset = ref<typeof ASSETS[number] | null>(null)
const uploaded = ref(false)
const dragOver = ref(false)
const editTemplate = ref<typeof TEMPLATES[number] | null>(null)
const subject = ref('')
const body = ref('')
const testSent = ref(false)
const publishOpen = ref(false)
const publishState = ref<'confirm' | 'publishing' | 'done'>('confirm')
const resetOpen = ref(false)
const ASSETS = [
{ id: 'full', l: 'Full logo', d: 'horizontal · 4:1 · png/svg', ratio: '4:1', formats: 'png · svg', maxKb: 400, current: false, currentName: '', currentSize: '' },
{ id: 'mark', l: 'Square mark', d: '1:1 · transparent · png/svg', ratio: '1:1', formats: 'png · svg', maxKb: 200, current: true, currentName: 'acme-mark.svg', currentSize: '12 KB' },
{ id: 'favicon', l: 'Favicon', d: '32×32 · ico/png', ratio: '1:1', formats: 'ico · png', maxKb: 50, current: true, currentName: 'favicon.ico', currentSize: '4 KB' },
] as const
const TEMPLATES = [
{ id: 'invitation', name: 'User invitation', subject: 'Youve been invited to {{workspace.name}}', desc: 'sent when an admin invites a new user', edited: '3 days ago' },
{ id: 'reset', name: 'Password reset', subject: 'Reset your {{workspace.name}} password', desc: 'sent on forgot-password requests', edited: 'default' },
{ id: 'digest', name: 'Notification digest', subject: 'Your weekly summary from {{workspace.name}}', desc: 'sent weekly to users opted-in for digests', edited: '2 weeks ago' },
{ id: 'trial', name: 'Trial expiring', subject: 'Your trial ends in {{trial.days_left}} days', desc: 'sent 7 / 3 / 1 days before trial expiry', edited: 'default' },
] as const
const TEMPLATE_BODIES: Record<string, string> = {
invitation: `Hi {{user.first_name}},
{{inviter.name}} has invited you to join {{workspace.name}} on dezky.
Click below to set up your account — the link expires in 7 days.
→ {{invite.url}}
If you have any questions, reply to this email and we'll help out.
— The {{workspace.name}} team`,
reset: `Hi {{user.first_name}},
Someone (hopefully you) asked to reset your {{workspace.name}} password.
Click the link below within the next 60 minutes to choose a new one:
→ {{reset.url}}
If you didn't request this, you can safely ignore this email.
— {{workspace.name}} security`,
digest: `Hi {{user.first_name}},
Here's what happened in {{workspace.name}} this week:
· {{stats.messages}} new messages across your channels
· {{stats.files}} files shared
· {{stats.meetings}} meetings recorded
→ Open dashboard: {{workspace.url}}
Manage how often you receive these from your profile.`,
trial: `Hi {{user.first_name}},
Your {{workspace.name}} trial ends in {{trial.days_left}} days.
You've added {{stats.users}} users and uploaded {{stats.gb}} GB of files. To keep everything running smoothly, upgrade to Business or Enterprise.
→ Choose a plan: {{billing.url}}
— {{workspace.name}}`,
}
const TEMPLATE_MERGE_TAGS: Record<string, string[]> = {
invitation: ['user.first_name', 'user.email', 'inviter.name', 'workspace.name', 'invite.url', 'invite.expires_at'],
reset: ['user.first_name', 'workspace.name', 'reset.url', 'reset.expires_at', 'security.ip'],
digest: ['user.first_name', 'workspace.name', 'workspace.url', 'stats.messages', 'stats.files', 'stats.meetings'],
trial: ['user.first_name', 'workspace.name', 'trial.days_left', 'stats.users', 'stats.gb', 'billing.url'],
}
const colorPalette = ['#D4FF3A', '#3F6BFF', '#FF6B4A', '#5B8C5A', '#9B59B6']
function openTemplate(t: typeof TEMPLATES[number]) {
editTemplate.value = t
subject.value = t.subject
body.value = TEMPLATE_BODIES[t.id] || ''
testSent.value = false
}
function insertTag(tag: string) {
body.value += `{{${tag}}}`
}
// Reset the currently-open template's subject + body to the canonical default.
function resetTemplate() {
if (!editTemplate.value) return
subject.value = editTemplate.value.subject
body.value = TEMPLATE_BODIES[editTemplate.value.id] || ''
toast.info('Template reset to default')
}
// Wrap a merge-tag name in mustaches via JS so the template doesn't have to
// nest `{{ ... }}` inside `{{ ... }}` (which Vue's parser scans positionally
// and breaks on).
function wrapTag(tag: string) {
return '{' + '{' + tag + '}' + '}'
}
function startPublish() {
publishState.value = 'publishing'
setTimeout(() => { publishState.value = 'done' }, 1800)
}
function openPublish() {
publishOpen.value = true
publishState.value = 'confirm'
}
const renderedSubject = computed(() =>
subject.value
.replace(/\{\{workspace\.name\}\}/g, name.value)
.replace(/\{\{user\.first_name\}\}/g, 'Anne')
.replace(/\{\{trial\.days_left\}\}/g, '3'),
)
const renderedBody = computed(() =>
body.value
.replace(/\{\{workspace\.name\}\}/g, name.value)
.replace(/\{\{workspace\.url\}\}/g, 'workspace.acme.dk')
.replace(/\{\{user\.first_name\}\}/g, 'Anne')
.replace(/\{\{user\.email\}\}/g, 'anne@acme.dk')
.replace(/\{\{inviter\.name\}\}/g, 'Mikkel Nørgaard')
.replace(/\{\{invite\.url\}\}/g, 'workspace.acme.dk/accept/x9k2a')
.replace(/\{\{reset\.url\}\}/g, 'workspace.acme.dk/reset/p2b7c')
.replace(/\{\{billing\.url\}\}/g, 'workspace.acme.dk/billing')
.replace(/\{\{trial\.days_left\}\}/g, '3')
.replace(/\{\{stats\.messages\}\}/g, '1.840')
.replace(/\{\{stats\.files\}\}/g, '24')
.replace(/\{\{stats\.meetings\}\}/g, '6')
.replace(/\{\{stats\.users\}\}/g, '8')
.replace(/\{\{stats\.gb\}\}/g, '14'),
)
</script>
<template>
<div>
<PageHeader
eyebrow="Whitelabel"
title="Branding"
subtitle="Replace the dezky shell with your own logo, color, and product name. Changes propagate everywhere."
>
<template #actions>
<UiButton variant="ghost" @click="resetOpen = true">Reset</UiButton>
<UiButton variant="primary" @click="openPublish">Publish</UiButton>
</template>
</PageHeader>
<div class="content">
<!-- Controls -->
<div class="controls">
<Card>
<div class="card-head"><Eyebrow>Identity</Eyebrow><div class="card-title">Product identity</div></div>
<label class="field"><Eyebrow>Product name (shown to users)</Eyebrow><input class="input" v-model="name" /></label>
<label class="field"><Eyebrow>Custom domain</Eyebrow>
<div class="input-row">
<input value="workspace.acme.dk" readonly />
<UiIcon name="check" :size="12" stroke="var(--ok)" :stroke-width="2.5" />
</div>
</label>
</Card>
<Card>
<div class="card-head">
<Eyebrow>Color</Eyebrow>
<div class="card-title">Primary accent</div>
<div class="card-sub">Propagates to buttons, links, focus rings, and active states.</div>
</div>
<div class="swatches">
<button v-for="c in colorPalette" :key="c" :style="{ background: c, borderColor: color === c ? 'var(--text)' : 'var(--border)', borderWidth: color === c ? '2px' : '1px' }" @click="color = c" />
</div>
<div class="input-row">
<input v-model="color" />
<div class="color-preview" :style="{ background: color }" />
</div>
</Card>
<Card>
<div class="card-head"><Eyebrow>Assets</Eyebrow><div class="card-title">Logo upload</div></div>
<div class="assets">
<div v-for="a in ASSETS" :key="a.id" class="asset" :class="{ has: a.current }">
<div class="asset-icon" :style="{ color: a.current ? 'var(--ok)' : 'var(--text-mute)' }">
<UiIcon :name="a.current ? 'check' : 'upload'" :size="16" :stroke-width="a.current ? 2.5 : 2" />
</div>
<div class="asset-meta">
<div class="asset-l">{{ a.l }}</div>
<Mono dim>{{ a.current ? `${a.currentName} · ${a.currentSize}` : a.d }}</Mono>
</div>
<UiButton size="sm" :variant="a.current ? 'ghost' : 'secondary'" @click="uploadAsset = a as any; uploaded = false">
{{ a.current ? 'Replace' : 'Upload' }}
</UiButton>
</div>
</div>
</Card>
<Card>
<div class="card-head"><Eyebrow>Templates</Eyebrow><div class="card-title">Email templates</div></div>
<div class="templates">
<button v-for="t in TEMPLATES" :key="t.id" class="tmpl-row" @click="openTemplate(t as any)">
<div class="tmpl-meta">
<div class="tmpl-name-row">
<span class="tmpl-name">{{ t.name }}</span>
<Badge :tone="t.edited === 'default' ? 'neutral' : 'info'">{{ t.edited === 'default' ? 'default' : 'edited' }}</Badge>
</div>
<Mono dim>edited {{ t.edited }}</Mono>
</div>
<UiIcon name="chevRight" :size="14" stroke="var(--text-mute)" />
</button>
</div>
</Card>
</div>
<!-- Preview -->
<div class="preview-col">
<div class="preview-head">
<Eyebrow>Live preview</Eyebrow>
<Mono dim>workspace.acme.dk</Mono>
</div>
<div class="preview-frame">
<div class="frame-topbar">
<div class="frame-mark" :style="{ background: color }">{{ name[0]?.toLowerCase() || 'a' }}</div>
<div class="frame-brand">{{ name.toLowerCase() }}</div>
<div class="frame-spacer" />
<div class="frame-user">anne@acme.dk</div>
</div>
<div class="frame-hero">
<div class="frame-eyebrow">Dashboard</div>
<div class="frame-title">Good morning, Anne.</div>
<div class="frame-tiles">
<div v-for="n in ['Mail', 'Drev', 'Møder', 'Chat']" :key="n" class="frame-tile">
<div class="frame-tile-icon">{{ n[0] }}</div>
<div class="frame-tile-name">{{ n }}</div>
</div>
</div>
<div class="frame-cta" :style="{ background: color }">
<div>
<div class="frame-cta-title">Welcome to {{ name }}.</div>
<div class="frame-cta-sub">Your team's workspace is ready.</div>
</div>
<button class="frame-cta-btn">Get started</button>
</div>
</div>
<div class="frame-foot">
<span>powered by dezky</span>
<span>v1.0 · light</span>
</div>
</div>
</div>
</div>
<!-- Upload asset modal -->
<Modal :open="!!uploadAsset" :eyebrow="uploadAsset ? `Branding · ${uploadAsset.l.toLowerCase()}` : ''" :title="uploadAsset ? `Upload ${uploadAsset.l.toLowerCase()}` : ''" size="md" @close="uploadAsset = null">
<div v-if="uploadAsset" class="upload">
<button v-if="!uploaded" class="dropzone" :class="{ over: dragOver }"
@dragover.prevent="dragOver = true"
@dragleave="dragOver = false"
@drop.prevent="dragOver = false; uploaded = true"
@click="uploaded = true">
<UiIcon name="upload" :size="28" stroke="var(--text-mute)" />
<div class="drop-text">
<div class="drop-title">Drop {{ uploadAsset.l.toLowerCase() }} here, or click to browse</div>
<Mono dim>{{ uploadAsset.formats }} · {{ uploadAsset.ratio }} ratio · up to {{ uploadAsset.maxKb }} KB</Mono>
</div>
</button>
<template v-if="uploaded">
<div class="upload-preview">
<div class="upload-mark" :style="{ width: uploadAsset.id === 'full' ? '96px' : '56px' }">
{{ uploadAsset.id === 'full' ? 'acme' : 'a' }}
</div>
<div class="upload-meta">
<div class="upload-name">{{ uploadAsset.id === 'favicon' ? 'favicon-new.png' : uploadAsset.id === 'mark' ? 'acme-mark-v2.svg' : 'acme-logo.svg' }}</div>
<Mono dim>{{ uploadAsset.id === 'favicon' ? '6 KB' : uploadAsset.id === 'mark' ? '14 KB' : '38 KB' }} · {{ uploadAsset.id === 'favicon' ? '32×32' : uploadAsset.id === 'mark' ? '512×512' : '1200×300' }} · clean alpha</Mono>
</div>
<UiButton size="sm" variant="ghost" @click="uploaded = false">Replace</UiButton>
</div>
<Eyebrow>Looks good</Eyebrow>
<div class="check-list">
<div class="check-row">
<UiIcon name="check" :size="12" stroke="var(--ok)" :stroke-width="2.5" />
<Mono dim>Format</Mono>
<span>{{ uploadAsset.formats.split(' · ')[0] }} ✓</span>
</div>
<div class="check-row">
<UiIcon name="check" :size="12" stroke="var(--ok)" :stroke-width="2.5" />
<Mono dim>Dimensions</Mono>
<span>{{ uploadAsset.id === 'favicon' ? '32×32 ' : uploadAsset.ratio + ' ' }}</span>
</div>
<div class="check-row">
<UiIcon name="check" :size="12" stroke="var(--ok)" :stroke-width="2.5" />
<Mono dim>Size</Mono>
<span>{{ uploadAsset.id === 'favicon' ? '6 KB' : uploadAsset.id === 'mark' ? '14 KB' : '38 KB' }} (under {{ uploadAsset.maxKb }} KB)</span>
</div>
<div class="check-row">
<UiIcon name="check" :size="12" stroke="var(--ok)" :stroke-width="2.5" />
<Mono dim>Transparency</Mono>
<span>{{ uploadAsset.id === 'favicon' ? 'opaque background OK' : 'transparent background ' }}</span>
</div>
</div>
<div class="ld-preview">
<Eyebrow>Preview · on light + dark</Eyebrow>
<div class="ld-grid">
<div class="ld-light">
<div class="ld-mark dark" :style="{ width: uploadAsset.id === 'full' ? '80px' : '32px' }">{{ uploadAsset.id === 'full' ? 'acme' : 'a' }}</div>
</div>
<div class="ld-dark">
<div class="ld-mark light" :style="{ width: uploadAsset.id === 'full' ? '80px' : '32px' }">{{ uploadAsset.id === 'full' ? 'acme' : 'a' }}</div>
</div>
</div>
</div>
</template>
<div class="req-box">
<Mono dim>// requirements</Mono>
<div class="req-body">
<template v-if="uploadAsset.id === 'full'">Used in the top navigation bar, login screen, and email headers. Roughly 200×50 displayed — supply at 2× minimum.</template>
<template v-else-if="uploadAsset.id === 'mark'">Used as the app icon, favicon fallback, and any compact context (PWA install, notifications). Must read at 24×24.</template>
<template v-else>Browser tab icon and bookmark badge. 32×32 is the standard size — modern browsers use the same file at 16×16.</template>
</div>
</div>
</div>
<template #footer>
<UiButton variant="ghost" @click="uploadAsset = null">Cancel</UiButton>
<UiButton variant="primary" :disabled="!uploaded" @click="uploadAsset = null">
<template #leading><UiIcon name="check" :size="13" /></template>
{{ uploaded ? 'Use this asset' : 'Select a file to continue' }}
</UiButton>
</template>
</Modal>
<!-- Edit email template side panel -->
<SidePanel :open="!!editTemplate" :eyebrow="'Email template'" :title="editTemplate?.name || ''" width="lg" @close="editTemplate = null">
<div v-if="editTemplate" class="tmpl-edit">
<div class="tmpl-col">
<label class="field"><Eyebrow>Subject</Eyebrow><input class="input" v-model="subject" /></label>
<div>
<Eyebrow>Body</Eyebrow>
<textarea v-model="body" class="body-area" />
</div>
<div>
<Eyebrow>Merge tags · click to insert</Eyebrow>
<div class="merge-tags">
<button v-for="tag in (TEMPLATE_MERGE_TAGS[editTemplate.id] || [])" :key="tag" @click="insertTag(tag)">{{ wrapTag(tag) }}</button>
</div>
</div>
</div>
<div class="tmpl-prev">
<Eyebrow>Preview</Eyebrow>
<div class="email-frame">
<div class="email-head">
<div class="from-row">
<div class="from-mark" :style="{ background: '#0A0A0A', color }">{{ name[0]?.toLowerCase() || 'a' }}</div>
<Mono dim>From: {{ name.toLowerCase().replace(/\s+/g, '-') }}@dezky.com</Mono>
</div>
<div class="email-subj">{{ renderedSubject }}</div>
</div>
<div class="email-body">{{ renderedBody }}</div>
<div class="email-foot" :style="{ background: color }">{{ name }} · workspace.acme.dk</div>
</div>
<Mono dim style="text-align: center; display: block;">preview substitutes sample data · real send uses recipient's data</Mono>
</div>
</div>
<template #footer>
<UiButton variant="ghost" @click="resetTemplate">
<template #leading><UiIcon name="refresh" :size="13" /></template>
Reset to default
</UiButton>
<div style="flex: 1" />
<UiButton variant="secondary" @click="testSent = true; setTimeout(() => testSent = false, 2500)">
<template #leading><UiIcon name="mail" :size="13" /></template>
{{ testSent ? 'Sent to anne@dezky.com ✓' : 'Send test to me' }}
</UiButton>
<UiButton variant="primary" @click="editTemplate = null">
<template #leading><UiIcon name="check" :size="13" /></template>
Save template
</UiButton>
</template>
</SidePanel>
<!-- Publish modal -->
<Modal :open="publishOpen" eyebrow="Branding · publish" :title="publishState === 'done' ? 'Branding published' : 'Publish branding changes?'" size="md" @close="publishState !== 'publishing' ? (publishOpen = false) : null">
<template v-if="publishState === 'confirm'">
<div class="publish-intro">These changes will replace dezky's branding for everyone in your workspace within ~30 seconds.</div>
<Eyebrow>Will go live</Eyebrow>
<div class="publish-summary">
<div class="ps-row"><Mono dim>Product name</Mono><span>{{ name }}</span></div>
<div class="ps-row">
<Mono dim>Primary color</Mono>
<span class="color-line">
<span class="color-chip" :style="{ background: color }" />
<Mono>{{ color }}</Mono>
</span>
</div>
<div class="ps-row">
<Mono dim>Custom domain</Mono>
<Mono>workspace.acme.dk</Mono>
<Badge tone="ok" dot>verified</Badge>
</div>
</div>
<Eyebrow>Propagates to</Eyebrow>
<div class="prop-grid">
<div v-for="[k, t] in [
['Web app · workspace shell', '~10s'],
['Login + auth pages', '~10s'],
['Outbound email templates', '~30s'],
['Mobile app · next session', 'on next launch'],
['Status page', '~30s'],
['PDF invoices', 'next billing cycle'],
]" :key="k" class="prop-cell">
<UiIcon name="check" :size="11" stroke="var(--ok)" :stroke-width="2.5" />
<span>{{ k }}</span>
<Mono dim>{{ t }}</Mono>
</div>
</div>
<div class="publish-warn">
<UiIcon name="shield" :size="14" stroke="var(--warn)" />
<div>Users may need to hard-refresh to see the new branding immediately. You can revert with one click for the next 7 days.</div>
</div>
</template>
<template v-else-if="publishState === 'publishing'">
<div class="publishing">
<div class="spinner" />
<div class="publish-title">Publishing across services…</div>
<Mono dim>web shell · auth · mail templates · CDN</Mono>
</div>
</template>
<template v-else>
<div class="done-head">
<div class="done-badge" :style="{ background: color }">
<UiIcon name="check" :size="20" :stroke-width="2.5" />
</div>
<div>
<div class="publish-title">{{ name }} branding is live</div>
<Mono dim>5 services updated · 1 queued for next cycle</Mono>
</div>
</div>
<div class="done-list">
<dl class="def">
<div><dt>Web app + auth</dt><dd>live · 8 seconds</dd></div>
<div><dt>Email templates</dt><dd>live · 18 seconds</dd></div>
<div><dt>Mobile · status · CDN</dt><dd>queued · ~30s</dd></div>
<div><dt>PDF invoices</dt><dd>starts 01 Jun 2026</dd></div>
</dl>
</div>
</template>
<template #footer>
<template v-if="publishState === 'confirm'">
<UiButton variant="ghost" @click="publishOpen = false">Cancel</UiButton>
<UiButton variant="primary" @click="startPublish">
<template #leading><UiIcon name="external" :size="13" /></template>
Publish now
</UiButton>
</template>
<template v-else-if="publishState === 'publishing'">
<UiButton variant="ghost" disabled>Publishing…</UiButton>
</template>
<template v-else>
<UiButton variant="primary" @click="publishOpen = false">Done</UiButton>
</template>
</template>
</Modal>
<!-- Reset branding modal -->
<Modal :open="resetOpen" eyebrow="Destructive · reverts to defaults" title="Reset branding to dezky defaults?" size="sm" @close="resetOpen = false">
<div class="reset-box bad">
<UiIcon name="shield" :size="16" stroke="var(--bad)" />
<div>Reverts product name, colors, logos, and email templates to dezky defaults. Your custom domain stays connected. Edits made today are kept for 7 days and can be restored from your audit log.</div>
</div>
<div class="reset-list">
<dl class="def">
<div><dt>Product name</dt><dd>Acme Workspace → dezky</dd></div>
<div><dt>Primary color</dt><dd>#D4FF3A → #D4FF3A (default)</dd></div>
<div><dt>Full logo</dt><dd>will be removed</dd></div>
<div><dt>Square mark</dt><dd>will be removed</dd></div>
<div><dt>Favicon</dt><dd>will be removed</dd></div>
<div><dt>Email templates</dt><dd>2 edited templates → defaults</dd></div>
<div><dt>Custom domain</dt><dd>workspace.acme.dk · kept</dd></div>
</dl>
</div>
<template #footer>
<UiButton variant="ghost" @click="resetOpen = false">Cancel</UiButton>
<UiButton variant="danger" @click="resetOpen = false">
<template #leading><UiIcon name="refresh" :size="13" /></template>
Reset everything
</UiButton>
</template>
</Modal>
</div>
</template>
<style scoped>
.content { padding: 24px 40px 64px 40px; display: grid; grid-template-columns: 420px 1fr; gap: 24px; }
.controls { display: flex; flex-direction: column; gap: 16px; }
.card-head { margin-bottom: 14px; }
.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; }
.field { display: flex; flex-direction: column; gap: 6px; margin-bottom: 14px; }
.field:last-child { margin-bottom: 0; }
.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); }
.input-row {
display: flex;
align-items: center;
gap: 8px;
height: 36px;
padding: 0 12px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
}
.input-row input { flex: 1; border: none; outline: none; background: transparent; font-family: inherit; font-size: 13px; color: var(--text); }
.color-preview { width: 36px; height: 36px; border-radius: 6px; border: 1px solid var(--border); flex-shrink: 0; }
.swatches { display: flex; gap: 10px; margin-bottom: 14px; }
.swatches button { width: 38px; height: 38px; border-radius: 6px; cursor: pointer; }
.assets { display: flex; flex-direction: column; gap: 10px; }
.asset {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
border: 1px dashed var(--border);
border-radius: 6px;
}
.asset.has { background: var(--surface); border-style: solid; }
.asset-icon { width: 40px; height: 40px; border-radius: 6px; background: var(--bg); display: inline-flex; align-items: center; justify-content: center; }
.asset-meta { flex: 1; min-width: 0; }
.asset-l { font-size: 13px; font-weight: 500; }
.templates { display: flex; flex-direction: column; }
.tmpl-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 12px 0; border-bottom: 1px solid var(--border); background: transparent; border-left: none; border-right: none; border-top: none; text-align: left; color: var(--text); font-family: inherit; font-size: 13px; cursor: pointer; }
.tmpl-row:last-child { border-bottom: none; }
.tmpl-meta { flex: 1; min-width: 0; }
.tmpl-name-row { display: flex; align-items: center; gap: 8px; }
.tmpl-name { font-weight: 500; }
.preview-col { min-width: 0; }
.preview-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
.preview-frame {
background: #FAFAF7;
color: #0A0A0A;
border-radius: 10px;
border: 1px solid var(--border);
overflow: hidden;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.08);
font-family: 'Inter', sans-serif;
}
.frame-topbar {
height: 52px;
background: #0A0A0A;
color: #F4F3EE;
display: flex;
align-items: center;
padding: 0 20px;
gap: 16px;
}
.frame-mark {
width: 24px;
height: 24px;
border-radius: 5px;
display: flex;
align-items: center;
justify-content: center;
font-family: var(--font-mono);
font-weight: 700;
font-size: 13px;
color: #0A0A0A;
}
.frame-brand { font-family: var(--font-mono); font-size: 13px; font-weight: 600; }
.frame-spacer { flex: 1; }
.frame-user { font-size: 11px; font-family: var(--font-mono); opacity: 0.6; }
.frame-hero { padding: 36px 32px 24px 32px; }
.frame-eyebrow { font-family: var(--font-mono); font-size: 10px; color: #5A5A55; letter-spacing: 0.12em; text-transform: uppercase; }
.frame-title { font-family: var(--font-display); font-size: 28px; font-weight: 600; letter-spacing: -0.02em; margin-top: 8px; }
.frame-tiles { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; margin-top: 20px; }
.frame-tile { background: #fff; border: 1px solid #E6E4DC; border-radius: 6px; padding: 14px; }
.frame-tile-icon {
width: 24px;
height: 24px;
border-radius: 5px;
background: #0A0A0A;
color: #F4F3EE;
display: inline-flex;
align-items: center;
justify-content: center;
font-family: var(--font-mono);
font-size: 11px;
}
.frame-tile-name { font-family: var(--font-display); font-weight: 600; font-size: 14px; margin-top: 12px; }
.frame-cta { margin-top: 24px; padding: 18px 20px; border-radius: 6px; display: flex; align-items: center; justify-content: space-between; }
.frame-cta-title { font-family: var(--font-display); font-weight: 600; font-size: 15px; color: #0A0A0A; }
.frame-cta-sub { font-size: 12px; color: rgba(10, 10, 10, 0.7); margin-top: 4px; }
.frame-cta-btn { height: 32px; padding: 0 14px; border-radius: 5px; border: none; background: #0A0A0A; color: #F4F3EE; font-weight: 600; font-size: 12px; cursor: pointer; }
.frame-foot { padding: 12px 32px; border-top: 1px solid #E6E4DC; background: #F4F3EE; font-size: 11px; color: #5A5A55; font-family: var(--font-mono); display: flex; justify-content: space-between; }
/* Upload modal */
.upload { display: flex; flex-direction: column; gap: 14px; }
.dropzone {
padding: 48px 24px;
background: var(--bg);
border: 2px dashed var(--border);
border-radius: 10px;
cursor: pointer;
font-family: inherit;
display: flex;
flex-direction: column;
align-items: center;
gap: 14px;
}
.dropzone.over { background: var(--surface); border-color: var(--text); }
.drop-text { text-align: center; }
.drop-title { font-size: 14px; font-weight: 500; color: var(--text); }
.upload-preview {
padding: 16px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
display: flex;
align-items: center;
gap: 14px;
}
.upload-mark {
height: 56px;
background: var(--text);
color: var(--bg);
border-radius: 6px;
display: inline-flex;
align-items: center;
justify-content: center;
font-family: var(--font-mono);
font-weight: 700;
font-size: 18px;
flex-shrink: 0;
}
.upload-meta { flex: 1; min-width: 0; }
.upload-name { font-size: 13px; font-weight: 500; }
.check-list { display: flex; flex-direction: column; gap: 6px; }
.check-row { display: flex; align-items: center; gap: 10px; padding: 8px 12px; background: var(--bg); border-radius: 6px; border: 1px solid var(--border); font-size: 12px; }
.check-row > :first-of-type { flex-shrink: 0; }
.ld-preview { padding: 12px; background: var(--bg); border-radius: 6px; border: 1px solid var(--border); }
.ld-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-top: 8px; }
.ld-light, .ld-dark { border-radius: 6px; padding: 18px; display: flex; align-items: center; justify-content: center; }
.ld-light { background: #FAFAF7; }
.ld-dark { background: #0A0A0A; }
.ld-mark {
height: 32px;
border-radius: 4px;
display: inline-flex;
align-items: center;
justify-content: center;
font-family: var(--font-mono);
font-weight: 700;
font-size: 12px;
}
.ld-mark.dark { background: #0A0A0A; color: #F4F3EE; }
.ld-mark.light { background: #F4F3EE; color: #0A0A0A; }
.req-box { padding: 12px; background: var(--bg); border-radius: 6px; border: 1px solid var(--border); font-size: 12px; color: var(--text-mute); line-height: 1.55; }
.req-body { margin-top: 6px; }
/* Email template editor */
.tmpl-edit { display: grid; grid-template-columns: 1fr 1fr; min-height: 0; height: 100%; }
.tmpl-col { padding: 24px; border-right: 1px solid var(--border); display: flex; flex-direction: column; gap: 14px; }
.tmpl-prev { padding: 24px; background: var(--bg); display: flex; flex-direction: column; gap: 12px; }
.body-area {
width: 100%;
min-height: 320px;
padding: 14px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
font-size: 13px;
color: var(--text);
font-family: var(--font-mono);
line-height: 1.6;
resize: vertical;
outline: none;
box-sizing: border-box;
}
.merge-tags { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
.merge-tags button {
padding: 4px 8px;
border-radius: 4px;
background: var(--surface);
border: 1px solid var(--border);
font-family: var(--font-mono);
font-size: 11px;
color: var(--text);
cursor: pointer;
}
.email-frame {
background: #fff;
border-radius: 8px;
overflow: hidden;
border: 1px solid #E6E4DC;
color: #0A0A0A;
font-family: 'Inter', sans-serif;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.06);
flex: 1;
display: flex;
flex-direction: column;
}
.email-head { padding: 16px 20px; border-bottom: 1px solid #E6E4DC; background: #FAFAF7; }
.from-row { display: flex; align-items: center; gap: 8px; }
.from-mark {
width: 22px;
height: 22px;
border-radius: 4px;
display: inline-flex;
align-items: center;
justify-content: center;
font-family: var(--font-mono);
font-weight: 700;
font-size: 11px;
}
.email-subj { font-family: var(--font-display); font-weight: 600; font-size: 16px; margin-top: 10px; color: #0A0A0A; }
.email-body { padding: 20px; font-size: 13px; line-height: 1.65; color: #3A3A35; white-space: pre-wrap; flex: 1; overflow-y: auto; }
.email-foot { padding: 14px 20px; border-top: 1px solid #E6E4DC; color: #0A0A0A; font-size: 11px; font-family: var(--font-mono); text-align: center; }
/* Publish modal */
.publish-intro { font-size: 13px; color: var(--text-dim); line-height: 1.55; margin-bottom: 14px; }
.publish-summary { padding: 14px; background: var(--bg); border-radius: 6px; border: 1px solid var(--border); display: flex; flex-direction: column; gap: 10px; margin-top: 8px; margin-bottom: 14px; }
.ps-row { display: flex; align-items: center; gap: 12px; }
.ps-row > :first-child { width: 100px; }
.color-line { display: inline-flex; align-items: center; gap: 8px; font-size: 13px; }
.color-chip { width: 14px; height: 14px; border-radius: 3px; border: 1px solid var(--border); }
.prop-grid { padding: 14px; background: var(--bg); border-radius: 6px; border: 1px solid var(--border); display: grid; grid-template-columns: 1fr 1fr; gap: 10px; font-size: 12px; margin-top: 8px; margin-bottom: 14px; }
.prop-cell { display: flex; align-items: center; gap: 8px; }
.prop-cell span:first-of-type { flex: 1; }
.publish-warn { padding: 12px; background: rgba(232, 154, 31, 0.06); border-radius: 6px; border: 1px solid rgba(232, 154, 31, 0.2); font-size: 12px; color: var(--text-dim); line-height: 1.55; display: flex; gap: 10px; }
.publishing { padding: 32px 0; text-align: center; }
.spinner {
width: 56px;
height: 56px;
margin: 0 auto 18px auto;
border-radius: 999px;
border: 3px solid var(--border);
border-top-color: var(--accent);
animation: spin 1s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg) } }
.publish-title { font-family: var(--font-display); font-size: 20px; font-weight: 600; }
.done-head { display: flex; align-items: center; gap: 14px; margin-bottom: 14px; }
.done-badge {
width: 44px;
height: 44px;
border-radius: 10px;
color: #0A0A0A;
display: inline-flex;
align-items: center;
justify-content: center;
}
.done-list { padding: 14px; background: var(--bg); border-radius: 6px; border: 1px solid var(--border); }
.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); }
/* Reset modal */
.reset-box { padding: 14px; border-radius: 6px; display: flex; gap: 10px; align-items: flex-start; margin-bottom: 14px; }
.reset-box.bad { background: rgba(226, 48, 48, 0.06); border: 1px solid rgba(226, 48, 48, 0.2); }
.reset-box > div { font-size: 13px; color: var(--text-dim); line-height: 1.5; }
.reset-list { padding: 12px; background: var(--bg); border-radius: 6px; border: 1px solid var(--border); }
</style>
+403
View File
@@ -0,0 +1,403 @@
<script setup lang="ts">
// Strict port of project/platform-collab.jsx `ChatScreen` (lines 261-435).
// 3 tabs: Workspaces / Channels / Retention, mirroring the source's data and
// per-tab structure.
import { chatWorkspaces, chatChannels } from '~/data/workspace'
const tab = ref<'workspaces' | 'channels' | 'retention'>('workspaces')
const newWsOpen = ref(false)
const openWs = ref<typeof chatWorkspaces[number] | null>(null)
const newExportOpen = ref(false)
const addOverrideOpen = ref(false)
const toast = useToast()
// Per-workspace and per-channel kebab actions — mirror source intent.
function wsAction(ws: typeof chatWorkspaces[number], id: string) {
if (id === 'manage') openWs.value = ws
else if (id === 'open') toast.info(`Opening ${ws.url}`)
else if (id === 'invite') toast.info(`Invite link copied for ${ws.name}`)
else if (id === 'archive') toast.warn(`${ws.name} archived`)
}
const wsItems = [
{ id: 'manage', label: 'Manage workspace', icon: 'brush' as const },
{ id: 'open', label: 'Open in browser', icon: 'external' as const },
{ id: 'invite', label: 'Copy invite link', icon: 'copy' as const },
{ id: 'sep1', separator: true },
{ id: 'archive', label: 'Archive workspace', icon: 'trash' as const, danger: true },
]
function channelAction(name: string, id: string) {
if (id === 'open') toast.info(`Opening #${name}`)
else if (id === 'rename') toast.info(`Rename #${name}`)
else if (id === 'archive') toast.warn(`#${name} archived`)
else if (id === 'delete') toast.bad(`#${name} deleted`)
}
const channelItems = [
{ id: 'open', label: 'Open channel', icon: 'external' as const },
{ id: 'rename', label: 'Rename', icon: 'brush' as const },
{ id: 'archive', label: 'Archive', icon: 'folder' as const },
{ id: 'sep1', separator: true },
{ id: 'delete', label: 'Delete channel', icon: 'trash' as const, danger: true },
]
function removeOverride(name: string) {
toast.info(`${name} override removed`)
}
const retention = ref<'30d' | '365d' | '3year' | 'forever'>('365d')
const retentionOptions = [
{ v: '30d' as const, label: '30 days', d: 'Short retention. Casual workspaces or strict privacy posture.' },
{ v: '365d' as const, label: '365 days · recommended', d: 'Useful for most teams. Channel history is searchable for a year.' },
{ v: '3year' as const, label: '3 years · Danish bookkeeping', d: 'Compliant with Danish accounting retention requirements.' },
{ v: 'forever' as const, label: 'Forever', d: 'No automatic deletion. Required for some legal/regulated industries.' },
]
const overrides = [
{ name: '#incidents', t: 'invert', r: 'forever', reason: 'Post-mortem evidence' },
{ name: '#dezky-roadmap', t: 'info', r: '3 years', reason: 'Product decisions log' },
{ name: '#random', t: 'neutral', r: '90 days', reason: 'Reduce noise' },
] as const
</script>
<template>
<div>
<PageHeader
eyebrow="Chat · Zulip"
title="Chat settings"
subtitle="Zulip workspaces, public channel visibility, and message retention policies."
/>
<div class="tab-wrap">
<Tabs
v-model="tab"
:items="[
{ value: 'workspaces', label: 'Workspaces', count: chatWorkspaces.length },
{ value: 'channels', label: 'Channels', count: chatChannels.length },
{ value: 'retention', label: 'Retention' },
]"
/>
</div>
<div class="content">
<template v-if="tab === 'workspaces'">
<div class="row">
<div class="lead">Workspaces let you separate communication scopes (e.g. company-wide vs. engineering-only). Members and channels are workspace-scoped.</div>
<UiButton variant="primary" @click="newWsOpen = true">
<template #leading><UiIcon name="plus" :size="14" /></template>
New workspace
</UiButton>
</div>
<div class="ws-grid">
<Card v-for="w in chatWorkspaces" :key="w.id">
<div class="ws-head">
<div class="ws-title">
<div class="ws-mark"><UiIcon name="chat" :size="18" /></div>
<div>
<div class="ws-name">
<span>{{ w.name }}</span>
<Badge v-if="w.primary" tone="invert">primary</Badge>
</div>
<Mono dim>{{ w.url }}</Mono>
</div>
</div>
<Badge tone="ok" dot>{{ w.status }}</Badge>
</div>
<div class="ws-stats">
<div><Eyebrow>Members</Eyebrow><div class="ws-num">{{ w.members }}</div></div>
<div><Eyebrow>Channels</Eyebrow><div class="ws-num">{{ w.channels }}</div></div>
<div><Eyebrow>30d msgs</Eyebrow><div class="ws-num">{{ w.messages30d.toLocaleString('da-DK') }}</div></div>
</div>
<div class="ws-actions">
<UiButton size="sm" variant="secondary" @click="toast.info(`Opening ${w.url}`)">
<template #leading><UiIcon name="external" :size="13" /></template>
Open
</UiButton>
<UiButton size="sm" variant="ghost" @click="openWs = w">Manage</UiButton>
<div class="spacer" />
<AdminKebabMenu :items="wsItems" @select="(id) => wsAction(w, id)" />
</div>
</Card>
</div>
</template>
<template v-else-if="tab === 'channels'">
<div class="ch-toolbar">
<div class="input-search">
<UiIcon name="search" :size="14" stroke="var(--text-mute)" />
<input placeholder="Search channels…" />
</div>
<button class="chip"><Eyebrow>Type:</Eyebrow><span>All</span><UiIcon name="chevDown" :size="12" stroke="var(--text-mute)" /></button>
<button class="chip"><Eyebrow>Workspace:</Eyebrow><span>All</span><UiIcon name="chevDown" :size="12" stroke="var(--text-mute)" /></button>
<div class="spacer" />
<Mono dim>{{ chatChannels.length }} channels</Mono>
</div>
<Card :pad="0">
<table class="tbl">
<thead>
<tr><th>Channel</th><th>Topic</th><th>Type</th><th>Members</th><th class="right">30d msgs</th><th>Owner</th><th /></tr>
</thead>
<tbody>
<tr v-for="c in chatChannels" :key="c.name">
<td>
<span class="ch-name" :class="{ priv: c.type === 'private' }">{{ c.type === 'private' ? '🔒' : '#' }} {{ c.name }}</span>
</td>
<td class="topic">{{ c.topic }}</td>
<td><Badge :tone="c.type === 'public' ? 'ok' : 'warn'">{{ c.type }}</Badge></td>
<td><Mono>{{ c.members }}</Mono></td>
<td class="right"><Mono>{{ c.messages30d.toLocaleString('da-DK') }}</Mono></td>
<td>
<div class="owner-cell">
<Avatar :name="c.owner" :size="18" />
<span>{{ c.owner }}</span>
</div>
</td>
<td class="right"><AdminKebabMenu :items="channelItems" @select="(id) => channelAction(c.name, id)" /></td>
</tr>
</tbody>
</table>
</Card>
</template>
<template v-else>
<div class="retention">
<Card>
<div class="card-head">
<Eyebrow>Retention</Eyebrow>
<div class="card-title">Message retention</div>
<div class="card-sub">Applied org-wide. Compliance overrides user-level deletion.</div>
</div>
<div class="radio-big">
<label v-for="o in retentionOptions" :key="o.v" :class="{ active: retention === o.v }">
<span class="radio-dot"><span v-if="retention === o.v" /></span>
<input type="radio" :value="o.v" v-model="retention" />
<div>
<div class="radio-label">{{ o.label }}</div>
<div class="radio-d">{{ o.d }}</div>
</div>
</label>
</div>
</Card>
<Card>
<div class="card-head card-head-inline">
<div>
<Eyebrow>Per-channel</Eyebrow>
<div class="card-title">Channel-level overrides</div>
</div>
<UiButton size="sm" variant="ghost" @click="addOverrideOpen = true">
<template #leading><UiIcon name="plus" :size="13" /></template>
Add override
</UiButton>
</div>
<Card :pad="0" surface="bg" style="margin-top: 12px">
<table class="tbl">
<thead>
<tr><th>Channel</th><th>Retention</th><th>Reason</th><th /></tr>
</thead>
<tbody>
<tr v-for="o in overrides" :key="o.name">
<td><Mono style="font-weight: 500">{{ o.name }}</Mono></td>
<td><Badge :tone="o.t as any" dot>{{ o.r }}</Badge></td>
<td class="topic">{{ o.reason }}</td>
<td class="right"><UiButton size="sm" variant="ghost" @click="removeOverride(o.name)"><UiIcon name="x" :size="12" /></UiButton></td>
</tr>
</tbody>
</table>
</Card>
</Card>
<Card>
<div class="card-head card-head-inline">
<div>
<Eyebrow>Export & e-discovery</Eyebrow>
<div class="card-title">Message export</div>
</div>
<UiButton size="sm" variant="secondary" @click="newExportOpen = true">New export</UiButton>
</div>
<div class="muted">Generate a signed ZIP of selected channels and date ranges for legal review or GDPR fulfillment. Available on Business and Enterprise plans.</div>
</Card>
</div>
</template>
</div>
<Modal :open="newWsOpen" eyebrow="Chat · workspaces" title="New workspace" size="md" @close="newWsOpen = false">
<div class="form-stack">
<label class="field"><Eyebrow>Name</Eyebrow><input class="input" placeholder="engineering" /></label>
<label class="field"><Eyebrow>URL</Eyebrow><input class="input" placeholder="eng.chat.dezky.com" /></label>
</div>
<template #footer>
<UiButton variant="ghost" @click="newWsOpen = false">Cancel</UiButton>
<UiButton variant="primary" @click="newWsOpen = false">Create workspace</UiButton>
</template>
</Modal>
<!-- Per-channel retention override -->
<Modal :open="addOverrideOpen" eyebrow="Chat · retention" title="Channel retention override" size="md" @close="addOverrideOpen = false">
<div class="form-stack">
<label class="field"><Eyebrow>Channel</Eyebrow><input class="input" placeholder="#incidents" /></label>
<label class="field"><Eyebrow>Retention</Eyebrow>
<select class="input">
<option>30 days</option><option>90 days</option><option>365 days</option><option>3 years</option><option>Forever</option>
</select>
</label>
<label class="field"><Eyebrow>Reason (audit)</Eyebrow><input class="input" placeholder="Post-mortem evidence" /></label>
</div>
<template #footer>
<UiButton variant="ghost" @click="addOverrideOpen = false">Cancel</UiButton>
<UiButton variant="primary" @click="addOverrideOpen = false; toast.ok('Override added')">Add override</UiButton>
</template>
</Modal>
<!-- New e-discovery export -->
<Modal :open="newExportOpen" eyebrow="Chat · export" title="New message export" size="md" @close="newExportOpen = false">
<div class="form-stack">
<label class="field"><Eyebrow>Channels</Eyebrow><input class="input" placeholder="#engineering, #incidents" /></label>
<label class="field"><Eyebrow>From</Eyebrow><input class="input" placeholder="2026-01-01" /></label>
<label class="field"><Eyebrow>To</Eyebrow><input class="input" placeholder="2026-12-31" /></label>
<label class="field"><Eyebrow>Format</Eyebrow>
<select class="input"><option>Signed ZIP · JSONL</option><option>Signed ZIP · HTML</option></select>
</label>
</div>
<template #footer>
<UiButton variant="ghost" @click="newExportOpen = false">Cancel</UiButton>
<UiButton variant="primary" @click="newExportOpen = false; toast.info('Export queued · you will be emailed when ready')">Create export</UiButton>
</template>
</Modal>
<SidePanel :open="!!openWs" eyebrow="Workspace" :title="openWs?.name || ''" width="lg" @close="openWs = null">
<div v-if="openWs" class="manage">
<div class="ws-head">
<div class="ws-title">
<div class="ws-mark big"><UiIcon name="chat" :size="22" /></div>
<div>
<div class="ws-name big">{{ openWs.name }}</div>
<Mono dim>{{ openWs.url }}</Mono>
</div>
</div>
<Badge tone="ok" dot>{{ openWs.status }}</Badge>
</div>
</div>
<template #footer>
<UiButton variant="ghost" @click="openWs = null">Close</UiButton>
<div style="flex: 1" />
<UiButton variant="primary" @click="openWs = null">Save changes</UiButton>
</template>
</SidePanel>
</div>
</template>
<style scoped>
.tab-wrap { padding: 16px 40px 0 40px; }
.content { padding: 20px 40px 64px 40px; }
.row { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; }
.lead { font-size: 13px; color: var(--text-mute); max-width: 540px; line-height: 1.5; }
.spacer { flex: 1; }
.ws-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 12px; }
.ws-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
.ws-title { display: flex; align-items: center; gap: 12px; }
.ws-mark {
width: 40px;
height: 40px;
border-radius: 8px;
background: var(--text);
color: var(--bg);
display: inline-flex;
align-items: center;
justify-content: center;
}
.ws-mark.big { width: 48px; height: 48px; border-radius: 10px; }
.ws-name { display: flex; align-items: center; gap: 6px; font-family: var(--font-display); font-weight: 600; font-size: 18px; }
.ws-name.big { font-size: 22px; }
.ws-stats {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
margin-top: 18px;
padding-top: 14px;
border-top: 1px solid var(--border);
}
.ws-num {
font-family: var(--font-display);
font-weight: 600;
font-size: 22px;
margin-top: 4px;
font-variant-numeric: tabular-nums;
}
.ws-actions { display: flex; gap: 8px; margin-top: 16px; align-items: center; }
.ch-toolbar { display: flex; gap: 12px; align-items: center; margin-bottom: 12px; flex-wrap: wrap; }
.input-search {
display: flex;
align-items: center;
gap: 8px;
padding: 0 12px;
height: 36px;
width: 280px;
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); }
.chip {
display: inline-flex;
align-items: center;
gap: 6px;
height: 36px;
padding: 0 12px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
font-size: 13px;
font-family: inherit;
color: var(--text);
cursor: pointer;
}
.chip span { font-weight: 500; }
.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; }
.topic { font-size: 12px; color: var(--text-mute); }
.ch-name { font-family: var(--font-mono); font-size: 13px; font-weight: 600; }
.ch-name.priv { color: var(--text-dim); }
.owner-cell { display: flex; align-items: center; gap: 6px; font-size: 12px; }
.retention { display: flex; flex-direction: column; gap: 16px; max-width: 900px; }
.card-head { margin-bottom: 14px; }
.card-head-inline { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
.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; }
.muted { font-size: 13px; color: var(--text-mute); line-height: 1.6; }
.radio-big { display: flex; flex-direction: column; gap: 8px; }
.radio-big label { display: flex; gap: 12px; padding: 14px; border: 1px solid var(--border); border-radius: 6px; cursor: pointer; }
.radio-big label.active { border-color: var(--text); background: var(--bg); }
.radio-big input { display: none; }
.radio-dot { width: 18px; height: 18px; border-radius: 999px; border: 2px solid var(--border); display: inline-flex; align-items: center; justify-content: center; flex-shrink: 0; margin-top: 2px; }
.radio-big label.active .radio-dot { border-color: var(--text); }
.radio-dot span { width: 8px; height: 8px; border-radius: 999px; background: var(--text); }
.radio-label { font-size: 14px; font-weight: 500; }
.radio-d { font-size: 12px; color: var(--text-mute); margin-top: 4px; }
.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; }
.manage { padding-bottom: 24px; }
</style>
+319
View File
@@ -0,0 +1,319 @@
<script setup lang="ts">
// Strict port of project/platform-app.jsx `DomainsScreen` (lines 440-585) +
// `DomainCard` (502) + `DomainRecordDetail` (586). Each domain card shows
// monospace name, status badge, "X records to fix" hint, Re-check button,
// and a 4-record grid (MX/SPF/DKIM/DMARC) clickable to expand inline detail.
import { sampleDomainsFlat } from '~/data/workspace'
const router = useRouter()
const toast = useToast()
type Tone = 'ok' | 'warn' | 'bad'
type RecordKey = 'mx' | 'spf' | 'dkim' | 'dmarc'
// DNS_FIX (platform-app.jsx line 459) — copy strings, record values, per-status headlines.
const DNS_FIX: Record<RecordKey, {
label: string
purpose: string
record: { type: string; host: string; value: string; priority?: number; ttl: number }
states: Record<Tone, { headline: string; body: string }>
}> = {
mx: {
label: 'MX · mail exchange',
purpose: 'Routes inbound mail for this domain to dezky.',
record: { type: 'MX', host: '@', value: 'mx.dezky.com', priority: 10, ttl: 3600 },
states: {
ok: { headline: 'Mail routing healthy', body: 'Inbound mail flows to dezky correctly. Verified 4 minutes ago.' },
warn: { headline: 'Lower-priority MX detected', body: 'A secondary MX outside of dezky was found. This is allowed for failover but make sure it forwards back to mx.dezky.com.' },
bad: { headline: 'No MX record found', body: 'Mail to this domain will not reach dezky. Add the record below at your DNS provider.' },
},
},
spf: {
label: 'SPF · sender policy',
purpose: 'Tells receiving servers which IPs are allowed to send for this domain.',
record: { type: 'TXT', host: '@', value: 'v=spf1 include:_spf.dezky.com -all', ttl: 3600 },
states: {
ok: { headline: 'SPF aligned', body: 'Your SPF record correctly authorises dezky as a sender. Verified 4 minutes ago.' },
warn: { headline: 'SPF includes dezky but ends with ~all (softfail)', body: 'Receiving mail servers may still accept spoofed mail. Change the trailing mechanism to -all (hardfail) for stronger protection.' },
bad: { headline: 'No SPF record', body: 'Mail sent from this domain via dezky will fail Gmail/Outlook authentication.' },
},
},
dkim: {
label: 'DKIM · message signing',
purpose: 'Cryptographic signature proving the message was not altered in transit.',
record: { type: 'CNAME', host: 'dezky._domainkey', value: 'dkim.dezky.com', ttl: 3600 },
states: {
ok: { headline: 'DKIM signing live', body: 'Outbound mail is signed with selector dezky. Verified 4 minutes ago.' },
warn: { headline: 'DKIM CNAME points somewhere else', body: 'A DKIM record exists but does not delegate to dezky. Replace it with the CNAME below.' },
bad: { headline: 'No DKIM record', body: 'Outbound mail will be signed but receiving servers cannot verify the signature.' },
},
},
dmarc: {
label: 'DMARC · policy enforcement',
purpose: 'Tells receiving servers what to do with mail that fails SPF or DKIM.',
record: { type: 'TXT', host: '_dmarc', value: 'v=DMARC1; p=quarantine; rua=mailto:dmarc@dezky.com; pct=100; adkim=s; aspf=s', ttl: 3600 },
states: {
ok: { headline: 'DMARC at quarantine', body: 'Spoofed mail will be sent to spam at Gmail/Outlook. Aggregate reports flowing.' },
warn: { headline: 'DMARC at p=none', body: 'Youre collecting reports but not enforcing. Raise to quarantine once your SPF/DKIM look stable for a week.' },
bad: { headline: 'No DMARC record', body: 'Anyone can spoof this domain. Mail from this domain may fail Gmail / Outlook spam checks.' },
},
},
}
const expanded = reactive<Record<string, RecordKey | null>>({})
const copied = ref<string | null>(null)
function toggle(domain: string, key: RecordKey) {
expanded[domain] = expanded[domain] === key ? null : key
}
function copyValue(text: string) {
if (typeof navigator !== 'undefined' && navigator.clipboard) navigator.clipboard.writeText(text)
copied.value = text
setTimeout(() => { if (copied.value === text) copied.value = null }, 1400)
toast.ok('Copied to clipboard')
}
function issuesFor(d: typeof sampleDomainsFlat[number]) {
return (['mx', 'spf', 'dkim', 'dmarc'] as const).filter((k) => d[k] !== 'ok')
}
function statusIcon(tone: Tone): 'check' | 'shield' | 'x' {
return tone === 'ok' ? 'check' : tone === 'warn' ? 'shield' : 'x'
}
function recordTint(tone: Tone) {
return tone === 'bad' ? 'rgba(226,48,48,0.12)'
: tone === 'warn' ? 'rgba(232,154,31,0.12)'
: 'rgba(91,140,90,0.12)'
}
</script>
<template>
<div>
<PageHeader
eyebrow="Identity"
title="Domains"
subtitle="Your verified domains for mail, SSO, and user provisioning."
>
<template #actions>
<UiButton variant="primary" @click="router.push('/admin/domains/add')">
<template #leading><UiIcon name="plus" :size="14" /></template>
Add domain
</UiButton>
</template>
</PageHeader>
<div class="content">
<Card v-for="d in sampleDomainsFlat" :key="d.domain">
<div class="head">
<UiIcon name="globe" :size="20" stroke="var(--text-mute)" />
<div class="title">
<div class="domain-name">{{ d.domain }}</div>
<div class="domain-sub">
{{ d.users }} mailboxes
<template v-if="issuesFor(d).length">
· <span class="warn">{{ issuesFor(d).length }} record{{ issuesFor(d).length === 1 ? '' : 's' }} to fix</span>
</template>
</div>
</div>
<UiButton v-if="issuesFor(d).length" size="sm" variant="secondary" @click.stop="toast.ok('Re-checking ' + d.domain)">
<template #leading><UiIcon name="refresh" :size="12" /></template>
Re-check now
</UiButton>
<Badge :tone="d.status === 'ok' ? 'ok' : 'warn'" dot>{{ d.status === 'ok' ? 'verified' : 'attention' }}</Badge>
</div>
<div class="records">
<button
v-for="k in (['mx', 'spf', 'dkim', 'dmarc'] as RecordKey[])"
:key="k"
class="rec"
:class="{ active: expanded[d.domain] === k }"
@click="toggle(d.domain, k)"
>
<Mono>{{ k.toUpperCase() }}</Mono>
<div class="rec-right">
<Badge :tone="d[k]" dot>{{ d[k] }}</Badge>
<UiIcon :name="expanded[d.domain] === k ? 'chevDown' : 'chevRight'" :size="11" stroke="var(--text-mute)" />
</div>
</button>
</div>
<div v-if="expanded[d.domain]" class="detail" :data-tone="d[expanded[d.domain]!]">
<div class="detail-head">
<div class="detail-icon" :style="{ background: recordTint(d[expanded[d.domain]!] as Tone), color: `var(--${d[expanded[d.domain]!]})` }">
<UiIcon :name="statusIcon(d[expanded[d.domain]!] as Tone)" :size="14" :stroke-width="d[expanded[d.domain]!] === 'ok' ? 2.5 : 2" />
</div>
<div class="detail-body">
<div class="detail-title">
{{ DNS_FIX[expanded[d.domain]!].states[d[expanded[d.domain]!] as Tone].headline }}
<Mono dim>{{ DNS_FIX[expanded[d.domain]!].label }}</Mono>
</div>
<div class="detail-text">{{ DNS_FIX[expanded[d.domain]!].states[d[expanded[d.domain]!] as Tone].body }}</div>
<Mono dim style="display: block; margin-top: 10px">{{ DNS_FIX[expanded[d.domain]!].purpose }}</Mono>
</div>
<button class="detail-close" @click="expanded[d.domain] = null"><UiIcon name="x" :size="14" /></button>
</div>
<template v-if="d[expanded[d.domain]!] !== 'ok'">
<div class="rec-action">
<Eyebrow>Add this record at your DNS provider</Eyebrow>
<div class="rec-grid">
<div class="rec-grid-label">Type</div>
<div class="rec-grid-val">{{ DNS_FIX[expanded[d.domain]!].record.type }}</div>
<div class="rec-grid-ttl">TTL {{ DNS_FIX[expanded[d.domain]!].record.ttl }}</div>
<div class="rec-grid-label sep">Host</div>
<div class="rec-grid-span sep">
<span>{{ DNS_FIX[expanded[d.domain]!].record.host }} <span class="muted">· resolves to {{ DNS_FIX[expanded[d.domain]!].record.host === '@' ? d.domain : `${DNS_FIX[expanded[d.domain]!].record.host}.${d.domain}` }}</span></span>
<button class="copy" @click="copyValue(DNS_FIX[expanded[d.domain]!].record.host)"><UiIcon name="copy" :size="12" /></button>
</div>
<div class="rec-grid-label sep">Value</div>
<div class="rec-grid-span sep">
<span class="break">{{ DNS_FIX[expanded[d.domain]!].record.value }}</span>
<button class="copy" @click="copyValue(DNS_FIX[expanded[d.domain]!].record.value)"><UiIcon name="copy" :size="12" /></button>
</div>
<template v-if="DNS_FIX[expanded[d.domain]!].record.priority !== undefined">
<div class="rec-grid-label sep">Priority</div>
<div class="rec-grid-span sep">{{ DNS_FIX[expanded[d.domain]!].record.priority }}</div>
</template>
</div>
<div class="rec-actions-row">
<UiButton size="sm" variant="primary" @click="copyValue(DNS_FIX[expanded[d.domain]!].record.value)">
<template #leading><UiIcon name="copy" :size="13" /></template>
{{ copied === DNS_FIX[expanded[d.domain]!].record.value ? 'Copied · paste at your DNS provider' : 'Copy record value' }}
</UiButton>
<UiButton size="sm" variant="secondary" @click="toast.info('Opening DNS provider guide…')">
<template #leading><UiIcon name="external" :size="13" /></template>
Open DNS guide
</UiButton>
<UiButton size="sm" variant="ghost" @click="toast.ok('Re-checking record')">
<template #leading><UiIcon name="refresh" :size="13" /></template>
Re-check this record
</UiButton>
<div class="spacer" />
<Mono dim>changes can take up to 24h to propagate</Mono>
</div>
</div>
</template>
<template v-else>
<div class="currently-set">
<Eyebrow>Currently set</Eyebrow>
<div class="set-value">{{ DNS_FIX[expanded[d.domain]!].record.value }}</div>
</div>
</template>
</div>
</Card>
</div>
</div>
</template>
<style scoped>
.content { padding: 24px 40px 64px 40px; display: flex; flex-direction: column; gap: 12px; }
.head { display: flex; align-items: center; gap: 16px; }
.title { flex: 1; min-width: 0; }
.domain-name { font-family: var(--font-mono); font-size: 16px; font-weight: 600; }
.domain-sub { font-size: 12px; color: var(--text-mute); margin-top: 2px; }
.warn { color: var(--warn); }
.records {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 8px;
margin-top: 20px;
padding-top: 20px;
border-top: 1px solid var(--border);
}
.rec {
padding: 10px 12px;
background: var(--bg);
border-radius: 6px;
cursor: pointer;
border: 1px solid transparent;
display: flex;
align-items: center;
justify-content: space-between;
font-family: inherit;
text-align: left;
transition: background 120ms, border-color 120ms;
}
.rec:hover { background: var(--surface); }
.rec.active { background: var(--surface); border-color: var(--text); }
.rec-right { display: flex; align-items: center; gap: 6px; }
.detail {
margin-top: 16px;
padding: 16px;
background: var(--bg);
border-radius: 6px;
border: 1px solid var(--border);
border-left: 3px solid var(--border);
}
.detail[data-tone='ok'] { border-left-color: var(--ok); }
.detail[data-tone='warn'] { border-left-color: var(--warn); }
.detail[data-tone='bad'] { border-left-color: var(--bad); }
.detail-head { display: flex; align-items: flex-start; gap: 12px; }
.detail-icon {
width: 32px;
height: 32px;
border-radius: 8px;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.detail-body { flex: 1; }
.detail-title {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
font-family: var(--font-display);
font-weight: 600;
font-size: 15px;
}
.detail-text { font-size: 13px; color: var(--text-dim); margin-top: 6px; line-height: 1.55; }
.detail-close { background: transparent; border: none; padding: 4px; border-radius: 4px; color: var(--text-mute); cursor: pointer; }
.rec-action { margin-top: 16px; }
.rec-grid {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
display: grid;
grid-template-columns: 70px 1fr 80px;
font-family: var(--font-mono);
font-size: 12px;
overflow: hidden;
margin-top: 8px;
}
.rec-grid-label { padding: 10px 12px; color: var(--text-mute); border-right: 1px solid var(--border); }
.rec-grid-label.sep { border-top: 1px solid var(--border); }
.rec-grid-val { padding: 10px 12px; border-right: 1px solid var(--border); }
.rec-grid-ttl { padding: 10px 12px; color: var(--text-mute); }
.rec-grid-span {
padding: 10px 12px;
grid-column: 2 / 4;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.rec-grid-span.sep { border-top: 1px solid var(--border); }
.break { word-break: break-all; }
.muted { color: var(--text-mute); }
.copy { background: transparent; border: none; padding: 4px; border-radius: 4px; color: var(--text-mute); cursor: pointer; }
.copy:hover { background: var(--bg); }
.rec-actions-row { display: flex; align-items: center; gap: 10px; margin-top: 12px; flex-wrap: wrap; }
.spacer { flex: 1; }
.currently-set { margin-top: 12px; padding: 12px; background: var(--surface); border-radius: 6px; border: 1px solid var(--border); }
.set-value { font-family: var(--font-mono); font-size: 12px; color: var(--text-dim); word-break: break-all; margin-top: 4px; }
</style>
+430
View File
@@ -0,0 +1,430 @@
<script setup lang="ts">
// Strict port of platform-flows.jsx `DomainSetupWizard` (lines 134-176) +
// step components 178-369. 6-step full-page route: Domain · Verify · Mail ·
// DKIM · DMARC · Done. Same step rail at the top, same DNS record rows and
// per-step copy.
const router = useRouter()
const step = ref(1)
const domain = ref('lyngby-biler.dk')
const policy = ref<'none' | 'quarantine' | 'reject'>('quarantine')
const steps = ['Domain', 'Verify', 'Mail', 'DKIM', 'DMARC', 'Done']
const dmarcValue = computed(() => `v=DMARC1; p=${policy.value}; rua=mailto:dmarc@${domain.value}; pct=100; adkim=s; aspf=s`)
const policyOptions = [
{ v: 'none' as const, l: 'none · monitor only', d: 'Reports failures but never blocks. Use only for the first 2 weeks while you confirm legitimate mail flows.' },
{ v: 'quarantine' as const, l: 'quarantine · recommended', d: 'Suspicious mail goes to spam. Catches almost all spoofing without breaking legitimate edge cases.' },
{ v: 'reject' as const, l: 'reject · strictest', d: "Suspicious mail is bounced. Use after you've been at quarantine for 30+ days with no surprises." },
]
function cancel() {
router.push('/admin/domains')
}
function done() {
router.push('/admin/domains')
}
</script>
<template>
<div class="wizard">
<div class="flow-head">
<div class="row top">
<div class="left">
<button v-if="step > 1 && step < 6" class="back" @click="step--">
<UiIcon name="chevLeft" :size="12" /> back
</button>
<Eyebrow>Add domain</Eyebrow>
</div>
<button class="cancel" @click="cancel">
<UiIcon name="x" :size="14" /> cancel
</button>
</div>
<div class="row title-row">
<h1>{{ step < 6 ? 'Verify and configure your domain' : `${domain} is ready` }}</h1>
<Mono dim>Step {{ step }} of 6</Mono>
</div>
<div class="rail">
<div v-for="(s, i) in steps" :key="s" class="rail-cell">
<div
class="bar"
:class="i + 1 < step ? 'done' : i + 1 === step ? 'active' : 'todo'"
/>
<div class="rail-label">
<Mono dim>0{{ i + 1 }}</Mono>
<span :class="i + 1 === step ? 'is-active' : i + 1 < step ? 'is-done' : 'is-todo'">{{ s }}</span>
</div>
</div>
</div>
</div>
<div class="body">
<!-- Step 1: Domain -->
<div v-if="step === 1" class="step1">
<p class="lead">
Enter the domain you'll use for mail and identity. You'll need to add a few DNS records to prove you own it and route mail correctly.
</p>
<label class="field">
<Eyebrow>Domain</Eyebrow>
<div class="input-wrap">
<UiIcon name="globe" :size="14" stroke="var(--text-mute)" />
<input v-model="domain" placeholder="acme.dk" />
</div>
</label>
<div class="info-box">
<Eyebrow>Need to know</Eyebrow>
<div class="info-body">
DNS changes typically propagate in 530 minutes<br />
You'll need access to your domain's DNS provider (Cloudflare, GoDaddy, etc.)<br />
For Danish .dk domains, you'll work with <Mono>DK-Hostmaster</Mono> or your registrar
</div>
</div>
</div>
<!-- Step 2: Verify -->
<div v-else-if="step === 2" class="step2">
<p class="lead">
Add this TXT record to <Mono>{{ domain }}</Mono>. We check every 30 seconds until it appears.
</p>
<div class="dns-rows">
<div class="dns-row">
<div><Mono dim>TYPE</Mono><div class="dns-val">TXT</div></div>
<div><Mono dim>HOST</Mono><div class="dns-val">_dezky-verify.{{ domain }}</div></div>
<div><Mono dim>VALUE</Mono><div class="dns-val dim">dezky-verify=8a3f9c2e-4b7d-4e1a-9c8f-2d6e1a3b5c7e</div></div>
<div class="dns-right">
<Badge tone="warn" dot>pending</Badge>
<button class="copy-btn"><UiIcon name="copy" :size="11" stroke="var(--text-mute)" /> COPY</button>
</div>
</div>
</div>
<div class="banner warn">
<UiIcon name="refresh" :size="14" stroke="var(--warn)" />
<div class="banner-body">
<div class="banner-title">Last check · 14:42:08 · still waiting</div>
<div class="banner-text">
We saw <Mono>NS · ns1.gratisdns.dk</Mono> but no TXT record at <Mono>_dezky-verify.{{ domain }}</Mono> yet. Add the record above and click verify, or wait — we'll check every 30 seconds.
</div>
</div>
<UiButton size="sm" variant="primary">Verify now</UiButton>
</div>
</div>
<!-- Step 3: Mail -->
<div v-else-if="step === 3" class="step3">
<p class="lead">
Add these records so mail to <Mono>@{{ domain }}</Mono> reaches dezky and outgoing mail is trusted.
</p>
<Eyebrow style="display: block; margin-top: 20px; margin-bottom: 10px">MX · inbound</Eyebrow>
<div class="dns-rows">
<div class="dns-row">
<div><Mono dim>TYPE</Mono><div class="dns-val">MX</div></div>
<div><Mono dim>HOST</Mono><div class="dns-val">{{ domain }}</div></div>
<div><Mono dim>VALUE</Mono><div class="dns-val dim">10 inbound.mx.dezky.com</div></div>
<div class="dns-right"><Badge tone="ok" dot>verified</Badge></div>
</div>
<div class="dns-row">
<div><Mono dim>TYPE</Mono><div class="dns-val">MX</div></div>
<div><Mono dim>HOST</Mono><div class="dns-val">{{ domain }}</div></div>
<div><Mono dim>VALUE</Mono><div class="dns-val dim">20 inbound-backup.mx.dezky.com</div></div>
<div class="dns-right"><Badge tone="ok" dot>verified</Badge></div>
</div>
</div>
<Eyebrow style="display: block; margin-top: 24px; margin-bottom: 10px">SPF · sender policy</Eyebrow>
<div class="dns-rows">
<div class="dns-row">
<div><Mono dim>TYPE</Mono><div class="dns-val">TXT</div></div>
<div><Mono dim>HOST</Mono><div class="dns-val">{{ domain }}</div></div>
<div><Mono dim>VALUE</Mono><div class="dns-val dim">v=spf1 include:_spf.dezky.com -all</div></div>
<div class="dns-right"><Badge tone="ok" dot>verified</Badge></div>
</div>
</div>
<div class="banner ok">
<UiIcon name="check" :size="14" stroke="var(--ok)" :stroke-width="2.5" />
<div class="banner-body">
<div class="banner-title">Mail routing verified</div>
<div class="banner-text">All MX and SPF records resolve correctly. Test by sending mail to <Mono>postmaster@{{ domain }}</Mono>.</div>
</div>
</div>
</div>
<!-- Step 4: DKIM -->
<div v-else-if="step === 4" class="step4">
<p class="lead">
DKIM signs every outgoing email so Gmail and Outlook trust it. Two records, then we'll rotate the keys for you automatically every 90 days.
</p>
<Eyebrow style="display: block; margin-top: 20px; margin-bottom: 10px">DKIM · selector 1</Eyebrow>
<div class="dns-rows">
<div class="dns-row">
<div><Mono dim>TYPE</Mono><div class="dns-val">CNAME</div></div>
<div><Mono dim>HOST</Mono><div class="dns-val">dezky1._domainkey.{{ domain }}</div></div>
<div><Mono dim>VALUE</Mono><div class="dns-val dim">dezky1.dkim.dezky.com</div></div>
<div class="dns-right"><Badge tone="ok" dot>verified</Badge></div>
</div>
<div class="dns-row">
<div><Mono dim>TYPE</Mono><div class="dns-val">CNAME</div></div>
<div><Mono dim>HOST</Mono><div class="dns-val">dezky2._domainkey.{{ domain }}</div></div>
<div><Mono dim>VALUE</Mono><div class="dns-val dim">dezky2.dkim.dezky.com</div></div>
<div class="dns-right"><Badge tone="ok" dot>verified</Badge></div>
</div>
</div>
<div class="banner ok">
<UiIcon name="check" :size="14" stroke="var(--ok)" :stroke-width="2.5" />
<div class="banner-body">
<div class="banner-title">DKIM is signing</div>
<div class="banner-text">Selectors verified · key rotation enabled · next rotation 14 Aug 2026.</div>
</div>
</div>
</div>
<!-- Step 5: DMARC -->
<div v-else-if="step === 5" class="step5">
<p class="lead">
DMARC tells receiving servers what to do with email that fails authentication. We strongly recommend at least <Mono>quarantine</Mono>.
</p>
<Eyebrow style="display: block; margin-top: 20px; margin-bottom: 10px">Choose policy</Eyebrow>
<div class="policy-list">
<label v-for="p in policyOptions" :key="p.v" :class="{ active: policy === p.v }">
<span class="radio-dot"><span v-if="policy === p.v" /></span>
<input type="radio" :value="p.v" v-model="policy" />
<div>
<div class="policy-label">{{ p.l }}</div>
<div class="policy-d">{{ p.d }}</div>
</div>
</label>
</div>
<Eyebrow style="display: block; margin-top: 24px; margin-bottom: 10px">Add this record</Eyebrow>
<div class="dns-rows">
<div class="dns-row">
<div><Mono dim>TYPE</Mono><div class="dns-val">TXT</div></div>
<div><Mono dim>HOST</Mono><div class="dns-val">_dmarc.{{ domain }}</div></div>
<div><Mono dim>VALUE</Mono><div class="dns-val dim">{{ dmarcValue }}</div></div>
<div class="dns-right"><button class="copy-btn"><UiIcon name="copy" :size="11" stroke="var(--text-mute)" /> COPY</button></div>
</div>
</div>
</div>
<!-- Step 6: Done -->
<div v-else class="step6">
<div class="check-badge">
<UiIcon name="check" :size="36" :stroke-width="2.5" />
</div>
<h2>{{ domain }} is connected.</h2>
<p class="lead-center">
Mail is routing. DKIM is signing. DMARC is enforcing. You can now invite users on this domain and they'll receive working email immediately.
</p>
<div class="summary-grid">
<div v-for="k in ['MX', 'SPF', 'DKIM', 'DMARC']" :key="k" class="summary-cell">
<Badge tone="ok" dot>verified</Badge>
<Mono>{{ k }}</Mono>
</div>
</div>
</div>
</div>
<div class="footer">
<template v-if="step < 6">
<UiButton variant="ghost" @click="cancel">Save and exit</UiButton>
<div class="spacer" />
<UiButton v-if="step === 5" variant="secondary" @click="step = 6">Skip DMARC for now</UiButton>
<UiButton variant="primary" @click="step++">
<template v-if="step === 5" #leading><UiIcon name="check" :size="13" /></template>
{{ step === 1 ? 'Continue' : step === 5 ? 'Add DMARC & finish' : 'Verified · continue' }}
<template v-if="step < 5" #trailing><UiIcon name="arrowRight" :size="13" /></template>
</UiButton>
</template>
<template v-else>
<div class="spacer" />
<UiButton variant="secondary" @click="done">
<template #leading><UiIcon name="users" :size="13" /></template>
Invite users on this domain
</UiButton>
<UiButton variant="primary" @click="done">Back to domains</UiButton>
</template>
</div>
</div>
</template>
<style scoped>
.wizard { display: flex; flex-direction: column; min-height: 100%; }
.flow-head { border-bottom: 1px solid var(--border); }
.row { display: flex; align-items: center; justify-content: space-between; gap: 24px; }
.row.top { padding: 14px 32px; }
.row.title-row { padding: 0 32px 18px 32px; align-items: flex-end; }
.left { display: flex; align-items: center; gap: 14px; }
.back, .cancel {
background: transparent;
border: none;
padding: 0;
display: inline-flex;
align-items: center;
gap: 6px;
color: var(--text-mute);
font-size: 12px;
cursor: pointer;
font-family: var(--font-mono);
}
.cancel { padding: 6px; font-family: inherit; }
.row.title-row h1 {
font-family: var(--font-display);
font-weight: 600;
font-size: 28px;
letter-spacing: -0.025em;
margin: 0;
line-height: 1.05;
}
.rail { padding: 0 32px 18px 32px; display: flex; gap: 6px; }
.rail-cell { flex: 1; display: flex; flex-direction: column; gap: 6px; }
.bar { height: 3px; border-radius: 2px; }
.bar.done { background: var(--text); }
.bar.active { background: var(--accent); }
.bar.todo { background: var(--border); }
.rail-label { display: flex; align-items: center; gap: 6px; font-size: 12px; }
.is-active { font-weight: 600; color: var(--text); }
.is-done { color: var(--text); }
.is-todo { color: var(--text-mute); }
.body { flex: 1; padding: 24px 32px; max-width: 920px; margin: 0 auto; width: 100%; }
.lead { color: var(--text-dim); font-size: 14px; line-height: 1.6; margin-top: 0; }
.lead-center { color: var(--text-dim); font-size: 15px; line-height: 1.6; margin-top: 12px; max-width: 500px; margin-inline: auto; }
.field { display: flex; flex-direction: column; gap: 6px; max-width: 520px; }
.input-wrap {
display: flex;
align-items: center;
gap: 8px;
padding: 0 12px;
height: 36px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
}
.input-wrap input { flex: 1; border: none; outline: none; background: transparent; font-family: inherit; font-size: 13px; color: var(--text); }
.info-box {
margin-top: 18px;
padding: 14px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 6px;
max-width: 520px;
}
.info-body { margin-top: 10px; font-size: 13px; color: var(--text-dim); line-height: 1.65; }
.dns-rows { display: flex; flex-direction: column; gap: 8px; }
.dns-row {
display: grid;
grid-template-columns: 80px 220px 1fr 90px;
gap: 12px;
align-items: center;
padding: 12px 14px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 6px;
}
.dns-val { font-family: var(--font-mono); font-size: 13px; font-weight: 600; margin-top: 2px; }
.dns-val.dim { color: var(--text-dim); font-weight: 400; font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.dns-right { display: flex; flex-direction: column; align-items: flex-end; gap: 6px; }
.copy-btn {
background: transparent;
border: 1px solid var(--border);
border-radius: 4px;
padding: 4px 8px;
font-size: 11px;
font-family: var(--font-mono);
color: var(--text-dim);
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 4px;
}
.banner {
margin-top: 16px;
padding: 14px;
border-radius: 6px;
display: flex;
align-items: flex-start;
gap: 12px;
}
.banner.warn { background: rgba(232, 154, 31, 0.06); border: 1px solid rgba(232, 154, 31, 0.24); border-left: 3px solid var(--warn); }
.banner.ok { background: rgba(31, 138, 91, 0.06); border: 1px solid rgba(31, 138, 91, 0.24); border-left: 3px solid var(--ok); }
.banner-body { flex: 1; font-size: 13px; }
.banner-title { font-weight: 600; }
.banner-text { color: var(--text-dim); margin-top: 4px; line-height: 1.5; }
.policy-list { display: flex; flex-direction: column; gap: 8px; }
.policy-list label {
display: flex;
gap: 14px;
padding: 14px;
border: 1px solid var(--border);
background: var(--surface);
border-radius: 6px;
cursor: pointer;
}
.policy-list label.active { background: var(--bg); border-color: var(--text); }
.policy-list input { display: none; }
.radio-dot {
width: 16px;
height: 16px;
border-radius: 999px;
border: 2px solid var(--border-hi, var(--border));
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
margin-top: 2px;
}
.policy-list label.active .radio-dot { border-color: var(--text); }
.radio-dot span { width: 7px; height: 7px; border-radius: 999px; background: var(--text); }
.policy-label { font-size: 13px; font-weight: 600; }
.policy-d { font-size: 12px; color: var(--text-mute); margin-top: 4px; line-height: 1.5; }
.step6 { max-width: 680px; text-align: center; padding: 60px 0; margin: 0 auto; }
.check-badge {
width: 72px;
height: 72px;
border-radius: 16px;
background: var(--accent);
color: var(--accent-fg);
display: inline-flex;
align-items: center;
justify-content: center;
margin-bottom: 24px;
}
.step6 h2 {
font-family: var(--font-display);
font-weight: 600;
font-size: 36px;
letter-spacing: -0.025em;
margin: 0;
line-height: 1.05;
}
.summary-grid {
display: inline-grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
margin-top: 36px;
padding: 16px 24px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
text-align: left;
}
.summary-cell { display: flex; align-items: center; gap: 6px; }
.footer {
border-top: 1px solid var(--border);
padding: 14px 32px;
display: flex;
align-items: center;
gap: 8px;
background: var(--surface);
position: sticky;
bottom: 0;
}
.spacer { flex: 1; }
</style>
+461
View File
@@ -0,0 +1,461 @@
<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>
+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>
+559
View File
@@ -0,0 +1,559 @@
<script setup lang="ts">
// Strict port of project/platform-admin.jsx `MailSettingsScreen` (lines 76-305).
// 5 tabs: Aliases / Forwarding / Filters · anti-spam / Distribution lists /
// Compliance · retention. Each uses the source's data and copy verbatim.
import {
orgAliases,
forwardingRules,
antiSpamFilters,
distributionLists,
} from '~/data/workspace'
const tab = ref<'aliases' | 'forwarding' | 'filters' | 'lists' | 'compliance'>('aliases')
const toast = useToast()
const addAliasOpen = ref(false)
const ruleOpen = ref(false)
const filterOpen = ref(false)
const listOpen = ref(false)
const holdOpen = ref(false)
const openList = ref<typeof distributionLists[number] | null>(null)
const deleteListOpen = ref(false)
// Track each forwarding rule's enabled flag locally so the toggle visually flips.
const ruleEnabled = reactive<Record<string, boolean>>(
Object.fromEntries(forwardingRules.map((r) => [r.name, r.enabled])),
)
const filterEnabled = reactive<Record<string, boolean>>(
Object.fromEntries(antiSpamFilters.map((f) => [f.name, f.enabled])),
)
async function copyAlias(alias: string) {
try {
await navigator.clipboard.writeText(alias)
toast.ok('Alias copied', alias)
} catch {
toast.warn('Copy failed', 'Select and copy manually')
}
}
function aliasAction(alias: string, id: string) {
if (id === 'edit') addAliasOpen.value = true
else if (id === 'copy') copyAlias(alias)
else if (id === 'disable') toast.info(`${alias} disabled`)
else if (id === 'delete') toast.bad(`${alias} deleted`)
}
const aliasItems = [
{ id: 'edit', label: 'Edit alias', icon: 'brush' as const },
{ id: 'copy', label: 'Copy address', icon: 'copy' as const },
{ id: 'disable', label: 'Disable alias', icon: 'x' as const },
{ id: 'sep1', separator: true },
{ id: 'delete', label: 'Delete alias', icon: 'trash' as const, danger: true },
]
function ruleAction(name: string, id: string) {
if (id === 'edit') ruleOpen.value = true
else if (id === 'run') toast.info(`Running "${name}" once`)
else if (id === 'duplicate') toast.ok(`"${name}" duplicated`)
else if (id === 'delete') toast.bad(`"${name}" deleted`)
}
const ruleItems = [
{ id: 'edit', label: 'Edit rule', icon: 'brush' as const },
{ id: 'run', label: 'Run once now', icon: 'refresh' as const },
{ id: 'duplicate', label: 'Duplicate', icon: 'copy' as const },
{ id: 'sep1', separator: true },
{ id: 'delete', label: 'Delete rule', icon: 'trash' as const, danger: true },
]
function filterAction(name: string, id: string) {
if (id === 'edit') filterOpen.value = true
else if (id === 'duplicate') toast.ok(`"${name}" duplicated`)
else if (id === 'delete') toast.bad(`"${name}" deleted`)
}
const filterItems = [
{ id: 'edit', label: 'Edit filter', icon: 'brush' as const },
{ id: 'duplicate', label: 'Duplicate', icon: 'copy' as const },
{ id: 'sep1', separator: true },
{ id: 'delete', label: 'Delete filter', icon: 'trash' as const, danger: true },
]
function confirmDeleteList() {
deleteListOpen.value = false
const name = openList.value?.name
openList.value = null
toast.bad(`${name} deleted`)
}
const retention = ref<'30d' | '1year' | '3year' | 'unlimited'>('3year')
const retentionOptions = [
{ v: '30d' as const, label: '30 days', d: 'Standard retention. Anything older is permanently deleted.' },
{ v: '1year' as const, label: '1 year', d: 'Mid-term. Suitable for most non-regulated businesses.' },
{ v: '3year' as const, label: '3 years · recommended', d: 'Danish bookkeeping retention compliant (5-year option also available).' },
{ v: 'unlimited' as const, label: 'Unlimited', d: 'Required for regulated industries (legal, healthcare, public sector).' },
]
const tabs = [
{ value: 'aliases', label: 'Aliases', count: orgAliases.length },
{ value: 'forwarding', label: 'Forwarding', count: forwardingRules.length },
{ value: 'filters', label: 'Filters · anti-spam', count: antiSpamFilters.length },
{ value: 'lists', label: 'Distribution lists', count: distributionLists.length },
{ value: 'compliance', label: 'Compliance · retention' },
]
function toneFor(action: string): 'bad' | 'warn' | 'info' {
return action === 'reject' ? 'bad' : action === 'quarantine' ? 'warn' : 'info'
}
</script>
<template>
<div>
<PageHeader
eyebrow="Workspace"
title="Mail settings"
subtitle="Organization-level aliases, forwarding, content filters, and compliance policies."
/>
<div class="tab-wrap">
<Tabs v-model="tab" :items="tabs" />
</div>
<div class="content">
<!-- ALIASES -->
<template v-if="tab === 'aliases'">
<div class="row">
<div class="lead">Aliases route mail to existing users or distribution lists. They count against your domain, not your seats.</div>
<UiButton variant="primary" @click="addAliasOpen = true">
<template #leading><UiIcon name="plus" :size="14" /></template>
Add alias
</UiButton>
</div>
<Card :pad="0">
<table class="tbl">
<thead><tr><th>Alias</th><th></th><th>Destination</th><th>State</th><th>Created</th><th></th></tr></thead>
<tbody>
<tr v-for="r in orgAliases" :key="r.alias">
<td><Mono style="font-weight: 500">{{ r.alias }}</Mono></td>
<td><UiIcon name="arrowRight" :size="12" stroke="var(--text-mute)" /></td>
<td>{{ r.dest }}</td>
<td><Badge :tone="r.active ? 'ok' : 'neutral'" dot>{{ r.active ? 'active' : 'paused' }}</Badge></td>
<td><Mono dim>{{ r.created }}</Mono></td>
<td class="right">
<UiButton size="sm" variant="ghost" @click="copyAlias(r.alias)"><UiIcon name="copy" :size="13" /></UiButton>
<AdminKebabMenu :items="aliasItems" :icon-size="13" @select="(id) => aliasAction(r.alias, id)" />
</td>
</tr>
</tbody>
</table>
</Card>
</template>
<!-- FORWARDING -->
<template v-else-if="tab === 'forwarding'">
<div class="row">
<div class="lead">Conditional rules applied to all incoming mail. Useful for routing customer inquiries or auto-escalating.</div>
<UiButton variant="primary" @click="ruleOpen = true">
<template #leading><UiIcon name="plus" :size="14" /></template>
New rule
</UiButton>
</div>
<div class="rules">
<Card v-for="r in forwardingRules" :key="r.name" :pad="16">
<div class="rule-row">
<button class="toggle" :class="{ on: ruleEnabled[r.name] }" @click="ruleEnabled[r.name] = !ruleEnabled[r.name]"><span /></button>
<div class="rule-meta">
<div class="rule-name">{{ r.name }}</div>
<div class="rule-line">
<Mono dim>WHEN</Mono>
<span class="rule-match">{{ r.match }}</span>
<UiIcon name="arrowRight" :size="11" stroke="var(--text-mute)" />
<Mono dim>FORWARD TO</Mono>
<Mono>{{ r.fwd }}</Mono>
</div>
</div>
<UiButton size="sm" variant="ghost" @click="ruleOpen = true">Edit</UiButton>
<AdminKebabMenu :items="ruleItems" @select="(id) => ruleAction(r.name, id)" />
</div>
</Card>
</div>
</template>
<!-- FILTERS -->
<template v-else-if="tab === 'filters'">
<div class="row">
<div class="lead">Org-wide content filters apply <i>before</i> user-level rules. Stalwart's spam engine handles the rest automatically.</div>
<UiButton variant="primary" @click="filterOpen = true">
<template #leading><UiIcon name="plus" :size="14" /></template>
New filter
</UiButton>
</div>
<Card :pad="0">
<table class="tbl">
<thead><tr><th>Filter</th><th>Match</th><th>Action</th><th></th></tr></thead>
<tbody>
<tr v-for="f in antiSpamFilters" :key="f.name">
<td>
<div class="filter-name">
<button class="toggle" :class="{ on: filterEnabled[f.name] }" @click="filterEnabled[f.name] = !filterEnabled[f.name]"><span /></button>
<span>{{ f.name }}</span>
</div>
</td>
<td><Mono dim>{{ f.match }}</Mono></td>
<td><Badge :tone="toneFor(f.action)">{{ f.action }}</Badge></td>
<td class="right"><AdminKebabMenu :items="filterItems" @select="(id) => filterAction(f.name, id)" /></td>
</tr>
</tbody>
</table>
</Card>
<div class="builtin">
<UiIcon name="shield" :size="16" stroke="var(--ok)" />
<div>
<div class="builtin-title">Built-in spam protection · enabled</div>
<div class="builtin-sub">
Stalwart's reputation engine and Bayesian filter block ~94% of spam at the edge. <Mono>last 7d: 12,840 blocked · 18 quarantined · 0 false positives reported</Mono>
</div>
</div>
</div>
</template>
<!-- LISTS -->
<template v-else-if="tab === 'lists'">
<div class="row">
<div class="lead">Distribution lists send mail to many recipients via a single alias. Members can be internal users, groups, or external addresses.</div>
<UiButton variant="primary" @click="listOpen = true">
<template #leading><UiIcon name="plus" :size="14" /></template>
New list
</UiButton>
</div>
<div class="lists">
<Card v-for="l in distributionLists" :key="l.alias">
<div class="list-head">
<div>
<div class="list-title">
<UiIcon name="users" :size="16" stroke="var(--text-mute)" />
<span class="list-name">{{ l.name }}</span>
<Badge v-if="l.external" tone="warn">external members</Badge>
</div>
<Mono dim style="display: block; margin-top: 4px">{{ l.alias }}</Mono>
</div>
<Badge :tone="l.moderation === 'open' ? 'ok' : 'neutral'">{{ l.moderation }}</Badge>
</div>
<div class="list-row">
<div>
<Eyebrow>Members</Eyebrow>
<div class="list-num">{{ l.members }}</div>
</div>
<div class="list-owner">
<Eyebrow>Owner</Eyebrow>
<div>{{ l.owner }}</div>
</div>
<UiButton size="sm" variant="secondary" @click="openList = l">Manage</UiButton>
</div>
</Card>
</div>
</template>
<!-- COMPLIANCE -->
<template v-else>
<div class="compliance">
<Card>
<div class="card-head">
<Eyebrow>Retention</Eyebrow>
<div class="card-title">Mail retention policy</div>
<div class="card-sub">Applied org-wide. Compliance requirements override user-level deletion.</div>
</div>
<div class="radio-big">
<label v-for="o in retentionOptions" :key="o.v" :class="{ active: retention === o.v }">
<span class="radio-dot"><span v-if="retention === o.v" /></span>
<input type="radio" :value="o.v" v-model="retention" />
<div>
<div class="radio-label">{{ o.label }}</div>
<div class="radio-d">{{ o.d }}</div>
</div>
</label>
</div>
</Card>
<Card>
<div class="card-head card-head-inline">
<div>
<Eyebrow>Journaling</Eyebrow>
<div class="card-title">Mail journaling</div>
<div class="card-sub">Copy every inbound and outbound mail to a journal mailbox for e-discovery.</div>
</div>
<button class="toggle"><span /></button>
</div>
<div class="muted">
When enabled, journals are written to <Mono>journal@dezky.com</Mono>. Storage usage counts against your plan. Available on Business and Enterprise plans.
</div>
</Card>
<Card>
<div class="card-head card-head-inline">
<div>
<Eyebrow>Legal hold</Eyebrow>
<div class="card-title">Legal hold cases</div>
</div>
<UiButton size="sm" variant="secondary" @click="holdOpen = true">
<template #leading><UiIcon name="plus" :size="13" /></template>
New hold
</UiButton>
</div>
<div class="empty">
<UiIcon name="shield" :size="28" stroke="var(--text-mute)" />
<div class="empty-title">No active holds</div>
<div class="empty-body">When legal review is needed, place a hold on specific users or date ranges to prevent deletion.</div>
</div>
</Card>
</div>
</template>
</div>
<!-- Add alias modal (stub) -->
<Modal :open="addAliasOpen" eyebrow="Mail · aliases" title="Add alias" size="md" @close="addAliasOpen = false">
<div class="form-stack">
<label class="field"><Eyebrow>Alias address</Eyebrow>
<div class="alias-row">
<input class="input" value="marketing" placeholder="prefix" />
<span class="at">@</span>
<select class="input"><option>dezky.com</option><option>baslund.dk</option></select>
</div>
</label>
<label class="field"><Eyebrow>Route to user</Eyebrow><input class="input" value="frederik@dezky.com" /></label>
</div>
<template #footer>
<UiButton variant="ghost" @click="addAliasOpen = false">Cancel</UiButton>
<UiButton variant="primary" @click="addAliasOpen = false">
<template #leading><UiIcon name="check" :size="13" /></template>
Create alias
</UiButton>
</template>
</Modal>
<!-- Forwarding rule modal -->
<Modal :open="ruleOpen" eyebrow="Mail · forwarding" title="New forwarding rule" size="md" @close="ruleOpen = false">
<div class="form-stack">
<label class="field"><Eyebrow>Rule name</Eyebrow><input class="input" placeholder="Out-of-hours to on-call" /></label>
<label class="field"><Eyebrow>When</Eyebrow><input class="input" placeholder="subject: …" /></label>
<label class="field"><Eyebrow>Forward to</Eyebrow><input class="input" placeholder="oncall@dezky.com" /></label>
</div>
<template #footer>
<UiButton variant="ghost" @click="ruleOpen = false">Cancel</UiButton>
<UiButton variant="primary" @click="ruleOpen = false">
<template #leading><UiIcon name="check" :size="13" /></template>
Save rule
</UiButton>
</template>
</Modal>
<!-- New filter modal -->
<Modal :open="filterOpen" eyebrow="Mail · filters" title="New content filter" size="md" @close="filterOpen = false">
<div class="form-stack">
<label class="field"><Eyebrow>Filter name</Eyebrow><input class="input" placeholder="Block executable attachments" /></label>
<label class="field"><Eyebrow>Match expression</Eyebrow><input class="input" placeholder="attachment ext in (.exe, .scr, .bat)" /></label>
<label class="field"><Eyebrow>Action</Eyebrow>
<select class="input"><option>reject</option><option>quarantine</option><option>add tag</option></select>
</label>
</div>
<template #footer>
<UiButton variant="ghost" @click="filterOpen = false">Cancel</UiButton>
<UiButton variant="primary" @click="filterOpen = false">Create filter</UiButton>
</template>
</Modal>
<!-- New list modal -->
<Modal :open="listOpen" eyebrow="Mail · lists" title="New distribution list" size="md" @close="listOpen = false">
<div class="form-stack">
<label class="field"><Eyebrow>List name</Eyebrow><input class="input" placeholder="Engineering" /></label>
<label class="field"><Eyebrow>Alias</Eyebrow><input class="input" placeholder="eng@dezky.com" /></label>
<label class="field"><Eyebrow>Owner</Eyebrow><input class="input" value="Anne Baslund" /></label>
</div>
<template #footer>
<UiButton variant="ghost" @click="listOpen = false">Cancel</UiButton>
<UiButton variant="primary" @click="listOpen = false">Create list</UiButton>
</template>
</Modal>
<!-- Manage list side panel -->
<SidePanel :open="!!openList" eyebrow="Distribution list" :title="openList?.name || ''" width="lg" @close="openList = null">
<div v-if="openList" class="manage">
<div class="manage-head">
<div class="manage-icon"><UiIcon name="users" :size="20" /></div>
<div class="manage-meta">
<div class="manage-name">{{ openList.name }}</div>
<Mono dim>{{ openList.alias }}</Mono>
</div>
<Badge :tone="openList.moderation === 'open' ? 'ok' : 'neutral'">{{ openList.moderation }}</Badge>
</div>
<div class="manage-stats">
<div><Eyebrow>Members</Eyebrow><div class="ms-v">{{ openList.members }}</div></div>
<div><Eyebrow>Owner</Eyebrow><div class="ms-v">{{ openList.owner }}</div></div>
<div><Eyebrow>Posts this week</Eyebrow><div class="ms-v">{{ openList.members > 8 ? '142' : openList.members > 2 ? '38' : '6' }}</div></div>
</div>
</div>
<template #footer>
<UiButton variant="danger" @click="deleteListOpen = true">
<template #leading><UiIcon name="trash" :size="13" /></template>
Delete list
</UiButton>
<div style="flex: 1" />
<UiButton variant="secondary" @click="openList = null">Discard</UiButton>
<UiButton variant="primary" @click="openList = null; toast.ok('List saved')">
<template #leading><UiIcon name="check" :size="13" /></template>
Save changes
</UiButton>
</template>
</SidePanel>
<!-- Confirm delete list -->
<ConfirmDialog
:open="deleteListOpen"
eyebrow="Distribution list"
:title="`Delete ${openList?.name || ''}?`"
confirm-label="Delete list"
tone="danger"
@close="deleteListOpen = false"
@confirm="confirmDeleteList"
>
Mail sent to this list will start bouncing immediately. Existing replies in members' inboxes are unaffected.
</ConfirmDialog>
<!-- Legal hold modal -->
<Modal :open="holdOpen" eyebrow="Compliance · legal hold" title="Place legal hold" size="md" @close="holdOpen = false">
<div class="form-stack">
<label class="field"><Eyebrow>Case name</Eyebrow><input class="input" placeholder="Case 2026-Q3-DPA-001" /></label>
<label class="field"><Eyebrow>Scope · users</Eyebrow><input class="input" placeholder="anne@, mikkel@, frederik@" /></label>
<label class="field"><Eyebrow>Date range</Eyebrow><input class="input" placeholder="2026-01-01 → 2026-12-31" /></label>
</div>
<template #footer>
<UiButton variant="ghost" @click="holdOpen = false">Cancel</UiButton>
<UiButton variant="primary" @click="holdOpen = false">Place hold</UiButton>
</template>
</Modal>
</div>
</template>
<style scoped>
.tab-wrap { padding: 16px 40px 0 40px; }
.content { padding: 20px 40px 64px 40px; }
.row { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; }
.lead { font-size: 13px; color: var(--text-mute); max-width: 540px; line-height: 1.5; }
.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; }
.tbl tr:last-child td { border-bottom: none; }
.tbl .right { text-align: right; }
.rules { display: flex; flex-direction: column; gap: 10px; }
.rule-row { display: flex; align-items: center; gap: 14px; }
.rule-meta { flex: 1; }
.rule-name { font-size: 14px; font-weight: 500; }
.rule-line { display: flex; align-items: center; gap: 8px; margin-top: 6px; font-size: 12px; }
.rule-match { font-family: var(--font-mono); color: var(--text-dim); }
.toggle {
width: 32px;
height: 18px;
border-radius: 999px;
background: var(--border);
border: none;
position: relative;
cursor: pointer;
padding: 0;
flex-shrink: 0;
}
.toggle span {
position: absolute;
top: 2px;
left: 2px;
width: 14px;
height: 14px;
border-radius: 999px;
background: var(--bg);
transition: left 120ms;
}
.toggle.on { background: var(--text); }
.toggle.on span { left: 16px; background: var(--accent); }
.filter-name { display: flex; align-items: center; gap: 10px; }
.builtin {
margin-top: 16px;
padding: 14px;
background: var(--bg);
border-radius: 6px;
border: 1px solid var(--border);
display: flex;
align-items: flex-start;
gap: 12px;
}
.builtin-title { font-size: 13px; font-weight: 600; }
.builtin-sub { color: var(--text-mute); margin-top: 4px; font-size: 13px; }
.lists { display: grid; grid-template-columns: repeat(2, 1fr); gap: 12px; }
.list-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
.list-title { display: flex; align-items: center; gap: 8px; }
.list-name { font-family: var(--font-display); font-weight: 600; font-size: 16px; }
.list-row { display: flex; gap: 18px; margin-top: 18px; padding-top: 14px; border-top: 1px solid var(--border); align-items: flex-end; }
.list-num { font-family: var(--font-display); font-weight: 600; font-size: 20px; margin-top: 4px; }
.list-owner { flex: 1; font-size: 13px; }
.compliance { display: flex; flex-direction: column; gap: 16px; max-width: 900px; }
.card-head { margin-bottom: 12px; }
.card-head-inline { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
.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; }
.muted { font-size: 13px; color: var(--text-mute); line-height: 1.6; }
.radio-big { display: flex; flex-direction: column; gap: 8px; }
.radio-big label { display: flex; gap: 12px; padding: 14px; border: 1px solid var(--border); border-radius: 6px; cursor: pointer; }
.radio-big label.active { border-color: var(--text); background: var(--bg); }
.radio-big input { display: none; }
.radio-dot { width: 18px; height: 18px; border-radius: 999px; border: 2px solid var(--border); display: inline-flex; align-items: center; justify-content: center; flex-shrink: 0; margin-top: 2px; }
.radio-big label.active .radio-dot { border-color: var(--text); }
.radio-dot span { width: 8px; height: 8px; border-radius: 999px; background: var(--text); }
.radio-label { font-size: 14px; font-weight: 500; }
.radio-d { font-size: 12px; color: var(--text-mute); margin-top: 4px; }
.empty { padding: 36px 24px; text-align: center; display: flex; flex-direction: column; align-items: center; gap: 8px; }
.empty-title { font-family: var(--font-display); font-weight: 600; font-size: 15px; }
.empty-body { font-size: 13px; color: var(--text-mute); max-width: 420px; line-height: 1.5; }
/* Modal forms */
.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); }
.alias-row { display: grid; grid-template-columns: 1fr auto 1fr; gap: 8px; align-items: center; }
.at { font-family: var(--font-mono); color: var(--text-mute); }
.manage { padding-bottom: 24px; }
.manage-head { display: flex; align-items: center; gap: 14px; }
.manage-icon {
width: 44px;
height: 44px;
border-radius: 10px;
background: var(--bg);
border: 1px solid var(--border);
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--text-dim);
}
.manage-meta { flex: 1; min-width: 0; }
.manage-name { font-family: var(--font-display); font-weight: 600; font-size: 18px; }
.manage-stats { display: flex; gap: 24px; margin-top: 16px; }
.ms-v { font-size: 13px; margin-top: 4px; font-weight: 500; }
</style>
+380
View File
@@ -0,0 +1,380 @@
<script setup lang="ts">
// Strict port of project/platform-collab.jsx `MeetingsScreen` (lines 71-260)
// with Rooms / Recordings / Settings tabs and source's sample data.
import { meetingRooms, meetingRecordings } from '~/data/workspace'
const tab = ref<'rooms' | 'recordings' | 'settings'>('rooms')
const newRoomOpen = ref(false)
const toast = useToast()
function roomAction(name: string, alias: string, id: string) {
if (id === 'start') toast.info(`Joining ${name}`)
else if (id === 'copy') {
navigator.clipboard?.writeText(`meet.dezky.com/${alias}`).catch(() => {})
toast.ok('Room link copied', `meet.dezky.com/${alias}`)
}
else if (id === 'edit') toast.info(`Edit ${name}`)
else if (id === 'history') toast.info(`Meeting history for ${name}`)
else if (id === 'delete') toast.bad(`${name} deleted`)
}
const roomItems = [
{ id: 'start', label: 'Start meeting', icon: 'video' as const },
{ id: 'copy', label: 'Copy room link', icon: 'copy' as const },
{ id: 'edit', label: 'Edit room…', icon: 'brush' as const },
{ id: 'history', label: 'Meeting history', icon: 'file' as const },
{ id: 'sep1', separator: true },
{ id: 'delete', label: 'Delete room', icon: 'trash' as const, danger: true },
]
function recAction(title: string, id: string) {
if (id === 'play') toast.info(`Playing "${title}"`)
else if (id === 'download') toast.info(`Downloading "${title}"`)
else if (id === 'share') toast.ok('Share link copied')
else if (id === 'transcript') toast.info('Opening transcript')
else if (id === 'hold') toast.warn(`Legal hold placed on "${title}"`)
else if (id === 'delete') toast.bad(`"${title}" deleted`)
}
const recItems = [
{ id: 'play', label: 'Play', icon: 'video' as const },
{ id: 'download', label: 'Download MP4', icon: 'download' as const },
{ id: 'share', label: 'Copy share link', icon: 'copy' as const },
{ id: 'transcript', label: 'Open transcript', icon: 'file' as const },
{ id: 'sep1', separator: true },
{ id: 'hold', label: 'Place legal hold', icon: 'shield' as const },
{ id: 'delete', label: 'Delete recording', icon: 'trash' as const, danger: true },
]
const totalSize = computed(() =>
meetingRecordings.reduce((s, r) => s + parseInt(r.size), 0),
)
const defaults: Array<{ l: string; v: boolean; d: string }> = [
{ l: 'Require lobby', v: true, d: 'Participants wait until host admits them.' },
{ l: 'End-to-end encryption', v: true, d: 'Available 1:1 and small group rooms.' },
{ l: 'Allow guest links', v: true, d: 'External participants can join via link.' },
{ l: 'Recording on by default', v: false, d: 'Override per room when needed.' },
{ l: 'Transcription', v: true, d: 'Auto-generate transcripts in Danish + English.' },
]
const recordingPolicy = ref<'off' | 'auto' | 'manual'>('auto')
const recordingOptions = [
{ v: 'off' as const, label: 'Disable recording org-wide', d: 'Hosts cannot record. Useful for regulated environments.' },
{ v: 'auto' as const, label: 'Allow · keep in Drev · 365 d', d: 'Recordings auto-save to /Recordings folder. Auto-delete after 365 days unless on legal hold.' },
{ v: 'manual' as const, label: 'Allow · host downloads only', d: 'Recordings are not stored on the platform. Host gets a download link valid for 24h.' },
]
</script>
<template>
<div>
<PageHeader
eyebrow="Møder · Jitsi"
title="Meeting settings"
subtitle="Persistent rooms, recordings, and default meeting policy for your workspace."
/>
<div class="tab-wrap">
<Tabs
v-model="tab"
:items="[
{ value: 'rooms', label: 'Rooms', count: meetingRooms.length },
{ value: 'recordings', label: 'Recordings', count: meetingRecordings.length },
{ value: 'settings', label: 'Settings' },
]"
/>
</div>
<div class="content">
<template v-if="tab === 'rooms'">
<div class="row">
<div class="lead">Persistent rooms keep a stable URL meeting recordings and chat history stay tied to the room.</div>
<UiButton variant="primary" @click="newRoomOpen = true">
<template #leading><UiIcon name="plus" :size="14" /></template>
New room
</UiButton>
</div>
<Card :pad="0">
<table class="tbl">
<thead>
<tr><th>Room</th><th>Type</th><th>Schedule</th><th>Owner</th><th>Recording</th><th class="right">Members</th><th /></tr>
</thead>
<tbody>
<tr v-for="r in meetingRooms" :key="r.id">
<td>
<div class="room-cell">
<div class="room-icon"><UiIcon name="video" :size="14" /></div>
<div>
<div class="room-name">
<span>{{ r.name }}</span>
<UiIcon v-if="r.protected" name="shield" :size="11" stroke="var(--text-mute)" />
</div>
<Mono dim>meet.dezky.com/{{ r.alias }}</Mono>
</div>
</div>
</td>
<td><Badge :tone="r.type === 'recurring' ? 'info' : 'neutral'">{{ r.type }}</Badge></td>
<td class="meta">{{ r.when }}</td>
<td>
<div class="owner-cell">
<Avatar :name="r.owner" :size="20" />
<span>{{ r.owner }}</span>
</div>
</td>
<td><Badge :tone="r.recording === 'auto' ? 'ok' : r.recording === 'off' ? 'neutral' : 'warn'">{{ r.recording }}</Badge></td>
<td class="right"><Mono>{{ r.members }}</Mono></td>
<td class="right"><AdminKebabMenu :items="roomItems" @select="(id) => roomAction(r.name, r.alias, id)" /></td>
</tr>
</tbody>
</table>
</Card>
</template>
<template v-else-if="tab === 'recordings'">
<div class="rec-toolbar">
<div class="input-search">
<UiIcon name="search" :size="14" stroke="var(--text-mute)" />
<input placeholder="Search by title, host, room…" />
</div>
<button class="chip"><Eyebrow>Retention:</Eyebrow> <span>All</span> <UiIcon name="chevDown" :size="12" stroke="var(--text-mute)" /></button>
<button class="chip"><Eyebrow>Host:</Eyebrow> <span>Anyone</span> <UiIcon name="chevDown" :size="12" stroke="var(--text-mute)" /></button>
<div class="spacer" />
<Mono dim>{{ meetingRecordings.length }} recordings · {{ totalSize }} MB</Mono>
</div>
<Card :pad="0">
<table class="tbl">
<thead>
<tr><th>Recording</th><th>Recorded</th><th>Host</th><th>Views</th><th>Retention</th><th /></tr>
</thead>
<tbody>
<tr v-for="r in meetingRecordings" :key="r.id">
<td>
<div class="rec-cell">
<div class="rec-thumb"><UiIcon name="video" :size="13" /></div>
<div>
<div class="rec-title">{{ r.title }}</div>
<Mono dim>{{ r.dur }} · {{ r.size }}</Mono>
</div>
</div>
</td>
<td><Mono dim>{{ r.date }}</Mono></td>
<td>
<div class="owner-cell">
<Avatar :name="r.host" :size="20" />
<span>{{ r.host }}</span>
</div>
</td>
<td><Mono>{{ r.views }}</Mono></td>
<td><Badge :tone="r.retention === 'forever' ? 'invert' : r.retention === '365 d' ? 'info' : 'neutral'" dot>{{ r.retention }}{{ r.legal ? ' · hold' : '' }}</Badge></td>
<td class="right"><AdminKebabMenu :items="recItems" @select="(id) => recAction(r.title, id)" /></td>
</tr>
</tbody>
</table>
</Card>
</template>
<template v-else>
<div class="settings">
<Card>
<div class="card-head">
<Eyebrow>Defaults</Eyebrow>
<div class="card-title">New room defaults</div>
<div class="card-sub">What every new room inherits unless the creator overrides.</div>
</div>
<div class="defaults">
<div v-for="r in defaults" :key="r.l" class="def-row">
<button class="toggle" :class="{ on: r.v }"><span /></button>
<div class="def-meta">
<div class="def-label">{{ r.l }}</div>
<div class="def-d">{{ r.d }}</div>
</div>
</div>
</div>
</Card>
<Card>
<div class="card-head">
<Eyebrow>Recording policy</Eyebrow>
<div class="card-title">Where recordings live</div>
</div>
<div class="radio-big">
<label v-for="o in recordingOptions" :key="o.v" :class="{ active: recordingPolicy === o.v }">
<span class="radio-dot"><span v-if="recordingPolicy === o.v" /></span>
<input type="radio" :value="o.v" v-model="recordingPolicy" />
<div>
<div class="radio-label">{{ o.label }}</div>
<div class="radio-d">{{ o.d }}</div>
</div>
</label>
</div>
</Card>
<Card>
<div class="card-head">
<Eyebrow>Limits</Eyebrow>
<div class="card-title">Capacity & quality</div>
</div>
<div class="limits">
<div v-for="[k, v] in [
['Max participants per room', '50'],
['Default video resolution', '720p · adaptive'],
['Recording resolution', '1080p'],
]" :key="k">
<Eyebrow>{{ k }}</Eyebrow>
<div class="limit-v">{{ v }}</div>
</div>
</div>
</Card>
</div>
</template>
</div>
<Modal :open="newRoomOpen" eyebrow="Meetings · rooms" title="New room" size="md" @close="newRoomOpen = false">
<div class="form-stack">
<label class="field"><Eyebrow>Name</Eyebrow><input class="input" placeholder="Engineering standup" /></label>
<label class="field"><Eyebrow>Alias</Eyebrow><input class="input" placeholder="eng-standup" /></label>
<label class="field"><Eyebrow>Owner</Eyebrow><input class="input" value="Anne Baslund" /></label>
</div>
<template #footer>
<UiButton variant="ghost" @click="newRoomOpen = false">Cancel</UiButton>
<UiButton variant="primary" @click="newRoomOpen = false">Create room</UiButton>
</template>
</Modal>
</div>
</template>
<style scoped>
.tab-wrap { padding: 16px 40px 0 40px; }
.content { padding: 20px 40px 64px 40px; }
.row { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; }
.lead { font-size: 13px; color: var(--text-mute); max-width: 540px; line-height: 1.5; }
.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; }
.meta { font-size: 12px; }
.room-cell { display: flex; align-items: center; gap: 12px; }
.room-icon {
width: 30px;
height: 30px;
border-radius: 6px;
background: var(--bg);
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--text-dim);
}
.room-name { display: flex; align-items: center; gap: 6px; font-size: 13px; font-weight: 500; }
.owner-cell { display: flex; align-items: center; gap: 8px; font-size: 12px; }
.rec-toolbar { display: flex; gap: 12px; align-items: center; margin-bottom: 16px; flex-wrap: wrap; }
.input-search {
display: flex;
align-items: center;
gap: 8px;
padding: 0 12px;
height: 36px;
width: 320px;
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); }
.chip {
display: inline-flex;
align-items: center;
gap: 6px;
height: 36px;
padding: 0 12px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
font-size: 13px;
font-family: inherit;
color: var(--text);
cursor: pointer;
}
.chip span { font-weight: 500; }
.spacer { flex: 1; }
.rec-cell { display: flex; align-items: center; gap: 12px; }
.rec-thumb {
width: 64px;
height: 36px;
border-radius: 5px;
background: var(--text);
color: var(--bg);
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.rec-title { font-size: 13px; font-weight: 500; }
.settings { display: flex; flex-direction: column; gap: 16px; max-width: 900px; }
.card-head { margin-bottom: 14px; }
.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; }
.defaults { display: flex; flex-direction: column; gap: 14px; }
.def-row { display: flex; align-items: center; gap: 16px; padding-bottom: 14px; border-bottom: 1px solid var(--border); }
.def-row:last-child { padding-bottom: 0; border-bottom: none; }
.def-meta { flex: 1; }
.def-label { font-size: 13px; font-weight: 500; }
.def-d { font-size: 12px; color: var(--text-mute); margin-top: 4px; }
.toggle {
width: 32px;
height: 18px;
border-radius: 999px;
background: var(--border);
border: none;
position: relative;
cursor: pointer;
padding: 0;
flex-shrink: 0;
}
.toggle span {
position: absolute;
top: 2px;
left: 2px;
width: 14px;
height: 14px;
border-radius: 999px;
background: var(--bg);
transition: left 120ms;
}
.toggle.on { background: var(--text); }
.toggle.on span { left: 16px; background: var(--accent); }
.radio-big { display: flex; flex-direction: column; gap: 8px; }
.radio-big label { display: flex; gap: 12px; padding: 14px; border: 1px solid var(--border); border-radius: 6px; cursor: pointer; }
.radio-big label.active { border-color: var(--text); background: var(--bg); }
.radio-big input { display: none; }
.radio-dot { width: 18px; height: 18px; border-radius: 999px; border: 2px solid var(--border); display: inline-flex; align-items: center; justify-content: center; flex-shrink: 0; margin-top: 2px; }
.radio-big label.active .radio-dot { border-color: var(--text); }
.radio-dot span { width: 8px; height: 8px; border-radius: 999px; background: var(--text); }
.radio-label { font-size: 14px; font-weight: 500; }
.radio-d { font-size: 12px; color: var(--text-mute); margin-top: 4px; }
.limits { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; }
.limit-v { font-family: var(--font-display); font-weight: 600; font-size: 18px; margin-top: 6px; font-variant-numeric: tabular-nums; }
.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; }
</style>
+400
View File
@@ -0,0 +1,400 @@
<script setup lang="ts">
// Strict port of project/platform-screens.jsx `SecurityScreen` (lines 2187-2310)
// and RadioBig (line 2311). Two tabs: Security · Audit log. Same cards, same
// copy, same SSO apps, same audit-log column structure with sample rows.
import { sampleAudit } from '~/data/workspace'
const tab = ref<'security' | 'audit'>('security')
const mfa = ref<'all' | 'admins' | 'optional'>('admins')
const toast = useToast()
const addCountryOpen = ref(false)
const newAllowCountry = ref('')
function ssoAction(name: string, id: string) {
if (id === 'configure') toast.info(`Configure ${name}`)
else if (id === 'test') toast.info(`Sending test sign-in to ${name}`)
else if (id === 'rotate') toast.info(`Rotating certificate for ${name}`)
else if (id === 'disconnect') toast.warn(`${name} disconnected`)
}
const ssoItems = [
{ id: 'configure', label: 'Configure', icon: 'brush' as const },
{ id: 'test', label: 'Send test sign-in', icon: 'key' as const },
{ id: 'rotate', label: 'Rotate certificate', icon: 'refresh' as const },
{ id: 'sep1', separator: true },
{ id: 'disconnect', label: 'Disconnect', icon: 'plug' as const, danger: true },
]
function removeCountry(c: string) {
toast.info(`${c} removed from allow-list`)
}
const ssoApps = [
{ n: 'Notion', p: 'SAML', s: 'ok' as const },
{ n: 'Figma', p: 'SAML', s: 'ok' as const },
{ n: 'Linear', p: 'OIDC', s: 'ok' as const },
{ n: 'GitHub', p: 'OIDC', s: 'warn' as const },
]
const mfaOptions = [
{ v: 'all' as const, label: 'Required for everyone', d: 'All members must enroll TOTP or WebAuthn at next sign-in.' },
{ v: 'admins' as const, label: 'Required for admins only', d: 'Members may opt in. Admins are forced to enroll.' },
{ v: 'optional' as const, label: 'Optional', d: 'No enforcement. Not recommended for compliance work.' },
]
const countries = ['Denmark', 'Sweden', 'Norway', 'Germany', 'Netherlands']
</script>
<template>
<div>
<PageHeader
eyebrow="Compliance"
title="Security & audit"
subtitle="Policies, identity controls, and a tamper-evident log of every administrative action."
/>
<div class="tab-wrap">
<Tabs
v-model="tab"
:items="[
{ value: 'security', label: 'Security' },
{ value: 'audit', label: 'Audit log', count: 4218 },
]"
/>
</div>
<div v-if="tab === 'security'" class="content security">
<Card>
<div class="card-head">
<Eyebrow>Identity</Eyebrow>
<div class="card-title">Multi-factor authentication</div>
</div>
<div class="radio-big">
<label v-for="o in mfaOptions" :key="o.v" :class="{ active: mfa === o.v }">
<span class="radio-dot"><span v-if="mfa === o.v" /></span>
<input type="radio" :value="o.v" v-model="mfa" />
<div>
<div class="radio-label">{{ o.label }}</div>
<div class="radio-d">{{ o.d }}</div>
</div>
</label>
</div>
</Card>
<Card>
<div class="card-head">
<Eyebrow>Sessions</Eyebrow>
<div class="card-title">Session policy</div>
</div>
<div class="grid-2">
<label class="field"><Eyebrow>Idle timeout</Eyebrow>
<div class="input-faux">
<input value="30 minutes" />
<UiIcon name="chevDown" :size="12" stroke="var(--text-mute)" />
</div>
</label>
<label class="field"><Eyebrow>Absolute timeout</Eyebrow>
<div class="input-faux">
<input value="24 hours" />
<UiIcon name="chevDown" :size="12" stroke="var(--text-mute)" />
</div>
</label>
</div>
</Card>
<Card>
<div class="card-head">
<Eyebrow>Network</Eyebrow>
<div class="card-title">Geo-fencing & allow-lists</div>
</div>
<div class="field">
<Eyebrow>Allowed countries</Eyebrow>
<div class="chip-row">
<Badge v-for="c in countries" :key="c" tone="neutral">
{{ c }}
<button class="badge-x" @click="removeCountry(c)" aria-label="Remove country">
<UiIcon name="x" :size="10" />
</button>
</Badge>
<UiButton size="sm" variant="ghost" @click="addCountryOpen = true">
<template #leading><UiIcon name="plus" :size="12" /></template>
Add country
</UiButton>
</div>
</div>
</Card>
<Card>
<div class="card-head">
<Eyebrow>SSO</Eyebrow>
<div class="card-title">dezky as identity provider</div>
</div>
<div class="sso-intro">
Connect external applications via OIDC or SAML. dezky's Authentik instance is the source of truth for identity.
</div>
<div class="sso-list">
<div v-for="a in ssoApps" :key="a.n" class="sso-row">
<div class="sso-icon">{{ a.n[0] }}</div>
<div class="sso-meta">
<div class="sso-name">{{ a.n }}</div>
<Mono dim>{{ a.p }} · provisioned</Mono>
</div>
<Badge :tone="a.s" dot>{{ a.s === 'ok' ? 'connected' : 'cert expiring' }}</Badge>
<AdminKebabMenu :items="ssoItems" @select="(id) => ssoAction(a.n, id)" />
</div>
</div>
</Card>
</div>
<div v-else class="content audit">
<div class="toolbar">
<div class="input-search">
<UiIcon name="search" :size="14" stroke="var(--text-mute)" />
<input placeholder="action.type, actor, target…" />
</div>
<button class="chip"><Eyebrow>Actor:</Eyebrow> <span>All</span> <UiIcon name="chevDown" :size="12" stroke="var(--text-mute)" /></button>
<button class="chip"><Eyebrow>Action:</Eyebrow> <span>All</span> <UiIcon name="chevDown" :size="12" stroke="var(--text-mute)" /></button>
<button class="chip"><Eyebrow>Last:</Eyebrow> <span>7 days</span> <UiIcon name="chevDown" :size="12" stroke="var(--text-mute)" /></button>
<div class="spacer" />
<UiButton variant="secondary" @click="toast.info('Exporting audit log', 'CSV · last 7 days · ~4,218 events')">
<template #leading><UiIcon name="download" :size="14" /></template>
Export CSV
</UiButton>
</div>
<Card :pad="0">
<table class="audit-table">
<thead>
<tr>
<th>Time</th>
<th>Actor</th>
<th>Action</th>
<th>Target</th>
<th>IP</th>
<th class="right" />
</tr>
</thead>
<tbody>
<tr v-for="a in sampleAudit" :key="a.id">
<td><Mono>{{ a.when }}</Mono></td>
<td>
<div class="actor-cell">
<Avatar v-if="a.actor !== 'system'" :name="a.actor" :size="22" />
<div v-else class="sys">sys</div>
<span>{{ a.actor }}</span>
</div>
</td>
<td><Mono>{{ a.action }}</Mono></td>
<td class="target">{{ a.target }}</td>
<td><Mono dim>{{ a.ip }}</Mono></td>
<td class="right"><Badge :tone="a.tone" dot>{{ a.tone }}</Badge></td>
</tr>
</tbody>
</table>
</Card>
<div class="retention">
<Mono dim>// retention · 365 days · tamper-evident · last verified 14:32:01 today</Mono>
</div>
</div>
<!-- Add country modal -->
<Modal :open="addCountryOpen" eyebrow="Security · geo-fencing" title="Add country to allow-list" size="sm" @close="addCountryOpen = false">
<div class="form-stack">
<label class="field"><Eyebrow>Country</Eyebrow>
<CountrySelect v-model="newAllowCountry" placeholder="Search countries" />
</label>
</div>
<template #footer>
<UiButton variant="ghost" @click="addCountryOpen = false">Cancel</UiButton>
<UiButton
variant="primary"
:disabled="!newAllowCountry"
@click="addCountryOpen = false; toast.ok(`Country ${newAllowCountry} added`); newAllowCountry = ''"
>
Add
</UiButton>
</template>
</Modal>
</div>
</template>
<style scoped>
.tab-wrap { padding: 16px 40px 0 40px; }
.content { padding: 24px 40px 64px 40px; }
.content.security { display: flex; flex-direction: column; gap: 16px; max-width: 1100px; }
.card-head { margin-bottom: 16px; }
.card-title {
font-family: var(--font-display);
font-weight: 600;
font-size: 18px;
letter-spacing: -0.01em;
margin-top: 4px;
}
/* RadioBig */
.radio-big { display: flex; flex-direction: column; gap: 8px; }
.radio-big label {
display: flex;
gap: 12px;
padding: 14px;
border: 1px solid var(--border);
border-radius: 6px;
cursor: pointer;
}
.radio-big label.active { border-color: var(--text); background: var(--bg); }
.radio-big input { display: none; }
.radio-dot {
width: 18px;
height: 18px;
border-radius: 999px;
border: 2px solid var(--border-hi, var(--border));
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
margin-top: 2px;
}
.radio-big label.active .radio-dot { border-color: var(--text); }
.radio-dot span { width: 8px; height: 8px; border-radius: 999px; background: var(--text); }
.radio-label { font-size: 14px; font-weight: 500; }
.radio-d { font-size: 12px; color: var(--text-mute); margin-top: 4px; }
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
.field { display: flex; flex-direction: column; gap: 6px; }
.input-faux {
display: flex;
align-items: center;
gap: 8px;
padding: 0 12px;
height: 36px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
}
.input-faux input {
flex: 1;
border: none;
outline: none;
background: transparent;
font-family: inherit;
font-size: 13px;
color: var(--text);
}
.chip-row { display: flex; gap: 6px; flex-wrap: wrap; align-items: center; }
.sso-intro { font-size: 13px; color: var(--text-dim); margin-bottom: 12px; line-height: 1.5; }
.sso-list { display: flex; flex-direction: column; gap: 8px; }
.sso-row {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
background: var(--bg);
border-radius: 6px;
}
.sso-icon {
width: 28px;
height: 28px;
border-radius: 5px;
background: var(--surface);
border: 1px solid var(--border);
display: inline-flex;
align-items: center;
justify-content: center;
font-family: var(--font-mono);
font-weight: 700;
font-size: 12px;
}
.sso-meta { flex: 1; }
.sso-name { font-size: 13px; font-weight: 500; }
/* Audit toolbar + table */
.toolbar { display: flex; gap: 8px; margin-bottom: 12px; flex-wrap: wrap; align-items: center; }
.input-search {
display: flex;
align-items: center;
gap: 8px;
padding: 0 12px;
height: 36px;
width: 360px;
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); }
.chip {
display: inline-flex;
align-items: center;
gap: 6px;
height: 36px;
padding: 0 12px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
font-size: 13px;
font-family: inherit;
color: var(--text);
cursor: pointer;
}
.chip span { font-weight: 500; }
.spacer { flex: 1; }
.audit-table { width: 100%; border-collapse: collapse; }
.audit-table thead 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;
}
.audit-table tbody td {
padding: 12px 16px;
border-bottom: 1px solid var(--border);
font-size: 13px;
vertical-align: middle;
}
.audit-table tbody tr:last-child td { border-bottom: none; }
.audit-table .right { text-align: right; }
.target { color: var(--text-dim); }
.actor-cell { display: flex; align-items: center; gap: 8px; }
.sys {
width: 22px;
height: 22px;
border-radius: 5px;
background: var(--text);
color: var(--bg);
display: inline-flex;
align-items: center;
justify-content: center;
font-family: var(--font-mono);
font-size: 10px;
font-weight: 700;
}
.retention { margin-top: 12px; font-size: 12px; color: var(--text-mute); }
.badge-x {
background: transparent;
border: none;
padding: 0;
margin-left: 4px;
display: inline-flex;
align-items: center;
cursor: pointer;
color: inherit;
}
.badge-x:hover { color: var(--bad); }
/* Add country modal */
.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>
+108
View File
@@ -0,0 +1,108 @@
<script setup lang="ts">
// Strict port of project/platform-app.jsx `StorageScreen` (lines 970-1020).
// Two-card 1.4fr/1fr layout: aggregate + top users on the left, type breakdown
// on the right. No tabs in the source — just two cards.
import { sampleUsersFlat } from '~/data/workspace'
const topUsers = computed(() =>
[...sampleUsersFlat].slice(0, 5).sort((a, b) => b.storage - a.storage),
)
const typeBreakdown: Array<[string, number, string]> = [
['Documents', 42, 'var(--text)'],
['Images', 24, 'var(--info)'],
['Video', 18, 'var(--warn)'],
['Archives', 9, 'var(--ok)'],
['Other', 7, 'var(--text-mute)'],
]
</script>
<template>
<div>
<PageHeader
eyebrow="Drev"
title="Storage"
subtitle="Aggregate file storage across your workspace, by user and type."
/>
<div class="content">
<Card>
<div class="card-head">
<Eyebrow>Aggregate</Eyebrow>
<div class="card-title">1.4 TB used</div>
<div class="card-sub">64% of 2.2 TB allocated · Business plan</div>
</div>
<div class="progress" style="height: 10px;">
<span style="width: 64%" />
</div>
<div class="progress-legend">
<span>1.4 TB used</span>
<span>820 GB free</span>
</div>
<div class="top-block">
<Eyebrow>Top users</Eyebrow>
<div class="top-list">
<div v-for="u in topUsers" :key="u.id" class="top-row">
<div class="user-cell">
<Avatar :name="u.name" :size="22" />
<span>{{ u.name }}</span>
</div>
<div class="progress thin"><span :style="{ width: Math.min(100, (u.storage / 50) * 100) + '%' }" /></div>
<Mono>{{ u.storage }} GB</Mono>
</div>
</div>
</div>
</Card>
<Card>
<div class="card-head">
<Eyebrow>By type</Eyebrow>
<div class="card-title">What's taking space</div>
</div>
<div class="types">
<div v-for="[n, p, c] in typeBreakdown" :key="n">
<div class="type-head">
<span>{{ n }}</span>
<span class="pct">{{ p }}%</span>
</div>
<div class="progress thinner"><span :style="{ width: p + '%', background: c }" /></div>
</div>
</div>
</Card>
</div>
</div>
</template>
<style scoped>
.content { padding: 24px 40px 64px 40px; display: grid; grid-template-columns: 1.4fr 1fr; gap: 16px; max-width: 1200px; }
.card-head { margin-bottom: 16px; }
.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; }
.progress { background: var(--bg); border-radius: 999px; overflow: hidden; }
.progress.thin { height: 6px; }
.progress.thinner { height: 5px; }
.progress 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: 11px;
color: var(--text-mute);
}
.top-block { margin-top: 32px; }
.top-list { margin-top: 12px; display: flex; flex-direction: column; gap: 10px; }
.top-row { display: grid; grid-template-columns: 180px 1fr 60px; gap: 12px; align-items: center; }
.user-cell { display: flex; align-items: center; gap: 8px; font-size: 12px; }
.top-row > .mono, .top-row :deep(.mono) { font-family: var(--font-mono); font-size: 11px; text-align: right; }
.types { display: flex; flex-direction: column; gap: 12px; }
.type-head { display: flex; justify-content: space-between; font-size: 12px; margin-bottom: 4px; }
.pct { font-family: var(--font-mono); color: var(--text-mute); }
</style>
+856
View File
@@ -0,0 +1,856 @@
<script setup lang="ts">
// Strict port of project/platform-screens.jsx `UsersScreen` (lines 625-768)
// with FilterChip (770), UserDetailPanel (816), DefList (948), InviteUserModal
// (961), plus GroupsTabRich from platform-admin.jsx (1022), InvitationsTab and
// ServiceAccountsTab (platform-screens.jsx 1090, 1123).
//
// User detail panel tabs follow the source order: Profile · Access · Mail ·
// Files · Activity · Audit (no Danger zone in the source).
import { sampleUsersFlat, groupsFull, sampleAudit } from '~/data/workspace'
type User = (typeof sampleUsersFlat)[number]
const toast = useToast()
const tab = ref<'users' | 'groups' | 'invitations' | 'service'>('users')
const query = ref('')
const statusFilter = ref<'all' | 'active' | 'invited' | 'suspended'>('all')
const selected = ref<Set<string>>(new Set())
const openUser = ref<User | null>(null)
const userTab = ref<'profile' | 'access' | 'mail' | 'files' | 'activity' | 'audit'>('profile')
const inviteOpen = ref(false)
const inviteStep = ref(1)
const importOpen = ref(false)
const filteredUsers = computed(() =>
sampleUsersFlat.filter((u) => {
if (statusFilter.value !== 'all' && u.status !== statusFilter.value) return false
if (query.value && !`${u.name} ${u.email}`.toLowerCase().includes(query.value.toLowerCase())) return false
return true
}),
)
const invites = computed(() => sampleUsersFlat.filter((u) => u.status === 'invited'))
const statusTone = (s: string): 'ok' | 'warn' | 'bad' =>
s === 'active' ? 'ok' : s === 'invited' ? 'warn' : 'bad'
function toggleSelect(id: string) {
const s = new Set(selected.value)
if (s.has(id)) s.delete(id)
else s.add(id)
selected.value = s
}
function clearSelection() { selected.value = new Set() }
watch(openUser, (u) => { if (u) userTab.value = 'profile' })
// Filter chip
type ChipOption = { value: string; label: string }
const statusOptions: ChipOption[] = [
{ value: 'all', label: 'All' },
{ value: 'active', label: 'Active' },
{ value: 'invited', label: 'Invited' },
{ value: 'suspended', label: 'Suspended' },
]
// Groups tab
const openGroup = ref<typeof groupsFull[number] | null>(null)
const createGroupOpen = ref(false)
// Bulk-action modals + confirm
const assignGroupOpen = ref(false)
const changeRoleOpen = ref(false)
const suspendOpen = ref(false)
const groupChoice = ref<Set<string>>(new Set())
const roleChoice = ref<'member' | 'admin' | 'owner'>('member')
function sendInvite() {
inviteOpen.value = false
inviteStep.value = 1
toast.ok('Invitation sent to magnus@dezky.com')
}
function applyBulkGroup() {
const n = selected.value.size
const gs = [...groupChoice.value].join(', ') || '—'
assignGroupOpen.value = false
toast.ok(`${n} user${n === 1 ? '' : 's'} added to: ${gs}`)
groupChoice.value = new Set()
}
function applyBulkRole() {
const n = selected.value.size
changeRoleOpen.value = false
toast.ok(`${n} user${n === 1 ? '' : 's'} set to ${roleChoice.value}`)
}
function applyBulkSuspend() {
const n = selected.value.size
suspendOpen.value = false
toast.warn(`${n} user${n === 1 ? '' : 's'} suspended`, 'Sign-in blocked · data preserved')
selected.value = new Set()
}
function bulkExport() {
const n = selected.value.size
toast.info(`Exporting ${n} user${n === 1 ? '' : 's'}`, 'CSV with profile + access columns')
}
function toggleGroup(g: string) {
const s = new Set(groupChoice.value)
if (s.has(g)) s.delete(g)
else s.add(g)
groupChoice.value = s
}
// Per-row kebab — open the user detail panel by default.
function rowAction(u: User, id: string) {
if (id === 'open') openUser.value = u
else if (id === 'reset') toast.info(`Password reset link sent to ${u.email}`)
else if (id === 'force') toast.info(`Forcing logout for ${u.name}`)
else if (id === 'suspend') toast.warn(`${u.name} suspended`)
else if (id === 'delete') toast.bad(`${u.name} deletion scheduled`)
}
function groupAction(g: typeof groupsFull[number], id: string) {
if (id === 'open') openGroup.value = g
else if (id === 'rename') toast.info(`Rename ${g.name}`)
else if (id === 'duplicate') toast.info(`Duplicated ${g.name}`)
else if (id === 'delete') toast.bad(`${g.name} deletion scheduled`)
}
const userRowItems = [
{ id: 'open', label: 'Open profile', icon: 'external' as const },
{ id: 'reset', label: 'Send password reset', icon: 'key' as const },
{ id: 'force', label: 'Force logout', icon: 'logout' as const },
{ id: 'sep1', separator: true },
{ id: 'suspend', label: 'Suspend user', icon: 'shield' as const, danger: true },
{ id: 'delete', label: 'Delete user', icon: 'trash' as const, danger: true },
]
const groupRowItems = [
{ id: 'open', label: 'Open group', icon: 'external' as const },
{ id: 'rename', label: 'Rename', icon: 'brush' as const },
{ id: 'duplicate', label: 'Duplicate', icon: 'copy' as const },
{ id: 'sep1', separator: true },
{ id: 'delete', label: 'Delete group',icon: 'trash' as const, danger: true },
]
</script>
<template>
<div>
<PageHeader
eyebrow="Identity"
title="Users & groups"
subtitle="Manage workspace members, their access, and group assignments."
>
<template #actions>
<UiButton variant="secondary" @click="importOpen = true">
<template #leading><UiIcon name="upload" :size="14" /></template>
Import CSV
</UiButton>
<UiButton variant="secondary">
<template #leading><UiIcon name="download" :size="14" /></template>
Export
</UiButton>
<UiButton variant="primary" @click="inviteOpen = true">
<template #leading><UiIcon name="plus" :size="14" /></template>
Invite user
</UiButton>
</template>
</PageHeader>
<div class="tab-wrap">
<Tabs
v-model="tab"
:items="[
{ value: 'users', label: 'Users', count: sampleUsersFlat.length },
{ value: 'groups', label: 'Groups', count: 6 },
{ value: 'invitations', label: 'Invitations', count: 2 },
{ value: 'service', label: 'Service accounts', count: 3 },
]"
/>
</div>
<!-- USERS TAB -->
<div v-if="tab === 'users'" class="content">
<div class="toolbar">
<div class="input-search">
<UiIcon name="search" :size="14" stroke="var(--text-mute)" />
<input v-model="query" placeholder="Search by name or email…" />
</div>
<AdminFilterChip label="Status" :options="statusOptions" v-model="statusFilter" />
<button class="chip"><Eyebrow>Role:</Eyebrow><span>All</span><UiIcon name="chevDown" :size="12" stroke="var(--text-mute)" /></button>
<button class="chip"><Eyebrow>Group:</Eyebrow><span>All</span><UiIcon name="chevDown" :size="12" stroke="var(--text-mute)" /></button>
<div class="spacer" />
<Mono dim>{{ filteredUsers.length }} of {{ sampleUsersFlat.length }}</Mono>
</div>
<div v-if="selected.size > 0" class="bulk">
<Mono style="color: inherit">{{ selected.size }} selected</Mono>
<div class="spacer" />
<UiButton size="sm" variant="ghost" class="invert" @click="assignGroupOpen = true">Assign group</UiButton>
<UiButton size="sm" variant="ghost" class="invert" @click="changeRoleOpen = true">Change role</UiButton>
<UiButton size="sm" variant="ghost" class="invert" @click="bulkExport">Export selected</UiButton>
<UiButton size="sm" variant="ghost" class="invert" @click="suspendOpen = true">Suspend</UiButton>
<UiButton size="sm" variant="ghost" class="invert" @click="clearSelection">Clear</UiButton>
</div>
<Card :pad="0">
<table class="users-tbl">
<thead>
<tr>
<th class="check">
<input type="checkbox" :checked="selected.size === filteredUsers.length && filteredUsers.length > 0" @change="(e) => (e.target as HTMLInputElement).checked ? (selected = new Set(filteredUsers.map(u => u.id))) : clearSelection()" />
</th>
<th>Name</th><th>Role</th><th>Status</th><th>Group</th><th>Last seen</th><th class="right">Storage</th><th />
</tr>
</thead>
<tbody>
<tr v-for="u in filteredUsers" :key="u.id" @click="openUser = u">
<td class="check" @click.stop>
<input type="checkbox" :checked="selected.has(u.id)" @change="toggleSelect(u.id)" />
</td>
<td>
<div class="name-cell">
<Avatar :name="u.name" :size="28" />
<div>
<div class="u-name">{{ u.name }}</div>
<Mono dim>{{ u.email }}</Mono>
</div>
</div>
</td>
<td><Badge :tone="u.role === 'Owner' ? 'invert' : 'neutral'">{{ u.role }}</Badge></td>
<td><Badge :tone="statusTone(u.status)" dot>{{ u.status }}</Badge></td>
<td><span class="group-text">{{ u.group }}</span></td>
<td><Mono dim>{{ u.last }}</Mono></td>
<td class="right"><Mono>{{ u.storage > 0 ? `${u.storage} GB` : '—' }}</Mono></td>
<td class="right" @click.stop>
<AdminKebabMenu :items="userRowItems" :icon-size="16" @select="(id) => rowAction(u, id)" />
</td>
</tr>
</tbody>
</table>
</Card>
<div class="pager">
<Mono dim>Showing 1{{ filteredUsers.length }}</Mono>
<div class="pager-btns">
<UiButton size="sm" variant="secondary">
<template #leading><UiIcon name="chevLeft" :size="12" /></template>
Prev
</UiButton>
<UiButton size="sm" variant="secondary">
Next
<template #trailing><UiIcon name="chevRight" :size="12" /></template>
</UiButton>
</div>
</div>
</div>
<!-- GROUPS TAB (GroupsTabRich) -->
<div v-else-if="tab === 'groups'" class="content">
<div class="toolbar">
<div class="input-search">
<UiIcon name="search" :size="14" stroke="var(--text-mute)" />
<input placeholder="Search groups…" />
</div>
<button class="chip"><Eyebrow>Sort:</Eyebrow><span>Name</span><UiIcon name="chevDown" :size="12" stroke="var(--text-mute)" /></button>
<div class="spacer" />
<Mono dim>{{ groupsFull.length }} groups</Mono>
<UiButton variant="primary" @click="createGroupOpen = true">
<template #leading><UiIcon name="plus" :size="14" /></template>
New group
</UiButton>
</div>
<Card :pad="0">
<table class="users-tbl">
<thead>
<tr><th>Group</th><th>Alias</th><th>Members</th><th>Owner</th><th>Created</th><th /></tr>
</thead>
<tbody>
<tr v-for="g in groupsFull" :key="g.id" @click="openGroup = g">
<td>
<div class="name-cell">
<div class="g-icon"><UiIcon name="users" :size="14" /></div>
<div>
<div class="u-name">{{ g.name }}</div>
<Mono dim>{{ g.description }}</Mono>
</div>
</div>
</td>
<td><Mono>{{ g.alias }}</Mono></td>
<td>
<div class="member-cell">
<UiIcon name="users" :size="12" stroke="var(--text-mute)" />
<Mono>{{ g.members }}</Mono>
</div>
</td>
<td>
<div class="name-cell small">
<Avatar :name="g.owner" :size="22" />
<span>{{ g.owner }}</span>
</div>
</td>
<td><Mono dim>{{ g.created }}</Mono></td>
<td class="right" @click.stop>
<AdminKebabMenu :items="groupRowItems" @select="(id) => groupAction(g, id)" />
</td>
</tr>
</tbody>
</table>
</Card>
</div>
<!-- INVITATIONS TAB -->
<div v-else-if="tab === 'invitations'" class="content">
<Card :pad="0">
<table class="users-tbl">
<thead><tr><th>Recipient</th><th>Sent</th><th>Expires</th><th /></tr></thead>
<tbody>
<tr v-for="u in invites" :key="u.id">
<td>
<div class="name-cell">
<Avatar :name="u.name" :size="28" />
<div>
<div class="u-name">{{ u.name }}</div>
<Mono dim>{{ u.email }}</Mono>
</div>
</div>
</td>
<td><Mono dim>14 May 2026</Mono></td>
<td><Mono dim>21 May 2026</Mono></td>
<td class="right">
<UiButton size="sm" variant="secondary">
<template #leading><UiIcon name="copy" :size="13" /></template>
Copy link
</UiButton>
<UiButton size="sm" variant="secondary">
<template #leading><UiIcon name="refresh" :size="13" /></template>
Resend
</UiButton>
</td>
</tr>
</tbody>
</table>
</Card>
</div>
<!-- SERVICE ACCOUNTS TAB -->
<div v-else class="content">
<div class="empty-card">
<UiIcon name="key" :size="28" stroke="var(--text-mute)" />
<div class="empty-title">3 service accounts</div>
<div class="empty-body">Service accounts let scripts and integrations authenticate to your workspace. Manage their API tokens here.</div>
<UiButton variant="primary">
<template #leading><UiIcon name="plus" :size="14" /></template>
New service account
</UiButton>
</div>
</div>
<!-- User detail side panel -->
<SidePanel :open="!!openUser" :eyebrow="openUser?.id || ''" :title="openUser?.name || ''" width="lg" @close="openUser = null">
<div v-if="openUser" class="user-detail">
<div class="ud-head">
<Avatar :name="openUser.name" :size="56" />
<div class="ud-meta">
<div class="ud-name">{{ openUser.name }}</div>
<Mono dim>{{ openUser.email }}</Mono>
<div class="ud-badges">
<Badge :tone="statusTone(openUser.status)" dot>{{ openUser.status }}</Badge>
<Badge tone="neutral">{{ openUser.role }}</Badge>
<Badge tone="neutral">{{ openUser.group }}</Badge>
</div>
</div>
</div>
<Tabs
v-model="userTab"
:items="[
{ value: 'profile', label: 'Profile' },
{ value: 'access', label: 'Access' },
{ value: 'mail', label: 'Mail' },
{ value: 'files', label: 'Files' },
{ value: 'activity', label: 'Activity' },
{ value: 'audit', label: 'Audit' },
]"
/>
<div class="ud-body">
<template v-if="userTab === 'profile'">
<dl class="def">
<div><dt>Full name</dt><dd>{{ openUser.name }}</dd></div>
<div><dt>Email</dt><dd>{{ openUser.email }}</dd></div>
<div><dt>Role</dt><dd>{{ openUser.role }}</dd></div>
<div><dt>Group</dt><dd>{{ openUser.group }}</dd></div>
<div><dt>License</dt><dd>Business · seat 11</dd></div>
<div><dt>Joined</dt><dd>14 January 2026</dd></div>
<div><dt>Locale</dt><dd>da-DK · Europe/Copenhagen</dd></div>
<div><dt>Phone</dt><dd>+45 21 47 88 02</dd></div>
</dl>
</template>
<template v-else-if="userTab === 'access'">
<dl class="def">
<div><dt>MFA</dt><dd><Badge tone="ok" dot>enabled · TOTP</Badge></dd></div>
<div><dt>SSO sessions</dt><dd>2 active</dd></div>
<div><dt>Last sign-in</dt><dd>{{ openUser.last }} · 92.43.118.4 · Copenhagen</dd></div>
<div><dt>Recovery codes</dt><dd>8 of 10 unused</dd></div>
</dl>
<div class="sub-head">Active devices</div>
<div v-for="d in [
{ d: 'MacBook Pro · macOS 14', w: 'Chrome 132', loc: 'Copenhagen', active: '2 min ago' },
{ d: 'iPhone 15 Pro · iOS 18', w: 'dezky Mail', loc: 'Copenhagen', active: '1 h ago' },
]" :key="d.d" class="dev-row">
<UiIcon name="device" :size="18" stroke="var(--text-mute)" />
<div class="dev-meta">
<div class="dev-d">{{ d.d }}</div>
<Mono dim>{{ d.w }} · {{ d.loc }} · {{ d.active }}</Mono>
</div>
<UiButton size="sm" variant="ghost">Revoke</UiButton>
</div>
</template>
<template v-else-if="userTab === 'mail'">
<dl class="def">
<div><dt>Primary address</dt><dd>{{ openUser.email }}</dd></div>
<div><dt>Quota</dt><dd>12.4 GB of 50 GB · 25%</dd></div>
<div><dt>Forwarding</dt><dd>Off</dd></div>
<div><dt>Vacation reply</dt><dd>Off</dd></div>
</dl>
<div class="sub-head">Aliases</div>
<div v-for="a in ['anne.b@dezky.com', 'founder@dezky.com']" :key="a" class="alias-row">
<Mono>{{ a }}</Mono>
<UiButton size="sm" variant="ghost">
<template #leading><UiIcon name="trash" :size="12" /></template>
Remove
</UiButton>
</div>
</template>
<template v-else-if="userTab === 'files'">
<dl class="def">
<div><dt>Quota</dt><dd>12.4 GB of 100 GB · 12%</dd></div>
<div><dt>Shared by user</dt><dd>14 items</dd></div>
<div><dt>Shared with user</dt><dd>23 items</dd></div>
</dl>
</template>
<template v-else-if="userTab === 'activity'">
<div class="activity-list">
<div v-for="a in sampleAudit.slice(0, 6)" :key="a.id" class="activity-row">
<Mono dim>{{ a.when }}</Mono>
<span class="activity-action">{{ a.action }}</span>
<Mono dim>{{ a.ip }}</Mono>
</div>
</div>
</template>
<template v-else>
<div class="empty-tab">
<UiIcon name="shield" :size="28" stroke="var(--text-mute)" />
<div class="empty-title">No changes recorded yet</div>
<div class="empty-body">Edits to this user's settings will appear here.</div>
</div>
</template>
</div>
</div>
<template #footer>
<UiButton variant="danger">
<template #leading><UiIcon name="logout" :size="13" /></template>
Force logout
</UiButton>
<UiButton variant="secondary">Reset password</UiButton>
<UiButton variant="primary">Save changes</UiButton>
</template>
</SidePanel>
<!-- Group detail side panel -->
<SidePanel :open="!!openGroup" eyebrow="Group" :title="openGroup?.name || ''" width="lg" @close="openGroup = null">
<div v-if="openGroup" class="user-detail">
<div class="ud-head">
<div class="g-icon big"><UiIcon name="users" :size="22" /></div>
<div class="ud-meta">
<div class="ud-name">{{ openGroup.name }}</div>
<Mono dim>{{ openGroup.alias }}</Mono>
</div>
</div>
<div class="ud-body">
<dl class="def">
<div><dt>Members</dt><dd>{{ openGroup.members }}</dd></div>
<div><dt>Owner</dt><dd>{{ openGroup.owner }}</dd></div>
<div><dt>Created</dt><dd>{{ openGroup.created }}</dd></div>
<div><dt>Description</dt><dd>{{ openGroup.description }}</dd></div>
</dl>
</div>
</div>
<template #footer>
<UiButton variant="danger">
<template #leading><UiIcon name="trash" :size="13" /></template>
Delete group
</UiButton>
<div style="flex: 1" />
<UiButton variant="primary" @click="openGroup = null">Save changes</UiButton>
</template>
</SidePanel>
<!-- Invite user modal (3 steps) -->
<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>
<!-- Bulk import modal -->
<Modal :open="importOpen" eyebrow="Users · bulk import" title="Import users from CSV" size="md" @close="importOpen = false">
<div class="import">
<div class="upload-stage">
<UiIcon name="upload" :size="28" stroke="var(--text-mute)" />
<div class="upload-text">
<div>Drop a CSV here, or click to browse</div>
<Mono dim>columns: name, email, role, group, license</Mono>
</div>
</div>
<div class="info-box">
<Mono dim>// example</Mono>
<pre class="csv-sample">name,email,role,group,license
Anne Hansen,anne@baslund.dk,owner,Leadership,business
Mikkel Sørensen,mikkel@baslund.dk,admin,Engineering,business</pre>
</div>
</div>
<template #footer>
<UiButton variant="ghost" @click="importOpen = false">Cancel</UiButton>
<UiButton variant="primary" @click="importOpen = false; toast.ok('22 users imported · 2 skipped')">
<template #leading><UiIcon name="check" :size="13" /></template>
Import users
</UiButton>
</template>
</Modal>
<!-- Bulk · assign group -->
<Modal :open="assignGroupOpen" :eyebrow="`${selected.size} selected`" title="Add to groups" size="md" @close="assignGroupOpen = false">
<div class="form-stack">
<Eyebrow>Pick one or more groups</Eyebrow>
<div class="check-stack">
<label v-for="g in ['Engineering', 'Design', 'Operations', 'Finance', 'Sales', 'Leadership']" :key="g">
<input type="checkbox" :checked="groupChoice.has(g)" @change="toggleGroup(g)" />
{{ g }}
</label>
</div>
</div>
<template #footer>
<UiButton variant="ghost" @click="assignGroupOpen = false">Cancel</UiButton>
<UiButton variant="primary" :disabled="groupChoice.size === 0" @click="applyBulkGroup">
<template #leading><UiIcon name="check" :size="13" /></template>
Add to groups
</UiButton>
</template>
</Modal>
<!-- Bulk · change role -->
<Modal :open="changeRoleOpen" :eyebrow="`${selected.size} selected`" title="Change role" size="md" @close="changeRoleOpen = false">
<div class="form-stack">
<Eyebrow>New role</Eyebrow>
<label v-for="r in ['member', 'admin', 'owner'] as const" :key="r" class="role-row" :class="{ active: roleChoice === r }">
<input type="radio" :value="r" v-model="roleChoice" />
<div>
<div class="role-name">{{ r[0].toUpperCase() + r.slice(1) }}</div>
<Mono dim>
{{ r === 'member' ? 'Standard access to apps' :
r === 'admin' ? 'Manage users, billing, and settings' :
'Full control — including billing and ownership' }}
</Mono>
</div>
</label>
</div>
<template #footer>
<UiButton variant="ghost" @click="changeRoleOpen = false">Cancel</UiButton>
<UiButton variant="primary" @click="applyBulkRole">Update role</UiButton>
</template>
</Modal>
<!-- Bulk · suspend -->
<ConfirmDialog
:open="suspendOpen"
:eyebrow="`${selected.size} selected`"
:title="`Suspend ${selected.size} user${selected.size === 1 ? '' : 's'}?`"
confirm-label="Suspend"
tone="danger"
@close="suspendOpen = false"
@confirm="applyBulkSuspend"
>
Sign-in will be blocked across mail, files, chat, and meetings. Data is preserved; you can re-enable any time.
</ConfirmDialog>
<!-- Create group modal -->
<Modal :open="createGroupOpen" eyebrow="Groups" title="New group" size="md" @close="createGroupOpen = false">
<div class="form-stack">
<label class="field"><Eyebrow>Group name</Eyebrow><input class="input" placeholder="Engineering" /></label>
<label class="field"><Eyebrow>Mail alias</Eyebrow><input class="input" placeholder="eng@dezky.com" /></label>
<label class="field"><Eyebrow>Description</Eyebrow><input class="input" placeholder="Product engineering team" /></label>
</div>
<template #footer>
<UiButton variant="ghost" @click="createGroupOpen = false">Cancel</UiButton>
<UiButton variant="primary" @click="createGroupOpen = false; toast.ok('Group created')">Create group</UiButton>
</template>
</Modal>
</div>
</template>
<style scoped>
.tab-wrap { padding: 16px 40px 0 40px; }
.content { padding: 16px 40px 64px 40px; }
.toolbar { display: flex; align-items: center; gap: 12px; margin-bottom: 16px; flex-wrap: wrap; }
.input-search {
display: flex;
align-items: center;
gap: 8px;
padding: 0 12px;
height: 36px;
width: 320px;
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); }
.chip {
display: inline-flex;
align-items: center;
gap: 6px;
height: 36px;
padding: 0 12px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
font-size: 13px;
font-family: inherit;
color: var(--text);
cursor: pointer;
}
.chip span { font-weight: 500; }
.spacer { flex: 1; }
.bulk {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 16px;
margin-bottom: 12px;
background: var(--text);
color: var(--bg);
border-radius: 6px;
}
.bulk .invert :deep(button) { color: var(--bg) !important; }
.bulk :deep([data-variant='ghost']) { color: var(--bg); }
.bulk :deep([data-variant='ghost']:hover) { background: rgba(255, 255, 255, 0.06); }
.users-tbl { width: 100%; border-collapse: collapse; }
.users-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;
}
.users-tbl td {
padding: 12px 16px;
border-bottom: 1px solid var(--border);
font-size: 13px;
vertical-align: middle;
}
.users-tbl tr { cursor: pointer; }
.users-tbl tr:hover { background: var(--surface); }
.users-tbl tr:last-child td { border-bottom: none; }
.users-tbl .right { text-align: right; }
.users-tbl .check { width: 36px; }
.name-cell { display: flex; align-items: center; gap: 12px; }
.name-cell.small { gap: 8px; }
.u-name { font-weight: 500; font-size: 13px; }
.group-text { font-family: var(--font-mono); font-size: 12px; color: var(--text-dim); }
.member-cell { display: flex; align-items: center; gap: 6px; }
.g-icon {
width: 28px;
height: 28px;
border-radius: 6px;
background: var(--bg);
display: flex;
align-items: center;
justify-content: center;
color: var(--text-mute);
}
.g-icon.big { width: 44px; height: 44px; border-radius: 10px; color: var(--text-dim); border: 1px solid var(--border); }
.pager { display: flex; justify-content: space-between; align-items: center; margin-top: 16px; font-size: 12px; color: var(--text-mute); }
.pager-btns { display: flex; gap: 4px; }
.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; }
/* User detail */
.user-detail { padding-bottom: 24px; margin: -22px -24px; }
.ud-head { padding: 24px; border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 16px; }
.ud-meta { flex: 1; }
.ud-name { font-size: 17px; font-weight: 600; font-family: var(--font-display); }
.ud-badges { display: flex; gap: 6px; margin-top: 8px; }
.ud-body { padding: 24px; }
.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); }
.sub-head { font-size: 13px; font-weight: 600; margin: 24px 0 8px; }
.dev-row {
padding: 12px 14px;
border: 1px solid var(--border);
border-radius: 6px;
margin-bottom: 8px;
display: flex;
align-items: center;
gap: 12px;
}
.dev-meta { flex: 1; }
.dev-d { font-size: 13px; font-weight: 500; }
.alias-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 0;
border-bottom: 1px solid var(--border);
font-size: 13px;
}
.activity-list { font-family: var(--font-mono); font-size: 12px; }
.activity-row {
display: grid;
grid-template-columns: 80px 1fr auto;
gap: 12px;
padding: 10px 0;
border-bottom: 1px solid var(--border);
}
.activity-action { color: var(--text); }
.empty-tab { text-align: center; padding: 60px 20px; }
/* Invite modal */
.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; }
.muted { font-size: 12px; color: var(--text-mute); line-height: 1.55; }
.import { display: flex; flex-direction: column; gap: 14px; }
.upload-stage {
padding: 32px 24px;
background: var(--bg);
border: 2px dashed var(--border);
border-radius: 10px;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
cursor: pointer;
}
.upload-text { text-align: center; font-size: 13px; }
.info-box {
padding: 12px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 6px;
font-size: 12px;
}
.csv-sample {
margin: 8px 0 0 0;
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-dim);
white-space: pre-wrap;
}
/* Bulk · role picker */
.role-row {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 12px;
border: 1px solid var(--border);
border-radius: 6px;
cursor: pointer;
}
.role-row.active { border-color: var(--text); background: var(--bg); }
.role-name { font-size: 13px; font-weight: 500; }
</style>
+1 -1
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
definePageMeta({ auth: false })
definePageMeta({ auth: false, layout: 'blank' })
const route = useRoute()
const accountEmail = computed(() => (route.query.email as string) || 'this account')
+1 -1
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
definePageMeta({ auth: false })
definePageMeta({ auth: false, layout: 'blank' })
async function signInAgain() {
await navigateTo('/auth/oidc/login', { external: true })
+4 -3
View File
@@ -1,8 +1,9 @@
<script setup lang="ts">
// Custom login page (nuxt-oidc-auth customLoginPage=true means it won't auto-bounce).
// We intercept the click and kick off the OIDC flow via the module's helper.
// Interstitial login page. With customLoginPage:false in nuxt.config the
// OIDC module's route rule auto-redirects /auth/login → /auth/oidc/login,
// so this content rarely renders — it's a fallback for direct navigation.
definePageMeta({ auth: false })
definePageMeta({ auth: false, layout: 'blank' })
const email = ref('')
+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>
+484
View File
@@ -0,0 +1,484 @@
<script setup lang="ts">
// Help & support. Faithfully ports project/platform-admin.jsx `HelpScreen`
// (lines 306610). 4 tabs: Knowledge base / My tickets / New ticket / Contact.
// A SidePanel opens for a ticket's full conversation thread.
import { helpArticles, myTickets } from '~/data/enduser'
const toast = useToast()
const tab = ref('kb')
// --- Knowledge base ---
const q = ref('')
const popular = computed(() => helpArticles.filter((a) => a.popular))
const categories = computed(() => {
const map = new Map<string, typeof helpArticles>()
for (const a of helpArticles) {
if (!map.has(a.category)) map.set(a.category, [])
map.get(a.category)!.push(a)
}
return Array.from(map.entries())
})
// --- Tickets ---
const openTicket = ref<typeof myTickets[number] | null>(null)
// Ticket conversation thread mirrors source TicketDetail messages.
const ticketThread = computed(() => {
if (!openTicket.value) return []
return [
{ who: 'You', when: `${openTicket.value.age} ago`, them: false,
body: `Hi — we're seeing slow delivery to Gmail recipients from @dezky.com. Started yesterday around 14:00 CET. SPF and DKIM all check out via mxtoolbox. Could you investigate?` },
{ who: 'Sofie Lindberg', when: '4 h ago', them: true,
body: `Thanks for the detailed report — we've reproduced it. Looks like our outbound IP got temporarily greylisted by Google after a brief spike. Working with Postmark to resolve. ETA 30 minutes.` },
{ who: 'Sofie Lindberg', when: '2 h ago', them: true,
body: `Postmark resolved the greylisting. Delivery should be back to normal. Can you confirm on your end and we'll close this out?` },
]
})
// --- New ticket ---
const newTicket = reactive({
subject: '',
affected: 'Mail · delivery to Gmail',
severity: 'P3' as 'P1' | 'P2' | 'P3' | 'P4',
body: '',
})
function submitTicket() {
toast.ok('Ticket submitted', `Severity ${newTicket.severity} · we'll reply within SLA`)
newTicket.subject = ''
newTicket.body = ''
tab.value = 'tickets'
}
// Tone resolver for ticket statuses (matches source TicketsTab inline logic).
function ticketTone(status: string): 'ok' | 'info' | 'warn' {
if (status === 'resolved') return 'ok'
if (status === 'in progress') return 'info'
return 'warn'
}
</script>
<template>
<div>
<PageHeader
eyebrow="Get unstuck"
title="Help & support"
subtitle="Search the knowledge base, file a ticket, or pick up an existing conversation."
>
<template #actions>
<UiButton variant="secondary" @click="toast.info('Opening live chat')">
<template #leading><UiIcon name="chat" :size="13" /></template>
Live chat
</UiButton>
<UiButton variant="primary" @click="tab = 'new'">
<template #leading><UiIcon name="plus" :size="13" /></template>
New ticket
</UiButton>
</template>
</PageHeader>
<div class="tabs-wrap">
<Tabs
v-model="tab"
:items="[
{ value: 'kb', label: 'Knowledge base' },
{ value: 'tickets', label: 'My tickets', count: myTickets.length },
{ value: 'new', label: 'New ticket' },
{ value: 'contact', label: 'Contact' },
]"
/>
</div>
<div class="content">
<!-- Knowledge base -->
<section v-if="tab === 'kb'">
<div class="search-wrap">
<div class="search">
<UiIcon name="search" :size="18" stroke="var(--text-mute)" />
<input v-model="q" placeholder="Search articles… try 'MFA setup' or 'OIOUBL'" />
<span class="kbd">/</span>
</div>
</div>
<!-- Popular row -->
<div class="kb-section">
<Eyebrow style="display: block; margin-bottom: 12px;">Popular</Eyebrow>
<div class="popular-grid">
<button v-for="a in popular" :key="a.id" class="popular-tile" @click="toast.info('Opening ' + a.title)">
<span class="pt-icon"><UiIcon name="file" :size="16" /></span>
<div class="pt-text">
<div class="pt-title">{{ a.title }}</div>
<Mono dim>{{ a.category }} · {{ a.read }}</Mono>
</div>
<UiIcon name="arrowRight" :size="14" stroke="var(--text-mute)" />
</button>
</div>
</div>
<!-- All categories -->
<Eyebrow style="display: block; margin-bottom: 12px;">All categories</Eyebrow>
<div class="cat-grid">
<Card v-for="[cat, articles] in categories" :key="cat" :pad="0">
<div class="cat-head">
<div class="cat-name">{{ cat }}</div>
<Mono dim>{{ articles.length }} article{{ articles.length > 1 ? 's' : '' }}</Mono>
</div>
<button
v-for="a in articles"
:key="a.id"
class="cat-row"
@click="toast.info('Opening ' + a.title)"
>
<div class="cr-text">
<div class="cr-title">{{ a.title }}</div>
<Mono dim>{{ a.read }} read</Mono>
</div>
<UiIcon name="chevRight" :size="13" stroke="var(--text-mute)" />
</button>
</Card>
</div>
</section>
<!-- My tickets -->
<section v-else-if="tab === 'tickets'">
<Card :pad="0">
<table class="tickets">
<thead>
<tr>
<th>ID</th>
<th>Subject</th>
<th>Status</th>
<th>Severity</th>
<th>Age</th>
</tr>
</thead>
<tbody>
<tr v-for="t in myTickets" :key="t.id" @click="openTicket = t">
<td><Mono>{{ t.id }}</Mono></td>
<td>
<div class="t-subj">{{ t.title }}</div>
<Mono dim>{{ t.updates }} update{{ t.updates > 1 ? 's' : '' }} · last {{ t.last }}</Mono>
</td>
<td><Badge :tone="ticketTone(t.status)" dot>{{ t.status }}</Badge></td>
<td><Mono>{{ t.severity }}</Mono></td>
<td><Mono dim>{{ t.age }}</Mono></td>
</tr>
</tbody>
</table>
</Card>
</section>
<!-- New ticket -->
<section v-else-if="tab === 'new'">
<div class="new-wrap">
<p class="new-intro">
Tell us what's not working and we'll get back to you within your plan's SLA. Most P3 tickets are answered within 4 hours during business days.
</p>
<div class="new-form">
<EnduserFormField label="Subject">
<input v-model="newTicket.subject" placeholder="What's the problem in one sentence?" />
</EnduserFormField>
<EnduserFormField label="Affected area">
<input v-model="newTicket.affected" />
</EnduserFormField>
<EnduserFormField label="Severity">
<div class="sev-row">
<button
v-for="s in (['P1', 'P2', 'P3', 'P4'] as const)"
:key="s"
:class="{ active: newTicket.severity === s }"
@click="newTicket.severity = s"
>{{ s }}</button>
</div>
<div class="sev-help">
<b>P1</b> · outage affecting whole org · <b>P2</b> · major feature broken · <b>P3</b> · standard · <b>P4</b> · question / feature request
</div>
</EnduserFormField>
<EnduserFormField label="What happened">
<textarea v-model="newTicket.body" placeholder="What did you try? What did you expect to happen? What actually happened?" rows="6" />
</EnduserFormField>
<EnduserFormField label="Attachments">
<button class="drop" @click="toast.info('File picker stub')">
<UiIcon name="upload" :size="14" />
<span>Drag screenshots or click to browse · 25 MB limit</span>
</button>
</EnduserFormField>
</div>
<div class="form-actions">
<UiButton variant="ghost" @click="toast.info('Draft saved')">Save draft</UiButton>
<UiButton variant="primary" @click="submitTicket">
<template #leading><UiIcon name="mail" :size="13" /></template>
Submit ticket
</UiButton>
</div>
</div>
</section>
<!-- Contact -->
<section v-else-if="tab === 'contact'">
<div class="contact-grid">
<Card>
<div class="c-card">
<span class="c-tile primary"><UiIcon name="chat" :size="20" /></span>
<div>
<div class="c-l">Live chat</div>
<div class="c-d">Available MonFri · 08:0018:00 CET</div>
</div>
<UiButton variant="primary" @click="toast.info('Live chat opening')">
Open chat
<template #trailing><UiIcon name="chevRight" :size="14" /></template>
</UiButton>
</div>
</Card>
<Card>
<div class="c-card">
<span class="c-tile"><UiIcon name="mail" :size="20" /></span>
<div>
<div class="c-l">Email</div>
<div class="c-d">support@dezky.com · response within 4h</div>
</div>
<UiButton variant="secondary" @click="toast.info('Composing email')">
Compose mail
<template #trailing><UiIcon name="chevRight" :size="14" /></template>
</UiButton>
</div>
</Card>
<Card>
<div class="c-card">
<span class="c-tile"><UiIcon name="video" :size="20" /></span>
<div>
<div class="c-l">Schedule a call</div>
<div class="c-d">For complex setup or migrations</div>
</div>
<UiButton variant="secondary" @click="toast.info('Opening scheduler')">
Book 30 min
<template #trailing><UiIcon name="chevRight" :size="14" /></template>
</UiButton>
</div>
</Card>
</div>
<div class="escalation">
<Eyebrow style="display: block; margin-bottom: 10px;">Escalation</Eyebrow>
<div class="esc-grid">
<div>
<Mono dim>P1 outage · 24/7</Mono>
<div class="esc-val">+45 70 70 12 34 · oncall@dezky.com</div>
</div>
<div>
<Mono dim>Account manager</Mono>
<div class="esc-val">Mette Holst · mette@dezky.com</div>
</div>
<div>
<Mono dim>Status page</Mono>
<div class="esc-val"><a href="#">status.dezky.com</a></div>
</div>
</div>
</div>
</section>
</div>
<!-- Ticket detail side panel -->
<SidePanel
:open="openTicket !== null"
width="lg"
:eyebrow="openTicket?.id"
:title="openTicket?.title"
@close="openTicket = null"
>
<template #header v-if="openTicket">
<div class="ticket-head">
<Badge :tone="ticketTone(openTicket.status)" dot>{{ openTicket.status }}</Badge>
<Badge tone="neutral">{{ openTicket.severity }}</Badge>
<Mono dim>opened {{ openTicket.age }} ago</Mono>
</div>
</template>
<div class="thread">
<div v-for="(m, i) in ticketThread" :key="i" class="msg" :data-them="m.them">
<div class="msg-head">
<Avatar v-if="m.them" :name="m.who" :size="24" />
<span v-else class="msg-you">YOU</span>
<span class="msg-who">{{ m.who }}</span>
<Mono dim>{{ m.when }}</Mono>
</div>
<div class="msg-body">{{ m.body }}</div>
</div>
</div>
<div class="reply-box">
<textarea placeholder="Write a reply…" rows="4" />
</div>
<template #footer>
<UiButton variant="ghost" @click="toast.ok('Ticket marked resolved')">Mark as resolved</UiButton>
<div style="flex: 1;" />
<UiButton variant="primary" @click="toast.info('Reply sent')">
<template #leading><UiIcon name="mail" :size="13" /></template>
Reply
</UiButton>
</template>
</SidePanel>
</div>
</template>
<style scoped>
.tabs-wrap { padding: 0 40px; margin-top: 16px; }
.content { padding: 20px 40px 64px 40px; }
/* KB search */
.search-wrap { max-width: 720px; margin: 0 auto 28px auto; }
.search {
display: flex; align-items: center; gap: 12px;
padding: 0 20px; height: 56px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 10px;
}
.search input {
flex: 1; border: none; outline: none; background: transparent;
font-size: 15px; color: var(--text); font-family: inherit;
}
.kbd {
font-family: var(--font-mono); font-size: 11px;
padding: 3px 8px; background: var(--bg);
border-radius: 4px; color: var(--text-mute);
border: 1px solid var(--border);
}
.kb-section { margin-bottom: 28px; }
/* Popular tiles */
.popular-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 10px; }
.popular-tile {
display: flex; align-items: center; gap: 14px;
padding: 18px;
background: var(--surface); border: 1px solid var(--border); border-radius: 8px;
cursor: pointer; font-family: inherit; color: var(--text); text-align: left;
}
.popular-tile:hover { border-color: var(--text); }
.pt-icon {
width: 36px; height: 36px; border-radius: 7px;
background: var(--text); color: var(--bg);
display: inline-flex; align-items: center; justify-content: center;
flex-shrink: 0;
}
.pt-text { flex: 1; min-width: 0; }
.pt-title { font-size: 14px; font-weight: 500; }
.pt-text :deep(.mono) { display: block; margin-top: 2px; }
/* Categories grid */
.cat-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 12px; }
.cat-head { padding: 16px 20px; border-bottom: 1px solid var(--border); }
.cat-name { font-family: var(--font-display); font-weight: 600; font-size: 16px; }
.cat-head :deep(.mono) { display: block; margin-top: 2px; }
.cat-row {
display: flex; align-items: center; gap: 10px;
width: 100%; padding: 12px 20px;
background: transparent; border: none; border-bottom: 1px solid var(--border);
cursor: pointer; font-family: inherit; color: var(--text); text-align: left;
}
.cat-row:last-child { border-bottom: none; }
.cat-row:hover { background: var(--row-hover); }
.cr-text { flex: 1; min-width: 0; }
.cr-title { font-size: 13px; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.cr-text :deep(.mono) { display: block; margin-top: 2px; }
/* Tickets table */
.tickets { width: 100%; border-collapse: collapse; }
.tickets thead th {
text-align: left;
padding: 12px 22px;
font-family: var(--font-mono); font-size: 10px; letter-spacing: 0.12em; text-transform: uppercase; color: var(--text-mute);
font-weight: 500; background: var(--bg); border-bottom: 1px solid var(--border);
}
.tickets tbody td {
padding: 14px 22px;
font-size: 13px;
border-bottom: 1px solid var(--border);
vertical-align: top;
}
.tickets tbody tr { cursor: pointer; }
.tickets tbody tr:hover { background: var(--row-hover); }
.tickets tbody tr:last-child td { border-bottom: none; }
.t-subj { font-size: 13px; font-weight: 500; }
.tickets td :deep(.mono) { display: block; margin-top: 2px; }
/* New ticket form */
.new-wrap { max-width: 680px; }
.new-intro { color: var(--text-dim); font-size: 14px; line-height: 1.6; margin: 0 0 24px 0; }
.new-form { display: flex; flex-direction: column; gap: 14px; }
.new-form textarea {
width: 100%; min-height: 140px; padding: 12px;
background: var(--surface); border: 1px solid var(--border); border-radius: 6px;
font-size: 13px; color: var(--text); font-family: inherit; resize: vertical; line-height: 1.55;
box-sizing: border-box;
}
.sev-row { display: inline-flex; gap: 6px; }
.sev-row button {
padding: 8px 18px; border-radius: 6px;
background: var(--surface); border: 1px solid var(--border);
color: var(--text); font-family: inherit; font-size: 13px; font-weight: 500;
cursor: pointer;
}
.sev-row button.active { background: var(--text); color: var(--bg); border-color: var(--text); }
.sev-help { font-size: 12px; color: var(--text-mute); margin-top: 6px; line-height: 1.5; }
.sev-help b { color: var(--text); }
.drop {
width: 100%; padding: 20px 14px;
background: transparent; border: 1px dashed var(--border-hi); border-radius: 6px;
color: var(--text-mute); cursor: pointer; font-family: inherit; font-size: 13px;
display: flex; align-items: center; justify-content: center; gap: 8px;
}
.drop:hover { border-color: var(--text); color: var(--text); background: var(--row-hover); }
.form-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 20px; }
/* Contact */
.contact-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; max-width: 1100px; margin-bottom: 16px; }
.c-card { display: flex; flex-direction: column; align-items: flex-start; gap: 16px; }
.c-tile {
width: 44px; height: 44px; border-radius: 10px;
background: var(--bg); color: var(--text-dim);
display: inline-flex; align-items: center; justify-content: center;
}
.c-tile.primary { background: var(--text); color: var(--bg); }
.c-l { font-family: var(--font-display); font-weight: 600; font-size: 18px; }
.c-d { margin-top: 6px; font-size: 13px; color: var(--text-mute); line-height: 1.5; }
.escalation {
padding: 16px;
background: var(--surface); border: 1px solid var(--border); border-radius: 8px;
max-width: 1100px;
}
.esc-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; font-size: 13px; }
.esc-val { margin-top: 4px; }
.esc-val a { color: inherit; }
/* Ticket detail side panel */
.ticket-head { display: flex; gap: 8px; align-items: center; margin-top: 8px; flex-wrap: wrap; }
.thread { display: flex; flex-direction: column; gap: 16px; }
.msg {
padding: 14px;
background: var(--surface); border: 1px solid var(--border); border-radius: 8px;
}
.msg[data-them='true'] { background: var(--bg); }
.msg-head { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; }
.msg-you {
display: inline-flex; align-items: center; justify-content: center;
width: 24px; height: 24px; border-radius: 999px;
background: var(--text); color: var(--bg);
font-size: 10px; font-weight: 700;
}
.msg-who { font-size: 13px; font-weight: 500; }
.msg-body { font-size: 13px; line-height: 1.6; color: var(--text); }
.reply-box { padding-top: 16px; }
.reply-box textarea {
width: 100%; min-height: 100px; padding: 12px;
background: var(--surface); border: 1px solid var(--border); border-radius: 6px;
font-size: 13px; color: var(--text); font-family: inherit; resize: vertical;
box-sizing: border-box;
}
</style>
+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>
+407
View File
@@ -0,0 +1,407 @@
<script setup lang="ts">
// Partner audit log. Strict port of PartnerAuditScreen
// (platform-partner-depth.jsx lines 1042-1098). Filter bar (search + Actor /
// Customer / Action / Last) + cross-customer audit log table + footer note.
// Click a row to open a detail SidePanel with before/after diff and origin.
const toast = useToast()
// AuditRow shape the template + side panel render against. Built from the
// real /api/partner/activity feed (see `rows` below).
interface AuditRow {
id: string
when: string // formatted timestamp for the table
whenIso: string // raw ISO for period filtering
actor: string // actor email
customer: string // tenant name resolved from tenantSlug, or ''
customerColor: string // tile colour for the cust swatch
action: string // dotted verb e.g. 'partner.user_invited'
target: string // resourceName from the audit event
tone: 'info' | 'warn' | 'ok' | 'bad'
}
interface PartnerTenant { _id: string; slug: string; name: string }
interface ActivityEvent {
_id: string
at: string
action: string
resourceName?: string
tenantSlug?: string
outcome?: 'success' | 'failure' | 'pending'
actor?: { email?: string; userId?: string }
}
// Pull the partner's tenants (for slugname + colour lookup) and recent
// audit events. Limit 200 generous compared to the dashboard's 8 so
// filters meaningfully shrink the visible set client-side. Pagination via
// `?before` lands when 200 is regularly hit.
const { data: tenants } = await useFetch<PartnerTenant[]>('/api/partner/tenants', {
key: 'partner-tenants',
default: () => [],
})
const { data: events } = await useFetch<ActivityEvent[]>('/api/partner/activity', {
key: 'partner-activity-full',
query: { limit: 200 },
default: () => [],
})
function tenantNameFromSlug(slug?: string): string {
if (!slug) return '—'
return tenants.value?.find((t) => t.slug === slug)?.name ?? slug
}
// Deterministic colour per tenant slug so the swatch stays stable across
// reloads even though we don't store brand colours on Tenant yet.
const PALETTE = ['#D4FF3A', '#4D8BE8', '#34C77B', '#F0B14A', '#F05858', '#A78BFA']
function tenantColor(slug?: string): string {
if (!slug) return 'var(--text-mute)'
let h = 0
for (let i = 0; i < slug.length; i++) h = (h * 31 + slug.charCodeAt(i)) | 0
return PALETTE[Math.abs(h) % PALETTE.length]
}
function eventTone(e: ActivityEvent): AuditRow['tone'] {
if (e.outcome === 'failure') return 'bad'
if (e.outcome === 'pending') return 'warn'
if (/\.(deleted|suspended|terminated|removed)$/.test(e.action)) return 'bad'
return 'info'
}
function fmtTime(iso: string): string {
// Always full date + time. The "today shows time only" shortcut made it
// unclear whether a bare "08.35" was this morning or yesterday morning;
// for an audit log, consistency beats brevity.
return new Date(iso).toLocaleString('da-DK', { dateStyle: 'short', timeStyle: 'short' })
}
const rows = computed<AuditRow[]>(() =>
(events.value ?? []).map((e) => ({
id: e._id,
when: fmtTime(e.at),
whenIso: e.at,
actor: e.actor?.email ?? 'system',
customer: tenantNameFromSlug(e.tenantSlug),
customerColor: tenantColor(e.tenantSlug),
action: e.action,
target: e.resourceName ?? '—',
tone: eventTone(e),
})),
)
const query = ref('')
const actorFilter = ref<string>('all')
const customerFilter = ref<string>('all')
const actionFilter = ref<string>('all')
const periodFilter = ref<'24h' | '7d' | '30d'>('7d')
const actors = computed(() => Array.from(new Set(rows.value.map((r) => r.actor))))
const actions = computed(() => Array.from(new Set(rows.value.map((r) => r.action))).sort())
// Distinct customer list for the dropdown replaces the fixture customers
// array. '' (partner-scoped events) shows up as a special "Partner-level"
// option so filtering to those is easy.
const customerOptions = computed(() => {
const names = new Set<string>()
for (const r of rows.value) names.add(r.customer)
return Array.from(names).sort()
})
const PERIOD_MS: Record<typeof periodFilter.value, number> = {
'24h': 24 * 60 * 60 * 1000,
'7d': 7 * 24 * 60 * 60 * 1000,
'30d': 30 * 24 * 60 * 60 * 1000,
}
const filtered = computed(() => {
const cutoff = Date.now() - PERIOD_MS[periodFilter.value]
return rows.value.filter((r) => {
if (new Date(r.whenIso).getTime() < cutoff) return false
if (actorFilter.value !== 'all' && r.actor !== actorFilter.value) return false
if (actionFilter.value !== 'all' && r.action !== actionFilter.value) return false
if (customerFilter.value !== 'all' && r.customer !== customerFilter.value) return false
if (query.value) {
const q = query.value.toLowerCase()
if (!(r.actor + ' ' + r.customer + ' ' + r.action + ' ' + r.target).toLowerCase().includes(q)) return false
}
return true
})
})
function customerColor(name: string) {
if (name === '—') return 'var(--text-mute)'
// Look up tenant by name to find the slug, then derive colour.
const t = tenants.value?.find((x) => x.name === name)
return tenantColor(t?.slug)
}
const detail = ref<AuditRow | null>(null)
</script>
<template>
<div>
<PageHeader
eyebrow="Compliance"
title="Partner audit log"
subtitle="Every action your team has taken across your customer portfolio. Customer admins see this in their own audit log too."
/>
<div class="content">
<div class="filters">
<div class="search">
<UiIcon name="search" :size="14" />
<input v-model="query" placeholder="actor, customer, action…" />
</div>
<div class="seg">
<span class="seg-label">Actor</span>
<select v-model="actorFilter">
<option value="all">Anyone</option>
<option v-for="a in actors" :key="a" :value="a">{{ a }}</option>
</select>
</div>
<div class="seg">
<span class="seg-label">Customer</span>
<select v-model="customerFilter">
<option value="all">All customers</option>
<option v-for="name in customerOptions" :key="name" :value="name">{{ name }}</option>
</select>
</div>
<div class="seg">
<span class="seg-label">Action</span>
<select v-model="actionFilter">
<option value="all">All actions</option>
<option v-for="a in actions" :key="a" :value="a">{{ a }}</option>
</select>
</div>
<div class="seg">
<span class="seg-label">Last</span>
<select v-model="periodFilter">
<option value="24h">24 hours</option>
<option value="7d">7 days</option>
<option value="30d">30 days</option>
</select>
</div>
<div class="spacer" />
<UiButton variant="secondary" @click="toast.ok('Exporting CSV', `${filtered.length} entries`)">
<template #leading><UiIcon name="download" :size="14" /></template>
Export CSV
</UiButton>
</div>
<Card :pad="0">
<table class="dtable">
<thead>
<tr>
<th>Time</th>
<th>Actor</th>
<th>Customer</th>
<th>Action</th>
<th>Target</th>
<th class="tone-col" />
</tr>
</thead>
<tbody>
<tr v-for="r in filtered" :key="r.id" @click="detail = r">
<td><Mono>{{ r.when }}</Mono></td>
<td>
<div class="actor-cell">
<Avatar :name="r.actor" :size="22" />
<div>
<div class="actor-name">{{ r.actor }}</div>
<Mono dim>partner</Mono>
</div>
</div>
</td>
<td>
<Mono v-if="r.customer === '—'" dim></Mono>
<div v-else class="cust-cell">
<div class="cust-swatch" :style="{ background: customerColor(r.customer) }" />
<span>{{ r.customer }}</span>
</div>
</td>
<td><Mono class="action-text">{{ r.action }}</Mono></td>
<td><span class="target-text">{{ r.target }}</span></td>
<td class="tone-col">
<Badge :tone="r.tone" dot>{{ r.tone === 'bad' ? 'fail' : r.tone }}</Badge>
</td>
</tr>
</tbody>
</table>
</Card>
<Mono dim class="footer-note">// retention 365 days · write-once · visible to customer admins on their own audit log</Mono>
</div>
<!-- Detail side panel -->
<SidePanel
:open="!!detail"
width="md"
eyebrow="Audit event"
:title="detail?.action || ''"
@close="detail = null"
>
<template v-if="detail">
<div class="detail-head">
<Mono dim>{{ detail.when }}</Mono>
<Badge :tone="detail.tone" dot>{{ detail.tone === 'bad' ? 'fail' : detail.tone }}</Badge>
</div>
<div class="detail-section">
<Eyebrow>Actor</Eyebrow>
<div class="actor-row">
<Avatar :name="detail.actor" :size="32" />
<div>
<div class="dn">{{ detail.actor }}</div>
<Mono dim>partner staff</Mono>
</div>
</div>
</div>
<div class="detail-section">
<Eyebrow>Target</Eyebrow>
<div class="target-row">
<div v-if="detail.customer !== '—'" class="cust-cell">
<div class="cust-swatch" :style="{ background: customerColor(detail.customer) }" />
<span>{{ detail.customer }}</span>
</div>
<Mono>{{ detail.target }}</Mono>
</div>
</div>
<div class="detail-section">
<Eyebrow>Event ID</Eyebrow>
<div class="eid"><Mono>{{ detail.id }}</Mono></div>
</div>
</template>
<template #footer>
<div class="audit-footer">
<UiIcon name="shield" :size="12" />
<Mono dim>tamper-evident · retention 365 days</Mono>
</div>
</template>
</SidePanel>
</div>
</template>
<style scoped>
.content { padding: 20px 40px 64px; display: flex; flex-direction: column; gap: 12px; }
.filters { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.search {
display: flex;
align-items: center;
gap: 8px;
padding: 0 10px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
width: 320px;
color: var(--text-mute);
}
.search input {
flex: 1;
border: none;
background: transparent;
font-family: inherit;
font-size: 13px;
padding: 9px 0;
color: var(--text);
}
.search input:focus { outline: none; }
.seg {
display: flex;
align-items: center;
gap: 6px;
padding: 0 10px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
}
.seg-label {
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--text-mute);
}
.seg select {
border: none;
background: transparent;
font-family: inherit;
font-size: 13px;
color: var(--text);
padding: 8px 4px;
cursor: pointer;
}
.seg select:focus { outline: none; }
.spacer { flex: 1; }
.dtable { width: 100%; border-collapse: collapse; }
.dtable th {
text-align: left;
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--text-mute);
font-weight: 500;
padding: 12px 16px;
border-bottom: 1px solid var(--border);
}
.dtable th.tone-col, .dtable td.tone-col { width: 80px; text-align: right; }
.dtable td {
padding: 12px 16px;
border-bottom: 1px solid var(--border);
font-size: 13px;
vertical-align: middle;
}
.dtable tbody tr { cursor: pointer; transition: background 80ms; }
.dtable tbody tr:hover { background: var(--row-hover); }
.actor-cell { display: flex; align-items: center; gap: 8px; }
.actor-name { font-size: 12px; font-weight: 500; }
.cust-cell { display: flex; align-items: center; gap: 6px; }
.cust-swatch { width: 12px; height: 12px; border-radius: 2px; flex-shrink: 0; }
.action-text { font-weight: 500; }
.target-text { font-size: 12px; color: var(--text-dim); }
.footer-note { display: block; margin-top: 4px; }
/* Side panel detail */
.detail-head {
display: flex;
justify-content: space-between;
align-items: center;
padding-bottom: 14px;
margin-bottom: 18px;
border-bottom: 1px solid var(--border);
}
.detail-section { margin-bottom: 20px; }
.detail-section + .detail-section { padding-top: 20px; border-top: 1px solid var(--border); }
.actor-row { display: flex; align-items: center; gap: 12px; margin-top: 8px; }
.dn { font-size: 14px; font-weight: 500; }
.target-row { display: flex; align-items: center; gap: 10px; margin-top: 8px; font-size: 13px; }
.eid { margin-top: 8px; }
.audit-footer {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
width: 100%;
}
.audit-footer :deep(svg) { color: var(--text-mute); }
</style>
+302
View File
@@ -0,0 +1,302 @@
<script setup lang="ts">
// Partner billing. Strict port of PartnerBillingScreen in partner-screens.jsx
// (lines 691-838). Four tabs: Overview / Customer invoices / Margin & revenue
// / Payouts. Each tab numbers seeded to match the source.
import { customers, partnerInvoices, partner } from '~/data/customers'
const toast = useToast()
const tab = ref<'overview' | 'invoices' | 'margin' | 'payouts'>('overview')
const tabs = [
{ value: 'overview', label: 'Overview' },
{ value: 'invoices', label: 'Customer invoices', count: 47 },
{ value: 'margin', label: 'Margin & revenue' },
{ value: 'payouts', label: 'Payouts', count: 12 },
]
function statusBadge(s: string): { tone: 'ok' | 'warn' | 'bad' | 'info' | 'neutral'; label: string } {
switch (s) {
case 'healthy': return { tone: 'ok', label: 'healthy' }
case 'attention': return { tone: 'warn', label: 'attention' }
case 'past_due': return { tone: 'bad', label: 'past-due' }
case 'trial': return { tone: 'info', label: 'trial' }
default: return { tone: 'neutral', label: s }
}
}
function invoiceTone(s: string): 'ok' | 'warn' | 'bad' | 'neutral' {
if (s === 'paid') return 'ok'
if (s === 'past_due') return 'bad'
if (s === 'sent') return 'warn'
return 'neutral'
}
// 52-week revenue series for Margin & revenue tab (deterministic).
const revenueSeries = Array.from({ length: 52 }, (_, i) => 8000 + i * 180 + Math.sin(i / 3) * 600)
const payouts = [
{ period: 'May 2026', amt: '11.150,00', paid: '—', ref: 'pending', status: 'pending' as const },
{ period: 'April 2026', amt: '10.520,00', paid: '03 May 2026', ref: 'TR-29841', status: 'paid' as const },
{ period: 'March 2026', amt: '9.840,00', paid: '03 Apr 2026', ref: 'TR-29402', status: 'paid' as const },
{ period: 'Feb 2026', amt: '9.180,00', paid: '03 Mar 2026', ref: 'TR-28977', status: 'paid' as const },
]
</script>
<template>
<div>
<PageHeader
eyebrow="Commercial"
title="Partner billing"
subtitle="Aggregate billing across your customer portfolio, margins, and payouts from Dezky."
>
<template #actions>
<UiButton variant="secondary" @click="toast.ok('Exporting', 'PDF compiled')">
<template #leading><UiIcon name="download" :size="14" /></template>
Export
</UiButton>
</template>
</PageHeader>
<div class="tabs-wrap">
<Tabs v-model="tab" :items="tabs" />
</div>
<!-- OVERVIEW -->
<div v-if="tab === 'overview'" class="content">
<div class="stat-strip">
<Card>
<Stat label="MRR · portfolio" value="55.750 DKK" delta="+18.2%" delta-tone="up" hint="vs. last month" />
</Card>
<Card>
<Stat :label="`Partner cut · ${partner.marginPct}%`" value="11.150 DKK" delta="+19.0%" delta-tone="up" />
</Card>
<Card>
<Stat label="Net to Dezky" value="44.600 DKK" hint="monthly" />
</Card>
<Card>
<Stat label="Open A/R" value="2.940 DKK" hint="1 customer past-due" delta-tone="down" />
</Card>
</div>
<Card :pad="0">
<div class="card-head">
<div>
<Eyebrow>Per customer · this month</Eyebrow>
<div class="card-title">Revenue breakdown</div>
</div>
</div>
<table class="dtable">
<thead>
<tr>
<th>Customer</th>
<th>Plan</th>
<th>Seats</th>
<th class="num">MRR</th>
<th class="num">Partner cut ({{ partner.marginPct }}%)</th>
<th>Status</th>
</tr>
</thead>
<tbody>
<tr v-for="c in customers" :key="c.id">
<td>
<div class="cust-cell">
<div class="cust-swatch" :style="{ background: c.brandColor }" />
<div>
<div class="cust-name">{{ c.name }}</div>
<Mono dim>{{ c.domain }}</Mono>
</div>
</div>
</td>
<td><Badge tone="neutral">{{ c.planLabel }}</Badge></td>
<td><Mono>{{ c.seats.used }}</Mono></td>
<td class="num"><span class="mrr">{{ c.mrrDkk.toLocaleString('da-DK') }} DKK</span></td>
<td class="num"><span class="cut">{{ Math.round(c.mrrDkk * partner.marginPct / 100).toLocaleString('da-DK') }} DKK</span></td>
<td>
<Badge :tone="statusBadge(c.status).tone" dot>{{ statusBadge(c.status).label }}</Badge>
</td>
</tr>
</tbody>
</table>
</Card>
</div>
<!-- CUSTOMER INVOICES -->
<div v-else-if="tab === 'invoices'" class="content">
<Card :pad="0">
<table class="dtable">
<thead>
<tr>
<th>Invoice</th>
<th>Customer</th>
<th>Date</th>
<th class="num">Amount</th>
<th>Status</th>
<th class="action-col" />
</tr>
</thead>
<tbody>
<tr v-for="inv in partnerInvoices" :key="inv.id">
<td><Mono>{{ inv.number }}</Mono></td>
<td><span class="cust-name">{{ inv.customer }}</span></td>
<td><span class="text-13">{{ inv.date }}</span></td>
<td class="num"><Mono>{{ inv.amount.toLocaleString('da-DK') }} DKK</Mono></td>
<td>
<Badge :tone="invoiceTone(inv.status)" dot>{{ inv.status.replace('_', '-') }}</Badge>
</td>
<td class="action-col">
<UiButton size="sm" variant="ghost" @click="toast.info('Downloading PDF', inv.number)">
<template #leading><UiIcon name="download" :size="13" /></template>
PDF
</UiButton>
</td>
</tr>
</tbody>
</table>
</Card>
</div>
<!-- MARGIN & REVENUE -->
<div v-else-if="tab === 'margin'" class="content">
<div class="grid-2">
<Card>
<Eyebrow>Margin</Eyebrow>
<div class="card-title">Your reseller margin</div>
<p class="sub">Per your agreement with Dezky · 20% gross on all customer revenue.</p>
<dl class="def">
<div><dt>Starter plan</dt><dd>20% · 9,80 DKK per seat / mo</dd></div>
<div><dt>Business plan</dt><dd>20% · 25,80 DKK per seat / mo</dd></div>
<div><dt>Enterprise plan</dt><dd>15% · negotiated per customer</dd></div>
<div><dt>Add-ons</dt><dd>Pass-through · 0%</dd></div>
<div><dt>Volume rebate</dt><dd>+2% over 200 active seats · qualifies</dd></div>
</dl>
</Card>
<Card>
<Eyebrow>Revenue · 12 months</Eyebrow>
<div class="card-title">Trailing twelve</div>
<div class="ttm-chart">
<PartnerSparkline :values="revenueSeries" :width="420" :height="120" stroke="var(--text)" fill="var(--row-hover)" />
</div>
<div class="ttm-foot">
<Mono dim>Jun 2025 · 8.180 DKK</Mono>
<Mono dim>May 2026 · 11.150 DKK</Mono>
</div>
<div class="ttm-total">
<Stat label="Total · 12 months" value="118.940 DKK" delta="+36% YoY" delta-tone="up" />
</div>
</Card>
</div>
</div>
<!-- PAYOUTS -->
<div v-else-if="tab === 'payouts'" class="content">
<Card :pad="0">
<table class="dtable">
<thead>
<tr>
<th>Period</th>
<th class="num">Amount</th>
<th>Paid on</th>
<th>Reference</th>
<th>Status</th>
</tr>
</thead>
<tbody>
<tr v-for="p in payouts" :key="p.period">
<td><span class="cust-name">{{ p.period }}</span></td>
<td class="num"><Mono>{{ p.amt }} DKK</Mono></td>
<td><Mono>{{ p.paid }}</Mono></td>
<td><Mono dim>{{ p.ref }}</Mono></td>
<td>
<Badge :tone="p.status === 'paid' ? 'ok' : 'warn'" dot>{{ p.status }}</Badge>
</td>
</tr>
</tbody>
</table>
</Card>
</div>
</div>
</template>
<style scoped>
.tabs-wrap { padding: 0 40px; margin-top: 16px; }
.content { padding: 24px 40px 64px; display: flex; flex-direction: column; gap: 16px; }
.stat-strip { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; }
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
.card-head {
padding: 20px 24px;
border-bottom: 1px solid var(--border);
}
.card-title {
font-family: var(--font-display);
font-weight: 600;
font-size: 18px;
margin-top: 4px;
}
.sub { font-size: 13px; color: var(--text-mute); margin: 6px 0 0; line-height: 1.5; }
.dtable { width: 100%; border-collapse: collapse; }
.dtable th {
text-align: left;
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--text-mute);
font-weight: 500;
padding: 12px 16px;
border-bottom: 1px solid var(--border);
}
.dtable th.num, .dtable td.num { text-align: right; }
.dtable th.action-col, .dtable td.action-col { width: 80px; text-align: right; }
.dtable td {
padding: 12px 16px;
border-bottom: 1px solid var(--border);
font-size: 13px;
vertical-align: middle;
}
.dtable tbody tr:hover { background: var(--row-hover); }
.cust-cell { display: flex; align-items: center; gap: 12px; }
.cust-swatch { width: 24px; height: 24px; border-radius: 4px; flex-shrink: 0; }
.cust-name { font-size: 13px; font-weight: 500; }
.text-13 { font-size: 13px; }
.mrr {
font-family: var(--font-mono);
font-size: 12px;
font-weight: 500;
}
.cut {
font-family: var(--font-mono);
font-size: 12px;
color: var(--ok);
}
/* TTM chart */
.def { display: flex; flex-direction: column; gap: 10px; margin: 14px 0 0; padding: 0; }
.def div { display: grid; grid-template-columns: 160px 1fr; gap: 12px; font-size: 13px; }
.def dt { color: var(--text-mute); font-family: var(--font-mono); font-size: 11px; }
.def dd { margin: 0; }
.ttm-chart { margin-top: 14px; }
.ttm-chart :deep(svg) { width: 100%; height: 120px; }
.ttm-foot {
display: flex;
justify-content: space-between;
margin-top: 16px;
}
.ttm-total {
margin-top: 20px;
padding-top: 20px;
border-top: 1px solid var(--border);
}
</style>
+250
View File
@@ -0,0 +1,250 @@
<script setup lang="ts">
// Partner branding. Strict port of PartnerBrandingScreen
// (partner-screens.jsx lines 839-942). Three cards:
// Your brand · NordicMSP identity
// Customer defaults · what gets pushed to new customers (7 toggles)
// Email templates · 2-col grid of 5 templates
import type { EmailTemplate } from '~/components/partner/EmailTemplateEditor.vue'
const toast = useToast()
const identityOpen = ref(false)
const editing = ref<EmailTemplate | null>(null)
// Customer defaults · partner-screens.jsx line 872-878
const defaults = ref([
{ l: 'Accent color', d: 'Cobalt #3F6BFF', on: true },
{ l: 'Product name pattern', d: '"{Customer} Workspace" e.g. Acme Workspace', on: true },
{ l: 'Custom subdomain', d: 'workspace.{customer-domain}', on: true },
{ l: 'Login screen', d: 'NordicMSP co-brand + customer logo', on: true },
{ l: 'Email templates', d: '5 templates · NordicMSP voice', on: true },
{ l: 'Allow customer override', d: 'Business plans and above', on: true },
{ l: 'Lock typography', d: 'Inter Tight + JetBrains Mono · brand-locked', on: false },
])
// Source mustache literals. Constructed in JS to avoid Vue parser eating
// nested {{ }} (see CRITICAL note in task brief).
const TAG_WORKSPACE = '{' + '{workspace.name}' + '}'
const TAG_INVOICE = '{' + '{invoice.id}' + '}'
const TAG_PLAN = '{' + '{plan.name}' + '}'
const templates = ref<EmailTemplate[]>([
{ id: 'welcome', name: 'Customer welcome email', subject: `Welcome to ${TAG_WORKSPACE} — managed by NordicMSP`, body: '', edited: '5 days ago' },
{ id: 'invitation', name: 'User invitation', subject: `Youve been invited to ${TAG_WORKSPACE}`, body: '', edited: '3 days ago' },
{ id: 'reset', name: 'Password reset', subject: `Reset your ${TAG_WORKSPACE} password`, body: '', edited: 'default' },
{ id: 'plan', name: 'Plan change confirmation', subject: `Your plan changed to ${TAG_PLAN}`, body: '', edited: 'default' },
{ id: 'invoice', name: 'Invoice notification', subject: `Your NordicMSP invoice ${TAG_INVOICE}`, body: '', edited: '2 weeks ago' },
])
function saveTemplate(t: EmailTemplate) {
templates.value = templates.value.map((x) => (x.id === t.id ? { ...t, edited: 'just now' } : x))
editing.value = null
toast.ok('Template saved', t.name)
}
</script>
<template>
<div>
<PageHeader
eyebrow="Whitelabel"
title="Partner branding"
subtitle="Your own brand identity, plus the defaults pushed to every customer you provision."
/>
<div class="content">
<!-- Your brand · identity card -->
<Card>
<div class="card-head">
<div>
<Eyebrow>Your brand</Eyebrow>
<div class="card-title">NordicMSP identity</div>
<p class="sub">Shown in the partner console and on emails sent by your team.</p>
</div>
<UiButton size="sm" variant="ghost" @click="identityOpen = true">Edit</UiButton>
</div>
<div class="id-grid">
<dl class="def">
<div><dt>Display name</dt><dd>NordicMSP</dd></div>
<div><dt>Logo</dt><dd>nordic-logo.svg · 4:1 horizontal</dd></div>
<div><dt>Mark</dt><dd>nordic-mark.svg · 1:1</dd></div>
<div>
<dt>Primary color</dt>
<dd>
<div class="color-row">
<div class="color-swatch" style="background:#3F6BFF" />
<Mono>#3F6BFF</Mono>
</div>
</dd>
</div>
</dl>
<dl class="def">
<div><dt>Support email</dt><dd>support@nordicmsp.dk</dd></div>
<div><dt>Support phone</dt><dd>+45 70 70 12 34</dd></div>
<div><dt>Website</dt><dd>nordicmsp.dk</dd></div>
<div><dt>Reply-to</dt><dd>no-reply@nordicmsp.dk</dd></div>
</dl>
</div>
</Card>
<!-- Customer defaults · toggle list -->
<Card>
<div class="card-head">
<div>
<Eyebrow>Customer defaults</Eyebrow>
<div class="card-title">What gets pushed to new customers</div>
<p class="sub">Applied at provisioning. Customers can override per their tier entitlements.</p>
</div>
</div>
<div class="defaults-list">
<div
v-for="(row, i) in defaults"
:key="row.l"
class="def-row"
:class="{ last: i === defaults.length - 1 }"
>
<div class="dr-meta">
<div class="dr-label">{{ row.l }}</div>
<div class="dr-detail">{{ row.d }}</div>
</div>
<button class="switch" :class="{ on: row.on }" @click="row.on = !row.on">
<span class="thumb" />
</button>
</div>
</div>
</Card>
<!-- Email templates · 2-col grid -->
<Card>
<Eyebrow>Templates</Eyebrow>
<div class="card-title">Email templates · NordicMSP defaults</div>
<div class="tpl-grid">
<button
v-for="t in templates"
:key="t.id"
class="tpl-row"
@click="editing = t"
>
<UiIcon name="mail" :size="14" />
<div class="tpl-meta">
<div class="tpl-top">
<span class="tpl-name">{{ t.name }}</span>
<Badge :tone="t.edited === 'default' ? 'neutral' : 'info'">{{ t.edited === 'default' ? 'default' : 'edited' }}</Badge>
</div>
<Mono dim>edited {{ t.edited }}</Mono>
</div>
<UiIcon name="chevRight" :size="14" />
</button>
</div>
</Card>
</div>
<PartnerEditIdentityModal :open="identityOpen" @close="identityOpen = false" />
<PartnerEmailTemplateEditor
:template="editing"
brand-color="#3F6BFF"
brand-name="NordicMSP"
@close="editing = null"
@save="saveTemplate"
/>
</div>
</template>
<style scoped>
.content { padding: 24px 40px 64px; display: flex; flex-direction: column; gap: 16px; max-width: 1100px; }
.card-head {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 16px;
}
.card-title {
font-family: var(--font-display);
font-weight: 600;
font-size: 17px;
margin-top: 4px;
}
.sub { font-size: 13px; color: var(--text-mute); margin: 6px 0 0; max-width: 580px; line-height: 1.5; }
/* Identity DefList grid */
.id-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
.def { display: flex; flex-direction: column; gap: 10px; margin: 0; padding: 0; }
.def div { display: grid; grid-template-columns: 140px 1fr; gap: 12px; font-size: 13px; align-items: center; }
.def dt { color: var(--text-mute); font-family: var(--font-mono); font-size: 11px; }
.def dd { margin: 0; }
.color-row { display: flex; align-items: center; gap: 8px; }
.color-swatch { width: 14px; height: 14px; border-radius: 3px; }
/* Defaults toggle list */
.defaults-list { display: flex; flex-direction: column; }
.def-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 12px 0;
border-bottom: 1px solid var(--border);
}
.def-row.last { border-bottom: none; }
.dr-meta { flex: 1; min-width: 0; }
.dr-label { font-size: 13px; font-weight: 500; }
.dr-detail { font-size: 12px; color: var(--text-mute); margin-top: 2px; }
.switch {
width: 36px;
height: 20px;
border-radius: 999px;
background: var(--border);
border: none;
padding: 2px;
display: inline-flex;
align-items: center;
cursor: pointer;
transition: background 150ms;
flex-shrink: 0;
}
.switch.on { background: var(--text); }
.thumb {
width: 16px;
height: 16px;
border-radius: 999px;
background: var(--bg);
transition: transform 150ms;
}
.switch.on .thumb { transform: translateX(16px); }
/* Templates grid */
.tpl-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8px;
margin-top: 12px;
}
.tpl-row {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 14px;
border-radius: 6px;
background: var(--surface);
border: 1px solid var(--border);
color: var(--text);
font-family: inherit;
font-size: 13px;
cursor: pointer;
text-align: left;
}
.tpl-row:hover { background: var(--row-hover); }
.tpl-row :deep(svg) { color: var(--text-mute); flex-shrink: 0; }
.tpl-meta { flex: 1; min-width: 0; }
.tpl-top { display: flex; align-items: center; gap: 8px; }
.tpl-name { font-weight: 500; }
</style>
+530
View File
@@ -0,0 +1,530 @@
<script setup lang="ts">
// Full portfolio list. Strict port of CustomersScreen in partner-screens.jsx
// (lines 366-494). Table + cards view toggle, status/plan filters, search.
// Click a row confirm enter customer modal partnerMode.enter() + /admin.
import type { CustomerOrg, CustomerStatus } from '~/data/customers'
const toast = useToast()
const router = useRouter()
const partnerMode = usePartnerMode()
const view = ref<'table' | 'cards'>('table')
const query = ref('')
const statusFilter = ref<'all' | CustomerStatus>('all')
const planFilter = ref<'all' | 'starter' | 'business' | 'enterprise'>('all')
const wizardOpen = ref(false)
const entryCustomer = ref<CustomerOrg | null>(null)
// Real tenants attached to this partner (via /api/partner/tenants platform-api
// /users/me/partner/tenants). The backend doesn't yet store health-score,
// MRR, or industry, so those render as placeholders. Plan + seats now come
// from real fields.
interface PartnerTenantDoc {
_id: string
slug: string
name: string
status: 'active' | 'pending' | 'suspended' | 'deleted'
plan?: 'mvp' | 'pro' | 'enterprise'
seats?: number
// Active User docs whose tenantIds include this tenant. Comes from
// /api/partner/tenants (server-side aggregation), so the column can show
// "used / total" without a second client round-trip.
userCount?: number
domains?: string[]
createdAt?: string
}
const { data: rawTenants, refresh: refreshTenants } = await useFetch<PartnerTenantDoc[]>(
'/api/partner/tenants',
{ key: 'partner-tenants', default: () => [] },
)
// Per-tenant MRR comes from the same aggregation that powers the dashboard
// MRR card. We reuse the cached response (same key) instead of issuing a
// second fetch; the customers page just reads the breakdown to fill the MRR
// column per row.
interface MrrBreakdownRow {
tenantId: string
currency: 'DKK' | 'EUR' | 'USD'
monthlyMinor: number
custom: boolean
}
interface MrrResponse {
totals: Array<{ currency: 'DKK' | 'EUR' | 'USD'; monthlyMinor: number }>
breakdown: MrrBreakdownRow[]
}
const { data: mrr, refresh: refreshMrr } = await useFetch<MrrResponse>('/api/partner/mrr', {
key: 'partner-mrr',
default: () => ({ totals: [], breakdown: [] }),
})
const mrrByTenant = computed(() => {
const m = new Map<string, MrrBreakdownRow>()
for (const row of mrr.value?.breakdown ?? []) m.set(row.tenantId, row)
return m
})
function mapTenantStatus(s: PartnerTenantDoc['status']): CustomerStatus {
// Tenant.status values overlap partially with the fixture's CustomerStatus.
// Best-effort map: deleted/suspended suspended badge, pending trial,
// active stays active (rendered as 'healthy').
if (s === 'active') return 'healthy'
if (s === 'pending') return 'trial'
return 'suspended'
}
// Backend plan enum (mvp/pro/enterprise) fixture-friendly slug + label.
// Same mapping mirrored in CustomerCreateWizard.vue; keep both in sync if
// the backend enum ever changes.
const PLAN_INFO: Record<
'mvp' | 'pro' | 'enterprise',
{ slug: CustomerOrg['plan']; label: CustomerOrg['planLabel'] }
> = {
mvp: { slug: 'starter', label: 'Starter' },
pro: { slug: 'business', label: 'Business' },
enterprise: { slug: 'enterprise', label: 'Enterprise' },
}
// Type extension on the row so the table can show the currency next to
// the amount (the original fixture only had a DKK number, but real data
// can be in DKK/EUR/USD).
interface CustomerRow extends CustomerOrg {
mrrCurrency: 'DKK' | 'EUR' | 'USD'
mrrCustom: boolean
}
const customers = computed<CustomerRow[]>(() =>
(rawTenants.value ?? []).map((t) => {
const info = PLAN_INFO[t.plan ?? 'pro']
const subMrr = mrrByTenant.value.get(t._id)
return {
id: t._id,
name: t.name,
domain: t.domains?.[0] ?? `${t.slug}.dezky.com`,
plan: info.slug,
planLabel: info.label,
// Real seat utilisation: count of active User docs attached to this
// tenant vs the contractual seat total from provisioning.
seats: { used: t.userCount ?? 0, total: t.seats ?? 0 },
health: 100,
status: mapTenantStatus(t.status),
// MRR for this tenant, in major units. From /api/partner/mrr; 0 when
// the sub has no priced amount (Enterprise / pre-catalog tenants).
mrrDkk: subMrr ? Math.round(subMrr.monthlyMinor / 100) : 0,
mrrCurrency: subMrr?.currency ?? 'DKK',
mrrCustom: subMrr?.custom ?? false,
brandColor: '#D4FF3A',
industry: '—',
createdOn: t.createdAt ?? '',
since: t.createdAt ?? '',
}
}),
)
const filtered = computed(() => {
return customers.value.filter((c) => {
if (statusFilter.value !== 'all' && c.status !== statusFilter.value) return false
if (planFilter.value !== 'all' && c.plan !== planFilter.value) return false
if (query.value) {
const q = query.value.toLowerCase()
if (!(c.name + ' ' + c.domain).toLowerCase().includes(q)) return false
}
return true
})
})
const statusOpts = [
{ value: 'all', label: 'All' },
{ value: 'healthy', label: 'Healthy' },
{ value: 'attention', label: 'Attention' },
{ value: 'past_due', label: 'Past due' },
{ value: 'trial', label: 'Trial' },
] as const
const planOpts = [
{ value: 'all', label: 'All' },
{ value: 'starter', label: 'Starter' },
{ value: 'business', label: 'Business' },
{ value: 'enterprise', label: 'Enterprise' },
] as const
function statusBadge(s: CustomerStatus): { tone: 'ok' | 'warn' | 'bad' | 'info' | 'neutral'; label: string } {
switch (s) {
case 'healthy': return { tone: 'ok', label: 'healthy' }
case 'attention': return { tone: 'warn', label: 'attention' }
case 'past_due': return { tone: 'bad', label: 'past-due' }
case 'trial': return { tone: 'info', label: 'trial' }
case 'suspended': return { tone: 'neutral', label: 'suspended' }
}
}
function startEnter(c: CustomerOrg) {
entryCustomer.value = c
}
async function onProvisioned() {
toast.ok('Customer provisioned', 'Tenant created — provisioning runs in the background')
// Refetch so the new tenant + its subscription's MRR show up without a
// full reload. refreshNuxtData also nudges the dashboard's cached
// MRR card the next time the user navigates back to /partner.
await Promise.all([
refreshTenants(),
refreshMrr(),
refreshNuxtData('partner-tenants'),
refreshNuxtData('partner-mrr'),
])
}
function confirmEnter(reason: string) {
if (!entryCustomer.value) return
const c = entryCustomer.value
partnerMode.enter(c.id)
entryCustomer.value = null
toast.info(`Entered ${c.name}`, reason ? `Reason: ${reason}` : 'No reason captured')
router.push('/admin')
}
</script>
<template>
<div>
<PageHeader
eyebrow="Portfolio"
title="Customer organizations"
subtitle="Every customer org under your reseller agreement. Click to manage as partner."
>
<template #actions>
<UiButton variant="secondary" @click="toast.ok('Exporting CSV', `${customers.length} customers`)">
<template #leading><UiIcon name="download" :size="14" /></template>
Export CSV
</UiButton>
<UiButton variant="primary" @click="wizardOpen = true">
<template #leading><UiIcon name="plus" :size="14" /></template>
New customer
</UiButton>
</template>
</PageHeader>
<div class="content">
<!-- Filter bar -->
<div class="filters">
<div class="search">
<UiIcon name="search" :size="14" />
<input v-model="query" placeholder="Search customer or domain…" />
</div>
<div class="seg">
<span class="seg-label">Status</span>
<select v-model="statusFilter">
<option v-for="o in statusOpts" :key="o.value" :value="o.value">{{ o.label }}</option>
</select>
</div>
<div class="seg">
<span class="seg-label">Plan</span>
<select v-model="planFilter">
<option v-for="o in planOpts" :key="o.value" :value="o.value">{{ o.label }}</option>
</select>
</div>
<div class="spacer" />
<Mono dim>{{ filtered.length }} of {{ customers.length }}</Mono>
<div class="view-toggle">
<button
v-for="v in (['table','cards'] as const)"
:key="v"
:class="{ active: view === v }"
@click="view = v"
>{{ v }}</button>
</div>
</div>
<!-- Table view -->
<Card v-if="view === 'table'" :pad="0">
<div class="table-wrap">
<table class="dtable">
<thead>
<tr>
<th class="sortable">Customer <UiIcon name="chevUpDown" :size="11" /></th>
<th class="sortable">Plan <UiIcon name="chevUpDown" :size="11" /></th>
<th class="sortable">Seats <UiIcon name="chevUpDown" :size="11" /></th>
<th class="sortable num">MRR <UiIcon name="chevUpDown" :size="11" /></th>
<th class="sortable">Status <UiIcon name="chevUpDown" :size="11" /></th>
<th>Customer since</th>
<th class="action-col" />
</tr>
</thead>
<tbody>
<tr v-for="c in filtered" :key="c.id" @click="startEnter(c)">
<td>
<div class="cust-cell">
<div class="cust-swatch" :style="{ background: c.brandColor }" />
<div>
<div class="cust-name">{{ c.name }}</div>
<Mono dim>{{ c.domain }}</Mono>
</div>
</div>
</td>
<td>
<Badge :tone="c.plan === 'enterprise' ? 'invert' : 'neutral'">{{ c.planLabel }}</Badge>
</td>
<td>
<Mono>{{ c.seats.used }}/{{ c.seats.total }}</Mono>
</td>
<td class="num">
<span class="mrr">{{ c.mrrDkk > 0 ? c.mrrDkk.toLocaleString('da-DK') + ' ' + c.mrrCurrency : (c.mrrCustom ? 'custom' : '—') }}</span>
</td>
<td>
<Badge :tone="statusBadge(c.status).tone" dot>{{ statusBadge(c.status).label }}</Badge>
</td>
<td><Mono dim>{{ c.since }}</Mono></td>
<td class="action-col" @click.stop>
<UiButton size="sm" variant="secondary" @click="startEnter(c)">
Manage
<template #trailing><UiIcon name="chevRight" :size="12" /></template>
</UiButton>
</td>
</tr>
<tr v-if="filtered.length === 0">
<td colspan="7" class="empty">No customers match these filters.</td>
</tr>
</tbody>
</table>
</div>
</Card>
<!-- Cards view -->
<div v-else class="cards-grid">
<button
v-for="c in filtered"
:key="c.id"
class="ccard"
@click="startEnter(c)"
>
<div class="ccard-stripe" :style="{ background: c.brandColor }" />
<div class="ccard-body">
<div class="ccard-head">
<div class="ccard-id">
<div class="ccard-name">{{ c.name }}</div>
<Mono dim>{{ c.domain }}</Mono>
</div>
<Badge :tone="statusBadge(c.status).tone" dot>{{ statusBadge(c.status).label }}</Badge>
</div>
<div class="ccard-meta">
<div>
<Eyebrow>Plan</Eyebrow>
<div class="cm-val">{{ c.planLabel }}</div>
</div>
<div>
<Eyebrow>Seats</Eyebrow>
<div class="cm-val mono">{{ c.seats.used }} / {{ c.seats.total }}</div>
</div>
<div>
<Eyebrow>MRR</Eyebrow>
<div class="cm-val mono">{{ c.mrrDkk > 0 ? c.mrrDkk.toLocaleString('da-DK') + ' ' + c.mrrCurrency : (c.mrrCustom ? 'custom' : '—') }}</div>
</div>
<div>
<Eyebrow>Since</Eyebrow>
<div class="cm-val">{{ c.since }}</div>
</div>
</div>
</div>
</button>
</div>
</div>
<PartnerCustomerCreateWizard
:open="wizardOpen"
@close="wizardOpen = false"
@done="onProvisioned"
/>
<PartnerEnterCustomerConfirmModal
:customer="entryCustomer"
@close="entryCustomer = null"
@confirm="confirmEnter"
/>
</div>
</template>
<style scoped>
.content { padding: 16px 40px 64px; display: flex; flex-direction: column; gap: 16px; }
.filters {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.search {
display: flex;
align-items: center;
gap: 8px;
padding: 0 10px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
width: 320px;
color: var(--text-mute);
}
.search input {
flex: 1;
border: none;
background: transparent;
font-family: inherit;
font-size: 13px;
padding: 9px 0;
color: var(--text);
}
.search input:focus { outline: none; }
.seg {
display: flex;
align-items: center;
gap: 6px;
padding: 0 10px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
}
.seg-label {
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--text-mute);
}
.seg select {
border: none;
background: transparent;
font-family: inherit;
font-size: 13px;
color: var(--text);
padding: 8px 4px;
cursor: pointer;
}
.seg select:focus { outline: none; }
.spacer { flex: 1; }
.view-toggle {
display: flex;
border: 1px solid var(--border);
border-radius: 6px;
padding: 2px;
background: var(--surface);
}
.view-toggle button {
padding: 6px 12px;
border: none;
border-radius: 4px;
background: transparent;
color: var(--text-mute);
font-family: inherit;
font-size: 12px;
font-weight: 500;
cursor: pointer;
}
.view-toggle button.active { background: var(--text); color: var(--bg); }
/* Table */
.table-wrap { overflow-x: auto; }
.dtable { width: 100%; border-collapse: collapse; }
.dtable th {
text-align: left;
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--text-mute);
font-weight: 500;
padding: 12px 16px;
border-bottom: 1px solid var(--border);
}
.dtable th.sortable :deep(svg) { opacity: 0.5; margin-left: 4px; vertical-align: middle; }
.dtable th.num, .dtable td.num { text-align: right; }
.dtable th.action-col, .dtable td.action-col { width: 120px; text-align: right; }
.dtable td {
padding: 12px 16px;
border-bottom: 1px solid var(--border);
font-size: 13px;
color: var(--text);
vertical-align: middle;
}
.dtable tbody tr { cursor: pointer; transition: background 80ms; }
.dtable tbody tr:hover { background: var(--row-hover); }
.cust-cell { display: flex; align-items: center; gap: 12px; }
.cust-swatch { width: 28px; height: 28px; border-radius: 6px; flex-shrink: 0; }
.cust-name { font-size: 13px; font-weight: 500; }
.mrr {
font-family: var(--font-mono);
font-size: 12px;
font-weight: 500;
}
.empty {
text-align: center;
color: var(--text-mute);
font-size: 13px;
padding: 48px 0;
}
/* Cards */
.cards-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 12px;
}
.ccard {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
overflow: hidden;
text-align: left;
cursor: pointer;
color: inherit;
font: inherit;
padding: 0;
transition: border-color 120ms;
display: flex;
flex-direction: column;
}
.ccard:hover { border-color: var(--text); }
.ccard-stripe { height: 6px; }
.ccard-body { padding: 16px; }
.ccard-head {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 8px;
}
.ccard-id { min-width: 0; }
.ccard-name {
font-family: var(--font-display);
font-size: 16px;
font-weight: 600;
letter-spacing: -0.015em;
}
.ccard-meta {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
margin-top: 16px;
padding-top: 14px;
border-top: 1px solid var(--border);
}
.cm-val {
font-size: 13px;
font-weight: 500;
margin-top: 4px;
}
.cm-val.mono { font-family: var(--font-mono); }
</style>
+573
View File
@@ -0,0 +1,573 @@
<script setup lang="ts">
// Partner dashboard. Landing page for partner-admin role. Mirrors
// partner-screens.jsx PartnerDashboard (lines 196-365) line-for-line:
// MRR-card-plus-3-stat strip, customer-health 4-col grid, attention list,
// recent activity table.
import { customers, partnerMrrSparkline, partner as fixturePartner } from '~/data/customers'
import type { CustomerOrg } from '~/data/customers'
const toast = useToast()
const router = useRouter()
const partnerMode = usePartnerMode()
// Real partner identity from /api/me; falls back to the fixture if the user
// somehow lands here without partner data (middleware should've redirected
// them, but defending the read keeps the page from crashing).
const { partner: realPartner } = useMe()
const partner = computed(() => realPartner.value ?? fixturePartner)
const wizardOpen = ref(false)
const entryCustomer = ref<CustomerOrg | null>(null)
// Real MRR from platform-api. Subscriptions are grouped by currency so a
// partner with mixed-currency customers (e.g. some DKK, some EUR) sees a
// per-currency total rather than an FX-fudged single number.
type Currency = 'DKK' | 'EUR' | 'USD'
interface MrrResponse {
totals: Array<{ currency: Currency; monthlyMinor: number }>
breakdown: Array<{
tenantId: string
tenantName: string
currency: Currency
monthlyMinor: number
custom: boolean
}>
}
const { data: mrr } = await useFetch<MrrResponse>('/api/partner/mrr', {
key: 'partner-mrr',
default: () => ({ totals: [], breakdown: [] }),
})
const totalsDisplay = computed(() =>
(mrr.value?.totals ?? []).map((t) => ({
currency: t.currency,
majorAmount: Math.round(t.monthlyMinor / 100),
})),
)
const hasCustomPriced = computed(() => (mrr.value?.breakdown ?? []).some((b) => b.custom))
// Compact one-line summary used in the page subtitle.
const totalsLine = computed(() => {
const parts = totalsDisplay.value.map(
(t) => `${t.majorAmount.toLocaleString('da-DK')} ${t.currency}`,
)
if (parts.length === 0) return '0 DKK / mo'
return parts.join(' + ') + ' / mo'
})
// Real customer count from the breakdown.
const totalCustomers = computed(() => (mrr.value?.breakdown ?? []).length)
// Real end-user count = sum of active User docs across this partner's
// tenants. Reuses the cached /api/partner/tenants response (same key as
// the customers page) so this dashboard doesn't issue a second fetch.
interface PartnerTenant {
_id: string
slug: string
name: string
status: 'active' | 'pending' | 'suspended' | 'deleted'
plan?: 'mvp' | 'pro' | 'enterprise'
seats?: number
userCount?: number
newUserCount30d?: number // active users created in the last 30 days
createdAt?: string // tenant creation timestamp, for the customers delta
provisioningStatus?: {
authentik?: 'pending' | 'ok' | 'error' | 'skipped'
stalwart?: 'pending' | 'ok' | 'error' | 'skipped'
ocis?: 'pending' | 'ok' | 'error' | 'skipped'
}
}
const { data: tenants } = await useFetch<PartnerTenant[]>('/api/partner/tenants', {
key: 'partner-tenants',
default: () => [],
})
const totalUsers = computed(() =>
(tenants.value ?? []).reduce((s, t) => s + (t.userCount ?? 0), 0),
)
// 30-day deltas. Customers delta is derived from tenant.createdAt (already
// on the doc); end-user delta uses the aggregated newUserCount30d. Both
// render as "+N / 30d" or hide when 0 to keep the card clean on a quiet
// month.
const SINCE_30D = Date.now() - 30 * 24 * 60 * 60 * 1000
const newCustomers30d = computed(
() => (tenants.value ?? []).filter((t) => t.createdAt && new Date(t.createdAt).getTime() >= SINCE_30D).length,
)
const newUsers30d = computed(
() => (tenants.value ?? []).reduce((s, t) => s + (t.newUserCount30d ?? 0), 0),
)
const customersDelta = computed(() => (newCustomers30d.value > 0 ? `+${newCustomers30d.value} / 30d` : ''))
const usersDelta = computed(() => (newUsers30d.value > 0 ? `+${newUsers30d.value} / 30d` : ''))
// Sparkline is still fixture-driven historical MRR isn't stored yet, so
// the chart shape is decorative. Keep it for the design until we wire a
// daily MRR snapshot job.
const sparkline = partnerMrrSparkline
const sparkLast = sparkline[sparkline.length - 1]
const sparkTrendPct = '18.2' // matches source label
// Attention list · partner-screens.jsx line 207-212
const alerts = [
{ id: 'a-bygherre', tone: 'bad' as const, cust: 'Bygherre Cloud', msg: 'Invoice 21 days past due · 2.940 DKK', action: 'Review', custId: 'c-bygherre' },
{ id: 'a-henriksen', tone: 'warn' as const, cust: 'Henriksen Revision', msg: 'SPF record missing on h-revision.dk', action: 'Fix DNS', custId: 'c-henriksen' },
{ id: 'a-aalborg', tone: 'warn' as const, cust: 'Aalborg Logistik', msg: 'Approaching seat limit · 87/100 used', action: 'Upsell', custId: 'c-aalborg' },
{ id: 'a-norrebro', tone: 'info' as const, cust: 'Nørrebro Studio', msg: 'Trial ends in 7 days', action: 'Follow up', custId: 'c-norrebro' },
]
// Recent activity · partner-screens.jsx line 332-336
const activity = [
{ when: '14:02', cust: 'Acme Workspace', who: 'Anne Baslund', action: 'invited 3 users', tone: 'info' as const },
{ when: '12:18', cust: 'Bygherre Cloud', who: 'system', action: 'invoice marked past-due', tone: 'bad' as const },
{ when: '11:44', cust: 'Aalborg Logistik', who: 'Sofie Lindberg', action: 'upgraded to Enterprise', tone: 'ok' as const },
{ when: '10:08', cust: 'Nørrebro Studio', who: 'NordicMSP', action: 'created new customer org', tone: 'info' as const },
{ when: '09:34', cust: 'Henriksen Revision', who: 'system', action: 'DNS health alert · SPF', tone: 'warn' as const },
]
function statusBadge(s: string): { tone: 'ok' | 'warn' | 'bad' | 'info' | 'neutral'; label: string } {
switch (s) {
case 'healthy': return { tone: 'ok', label: 'healthy' }
case 'attention': return { tone: 'warn', label: 'attention' }
case 'past_due': return { tone: 'bad', label: 'past-due' }
case 'trial': return { tone: 'info', label: 'trial' }
default: return { tone: 'neutral', label: s }
}
}
function startEnter(c: CustomerOrg) {
entryCustomer.value = c
}
function confirmEnter(reason: string) {
if (!entryCustomer.value) return
const c = entryCustomer.value
partnerMode.enter(c.id)
entryCustomer.value = null
toast.info(`Entered ${c.name}`, reason ? `Reason: ${reason}` : 'No reason captured')
router.push('/admin')
}
function onAlert(a: typeof alerts[number]) {
toast.ok(`${a.action}: ${a.cust}`, 'Workflow stub fired')
}
function activitySwatch(name: string) {
return customers.find((c) => c.name === name)?.brandColor || 'var(--text-mute)'
}
// Real health + activity (replace fixture cards)
// Health badge derived from real tenant state. We have:
// - Tenant.status: active / pending / suspended / deleted
// - provisioningStatus per integration: ok / pending / error / skipped
// Map: any provisioning error or suspended/deleted bad. Pending or
// awaiting provisioning warn. Active + all integrations ok|skipped ok.
function tenantHealth(t: PartnerTenant): 'ok' | 'warn' | 'bad' {
if (t.status === 'suspended' || t.status === 'deleted') return 'bad'
const states = Object.values(t.provisioningStatus ?? {}) as Array<string | undefined>
if (states.some((s) => s === 'error')) return 'bad'
if (t.status === 'pending' || states.some((s) => s === 'pending')) return 'warn'
return 'ok'
}
const PLAN_LABEL: Record<'mvp' | 'pro' | 'enterprise', string> = {
mvp: 'Starter',
pro: 'Business',
enterprise: 'Enterprise',
}
const healthTiles = computed(() =>
(tenants.value ?? []).map((t) => ({
id: t._id,
slug: t.slug,
name: t.name,
planLabel: PLAN_LABEL[t.plan ?? 'pro'],
usedSeats: t.userCount ?? 0,
totalSeats: t.seats ?? 0,
tone: tenantHealth(t),
})),
)
const healthyCount = computed(() => healthTiles.value.filter((t) => t.tone === 'ok').length)
// Real audit feed. Each event has resourceName + actor.email + at +
// outcome ('success'|'failure'|'pending'). We render the verb in
// dotted form (e.g. tenant.created) since the dashboard is a glance
// view clickthrough to /partner/audit can show the full row context.
interface ActivityEvent {
_id: string
at: string
action: string
resourceName?: string
tenantSlug?: string
outcome?: 'success' | 'failure' | 'pending'
actor?: { email?: string; userId?: string }
}
const { data: activityRaw } = await useFetch<ActivityEvent[]>('/api/partner/activity', {
key: 'partner-activity',
query: { limit: 8 },
default: () => [],
})
function eventTone(e: ActivityEvent): 'ok' | 'warn' | 'bad' | 'info' {
if (e.outcome === 'failure') return 'bad'
if (e.outcome === 'pending') return 'warn'
// Heuristic: actions ending in .deleted / .suspended / .terminated read as bad
if (/\.(deleted|suspended|terminated|removed)$/.test(e.action)) return 'bad'
return 'info'
}
function fmtTime(iso: string): string {
const d = new Date(iso)
return d.toLocaleTimeString('da-DK', { hour: '2-digit', minute: '2-digit' })
}
function tenantNameFromSlug(slug?: string): string {
if (!slug) return '—'
return tenants.value?.find((t) => t.slug === slug)?.name ?? slug
}
const realActivity = computed(() =>
(activityRaw.value ?? []).map((e) => ({
id: e._id,
when: fmtTime(e.at),
cust: tenantNameFromSlug(e.tenantSlug),
who: e.actor?.email ?? 'system',
action: e.action,
tone: eventTone(e),
})),
)
function provisioned() {
toast.ok('Customer provisioned', 'Welcome email is on its way to the first admin')
}
</script>
<template>
<div>
<PageHeader
:eyebrow="`${partner.name} · Partner console`"
title="Portfolio overview"
:subtitle="`${totalCustomers} customer organizations · ${totalUsers} end users · ${totalsLine} MRR${hasCustomPriced ? ' (+ custom-priced)' : ''}`"
>
<template #actions>
<UiButton variant="secondary" @click="toast.ok('Exporting', 'Portfolio PDF · sent to your inbox')">
<template #leading><UiIcon name="download" :size="14" /></template>
Export report
</UiButton>
<UiButton variant="primary" @click="wizardOpen = true">
<template #leading><UiIcon name="plus" :size="14" /></template>
New customer
</UiButton>
</template>
</PageHeader>
<div class="content">
<!-- Top strip: MRR card (1.4fr) + 3 stat cards -->
<div class="top-strip">
<Card :pad="0" class="mrr-card">
<div class="mrr-head">
<Eyebrow>Current MRR</Eyebrow>
<div class="mrr-totals">
<template v-if="totalsDisplay.length === 0">
<div class="mrr-value">0 <span class="dkk">DKK / mo</span></div>
</template>
<template v-else>
<div v-for="t in totalsDisplay" :key="t.currency" class="mrr-line">
<span class="mrr-amount">{{ t.majorAmount.toLocaleString('da-DK') }}</span>
<span class="mrr-cur">{{ t.currency }} / mo</span>
</div>
</template>
<Mono v-if="hasCustomPriced" dim>+ custom-priced</Mono>
</div>
</div>
<div class="mrr-chart">
<!-- Sparkline values are placeholder until a daily MRR-snapshot
job exists. Multi-currency makes a single sparkline less
meaningful anyway; the chart is decorative for now. -->
<PartnerSparkline :values="sparkline" :width="420" :height="64" stroke="var(--text)" fill="var(--row-hover)" />
</div>
</Card>
<Card>
<Stat label="Customers" :value="totalCustomers" :delta="customersDelta" delta-tone="up" />
</Card>
<Card>
<Stat label="End users" :value="totalUsers" :delta="usersDelta" delta-tone="up" />
</Card>
<Card>
<Stat label="Issues" :value="alerts.length" hint="1 critical · 2 warning" />
</Card>
</div>
<!-- Health grid + Attention -->
<div class="grid-2">
<Card :pad="0">
<div class="card-head">
<div>
<Eyebrow>Health</Eyebrow>
<div class="card-title">Customer status</div>
</div>
<Mono dim>{{ healthyCount }} healthy of {{ totalCustomers }}</Mono>
</div>
<div v-if="healthTiles.length === 0" class="empty-state">
<Mono dim>// no customers yet provision your first from the New customer button</Mono>
</div>
<div v-else class="health-grid">
<NuxtLink
v-for="t in healthTiles"
:key="t.id"
:to="`/partner/customers`"
class="health-tile"
>
<div class="tile-head">
<span class="tile-name">{{ t.name }}</span>
<StatusDot :color="`var(--${t.tone})`" :size="6" :glow="false" />
</div>
<div class="tile-meta">{{ t.planLabel }} · {{ t.usedSeats }}/{{ t.totalSeats }}</div>
<Mono dim class="tile-slug">{{ t.slug }}</Mono>
</NuxtLink>
</div>
</Card>
<Card :pad="0">
<div class="card-head">
<div>
<Eyebrow>Attention</Eyebrow>
<div class="card-title">What needs your attention</div>
</div>
</div>
<div class="attn-list">
<div
v-for="a in alerts"
:key="a.id"
class="attn-row"
:style="{ borderLeftColor: `var(--${a.tone})` }"
>
<div class="attn-meta">
<div class="attn-top">
<span class="attn-cust">{{ a.cust }}</span>
<Mono dim>{{ a.tone }}</Mono>
</div>
<div class="attn-msg">{{ a.msg }}</div>
</div>
<UiButton size="sm" variant="secondary" @click="onAlert(a)">{{ a.action }}</UiButton>
</div>
</div>
</Card>
</div>
<!-- Recent activity -->
<Card :pad="0">
<div class="card-head">
<div>
<Eyebrow>Activity</Eyebrow>
<div class="card-title">Recent across portfolio</div>
</div>
<UiButton size="sm" variant="ghost" @click="router.push('/partner/audit')">
View all
<template #trailing><UiIcon name="chevRight" :size="12" /></template>
</UiButton>
</div>
<div v-if="realActivity.length === 0" class="empty-state">
<Mono dim>// no recent events yet</Mono>
</div>
<div v-else class="activity-list">
<div
v-for="(a, i) in realActivity"
:key="a.id"
class="activity-row"
:class="{ last: i === realActivity.length - 1 }"
>
<Mono>{{ a.when }}</Mono>
<div class="activity-cust">
<span class="activity-cust-name">{{ a.cust }}</span>
</div>
<div class="activity-text">
<span class="activity-who">{{ a.who }}</span> <Mono dim>{{ a.action }}</Mono>
</div>
<div class="activity-tone">
<Badge :tone="a.tone" dot>{{ a.tone }}</Badge>
</div>
</div>
</div>
</Card>
</div>
<PartnerCustomerCreateWizard
:open="wizardOpen"
@close="wizardOpen = false"
@done="provisioned"
/>
<PartnerEnterCustomerConfirmModal
:customer="entryCustomer"
@close="entryCustomer = null"
@confirm="confirmEnter"
/>
</div>
</template>
<style scoped>
.content { padding: 24px 40px 64px; display: flex; flex-direction: column; gap: 16px; }
/* Top strip: MRR card (1.4fr) + 3 stat cards */
.top-strip {
display: grid;
grid-template-columns: 1.4fr 1fr 1fr 1fr;
gap: 12px;
}
.mrr-card { overflow: hidden; display: flex; flex-direction: column; }
.mrr-head { padding: 20px 24px 12px; }
.mrr-value-row {
display: flex;
align-items: baseline;
gap: 10px;
margin-top: 8px;
}
.mrr-totals {
display: flex;
flex-direction: column;
gap: 4px;
margin-top: 8px;
}
.mrr-line {
display: flex;
align-items: baseline;
gap: 8px;
}
.mrr-amount {
font-family: var(--font-display);
font-weight: 600;
font-size: 28px;
letter-spacing: -0.025em;
line-height: 1.05;
}
.mrr-cur { font-size: 14px; color: var(--text-mute); font-weight: 500; }
.mrr-value {
font-family: var(--font-display);
font-weight: 600;
font-size: 32px;
letter-spacing: -0.025em;
line-height: 1;
}
.dkk { font-size: 18px; color: var(--text-mute); font-weight: 500; }
.trend {
font-family: var(--font-mono);
font-size: 12px;
color: var(--ok);
font-weight: 500;
}
.mrr-chart { padding: 0 12px 12px; }
.mrr-chart :deep(svg) { width: 100%; height: 64px; }
/* 2-up grid */
.grid-2 {
display: grid;
grid-template-columns: 1.4fr 1fr;
gap: 16px;
}
.card-head {
padding: 20px 24px;
border-bottom: 1px solid var(--border);
display: flex;
justify-content: space-between;
align-items: center;
}
.card-title {
font-family: var(--font-display);
font-weight: 600;
font-size: 18px;
margin-top: 4px;
}
/* Health grid: 4 columns of tiles */
.health-grid {
padding: 12px;
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 8px;
}
.health-tile {
background: var(--bg);
border: 1px solid var(--border);
border-radius: 6px;
padding: 12px;
text-align: left;
cursor: pointer;
color: var(--text);
font: inherit;
transition: border-color 120ms;
}
.health-tile:hover { border-color: var(--text); }
.health-tile { text-decoration: none; display: block; }
.tile-slug { display: block; margin-top: 6px; font-size: 10px; }
.empty-state {
padding: 32px 24px;
text-align: center;
color: var(--text-mute);
}
.tile-head { display: flex; align-items: center; gap: 8px; }
.tile-swatch { width: 16px; height: 16px; border-radius: 3px; flex-shrink: 0; }
.tile-name {
flex: 1;
font-size: 12px;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tile-meta {
font-family: var(--font-mono);
font-size: 10px;
color: var(--text-mute);
margin-top: 8px;
}
.tile-mrr {
font-family: var(--font-mono);
font-size: 11px;
font-weight: 500;
margin-top: 6px;
}
/* Attention list */
.attn-list { padding: 8px 8px 12px; display: flex; flex-direction: column; gap: 4px; }
.attn-row {
padding: 10px 14px;
border-radius: 6px;
display: flex;
align-items: center;
gap: 10px;
border-left: 2px solid var(--border);
}
.attn-meta { flex: 1; min-width: 0; }
.attn-top { display: flex; align-items: baseline; gap: 6px; flex-wrap: wrap; }
.attn-cust { font-size: 12px; font-weight: 600; }
.attn-msg { font-size: 12px; color: var(--text-dim); margin-top: 2px; }
/* Recent activity */
.activity-list { display: flex; flex-direction: column; }
.activity-row {
display: grid;
grid-template-columns: 60px 200px 1fr 100px;
align-items: center;
gap: 12px;
padding: 12px 24px;
border-bottom: 1px solid var(--border);
}
.activity-row.last { border-bottom: none; }
.activity-cust { display: flex; align-items: center; gap: 8px; }
.activity-swatch { width: 12px; height: 12px; border-radius: 2px; flex-shrink: 0; }
.activity-cust-name { font-size: 13px; font-weight: 500; }
.activity-text { font-size: 13px; color: var(--text-dim); }
.activity-who { color: var(--text); }
.activity-tone { text-align: right; }
@media (max-width: 1280px) {
.top-strip { grid-template-columns: 1fr 1fr; }
.grid-2 { grid-template-columns: 1fr; }
.health-grid { grid-template-columns: repeat(3, 1fr); }
}
</style>

Some files were not shown because too many files have changed in this diff Show More