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
+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>