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:
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 & 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 & 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 & 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>
|
||||
Reference in New Issue
Block a user