feat(portal): customer-admin surface on real data + Stripe billing + session resilience
Access & navigation
- Gate partner-mode strictly to partner staff so admins/end-users never inherit
leftover partner-view state; purge stale session entry on hydrate.
- Role-driven admin entry: useMe.isTenantAdmin, Admin/Personal tiles in the app
launcher, and an /admin route guard in the global middleware (fail closed).
- Drop the duplicate user identity block from the sidebar footer.
Admin pages on real data
- New tenant-scoped, membership-gated endpoints: GET /tenants/:slug/{audit,users,
invoices}; useTenant composable resolves the active workspace + subscription.
- Dashboard: real seats, spend (cycle-normalized + minor-units), plan, renewal,
and recent audit; unbacked sections removed.
- Users & groups: real members; Groups/Invitations/Service accounts shown as
honest "coming soon".
- Subscription & invoices: real plan hero, invoice history, and billing details.
Stripe payment method (Elements + SetupIntent)
- StripeClient: publishable key + getDefaultCard/createSetupIntent/setDefaultCard.
- CustomerBillingController + BillingService methods (ensure-customer on demand).
- Portal: PaymentMethodModal, useStripeJs (CDN load), proxies; hidePostalCode.
Editable billing details & whitelabel branding
- PATCH /tenants/:slug/billing-info (narrow: company/VAT/country/email).
- TenantBranding schema/service + GET/PUT /tenants/:slug/branding: real product
name, accent colour, and per-tenant email-template overrides.
- Branding preview + sidebar workspace mark wired to real name/plan/seats/colour
with YIQ auto-contrast (readableOn util).
Session resilience
- Request offline_access so Authentik issues a refresh token (automaticRefresh).
- Silent refresh + single retry on 401 for writes (useApiFetch, incl. partner
pages) and reads (useMe.fetchMe) — no redirect, no lost input.
- Modal backdrop closes only on press+release on the backdrop (no more
drag-select-to-close).
This commit is contained in:
@@ -9,6 +9,7 @@ import type { IconName } from './UiIcon.vue'
|
|||||||
const launcher = useAppLauncher()
|
const launcher = useAppLauncher()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const partnerMode = usePartnerMode()
|
const partnerMode = usePartnerMode()
|
||||||
|
const { isTenantAdmin } = useMe()
|
||||||
|
|
||||||
interface Tile {
|
interface Tile {
|
||||||
key: string
|
key: string
|
||||||
@@ -38,8 +39,15 @@ const tiles = computed<Tile[]>(() => {
|
|||||||
{ key: 'cal', name: 'Kalender', icon: 'calendar', ext: 'cal.dezky.com' },
|
{ key: 'cal', name: 'Kalender', icon: 'calendar', ext: 'cal.dezky.com' },
|
||||||
{ key: 'contacts', name: 'Kontakter', icon: 'users', ext: 'contacts.dezky.com' },
|
{ key: 'contacts', name: 'Kontakter', icon: 'users', ext: 'contacts.dezky.com' },
|
||||||
]
|
]
|
||||||
if (isAdmin) {
|
// Admin tile is the entry point to the workspace-admin surface. Show it to any
|
||||||
base.push({ key: 'admin', name: 'Admin', icon: 'shield', ext: 'admin.dezky.com', current: !isPartner })
|
// tenant admin/owner (so they can get TO /admin from the personal shell), not
|
||||||
|
// only when already on the admin section. Marked "HERE" when on /admin. Pair it
|
||||||
|
// with a Personal tile so the launcher is a clean two-way toggle between the
|
||||||
|
// admin and personal surfaces — clicking either crosses over, "HERE" shows
|
||||||
|
// which side you're on.
|
||||||
|
if (isAdmin || isTenantAdmin.value) {
|
||||||
|
base.push({ key: 'home', name: 'Personal', icon: 'home', ext: 'app.dezky.com', current: section.value === 'user' })
|
||||||
|
base.push({ key: 'admin', name: 'Admin', icon: 'shield', ext: 'admin.dezky.com', current: isAdmin && !isPartner })
|
||||||
}
|
}
|
||||||
if (isPartner) {
|
if (isPartner) {
|
||||||
base.push({ key: 'partner', name: 'Partner', icon: 'briefcase', ext: 'partner.nordicmsp.dk', current: true })
|
base.push({ key: 'partner', name: 'Partner', icon: 'briefcase', ext: 'partner.nordicmsp.dk', current: true })
|
||||||
@@ -51,6 +59,7 @@ const tiles = computed<Tile[]>(() => {
|
|||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
function open(t: Tile) {
|
function open(t: Tile) {
|
||||||
launcher.hide()
|
launcher.hide()
|
||||||
|
if (t.key === 'home') return navigateTo('/')
|
||||||
if (t.key === 'admin') return navigateTo('/admin')
|
if (t.key === 'admin') return navigateTo('/admin')
|
||||||
if (t.key === 'partner') return navigateTo('/partner')
|
if (t.key === 'partner') return navigateTo('/partner')
|
||||||
toast.info(`Opening ${t.name}…`, t.ext)
|
toast.info(`Opening ${t.name}…`, t.ext)
|
||||||
@@ -73,7 +82,7 @@ onMounted(() => {
|
|||||||
<header>
|
<header>
|
||||||
<div class="head-meta">
|
<div class="head-meta">
|
||||||
<Eyebrow>Apps</Eyebrow>
|
<Eyebrow>Apps</Eyebrow>
|
||||||
<div class="head-title">Open in new tab</div>
|
<div class="head-title">Jump to</div>
|
||||||
</div>
|
</div>
|
||||||
<button class="x" @click="launcher.hide" aria-label="Close">
|
<button class="x" @click="launcher.hide" aria-label="Close">
|
||||||
<UiIcon name="x" :size="16" />
|
<UiIcon name="x" :size="16" />
|
||||||
|
|||||||
@@ -16,6 +16,18 @@ const emit = defineEmits<{ close: [] }>()
|
|||||||
|
|
||||||
const maxWidth = computed(() => ({ sm: 440, md: 600, lg: 880 })[props.size || 'md'])
|
const maxWidth = computed(() => ({ sm: 440, md: 600, lg: 880 })[props.size || 'md'])
|
||||||
|
|
||||||
|
// Close only when the press AND release both land on the backdrop. Without this,
|
||||||
|
// drag-selecting text inside an input and releasing on the backdrop fires a
|
||||||
|
// `click` on the backdrop (the common ancestor) and wrongly dismisses the modal.
|
||||||
|
const pressedOnBackdrop = ref(false)
|
||||||
|
function onBackdropMousedown(e: MouseEvent) {
|
||||||
|
pressedOnBackdrop.value = e.target === e.currentTarget
|
||||||
|
}
|
||||||
|
function onBackdropClick() {
|
||||||
|
if (pressedOnBackdrop.value) emit('close')
|
||||||
|
pressedOnBackdrop.value = false
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
const onKey = (e: KeyboardEvent) => {
|
const onKey = (e: KeyboardEvent) => {
|
||||||
if (e.key === 'Escape' && props.open) emit('close')
|
if (e.key === 'Escape' && props.open) emit('close')
|
||||||
@@ -28,8 +40,8 @@ onMounted(() => {
|
|||||||
<template>
|
<template>
|
||||||
<Teleport to="body">
|
<Teleport to="body">
|
||||||
<Transition name="modal">
|
<Transition name="modal">
|
||||||
<div v-if="open" class="backdrop" @click="emit('close')">
|
<div v-if="open" class="backdrop" @mousedown="onBackdropMousedown" @click.self="onBackdropClick">
|
||||||
<div class="modal" :style="{ maxWidth: maxWidth + 'px' }" @click.stop>
|
<div class="modal" :style="{ maxWidth: maxWidth + 'px' }">
|
||||||
<header v-if="title || eyebrow || $slots.header">
|
<header v-if="title || eyebrow || $slots.header">
|
||||||
<div class="lhs">
|
<div class="lhs">
|
||||||
<Eyebrow v-if="eyebrow">{{ eyebrow }}</Eyebrow>
|
<Eyebrow v-if="eyebrow">{{ eyebrow }}</Eyebrow>
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
// Card-on-file update via Stripe Elements + a SetupIntent. The card field is a
|
||||||
|
// Stripe-hosted iframe (loaded from js.stripe.com) — raw card data never hits
|
||||||
|
// our origin. Flow: open → POST setup-intent (secret + publishable key) → mount
|
||||||
|
// Elements → confirmCardSetup client-side → POST the resulting PM as default.
|
||||||
|
//
|
||||||
|
// Stripe handles are untyped (`any`) on purpose — we don't bundle @stripe/stripe-js.
|
||||||
|
|
||||||
|
import { loadStripeJs } from '~/composables/useStripeJs'
|
||||||
|
import type { PaymentMethodCard } from '~/types/workspace'
|
||||||
|
|
||||||
|
const props = defineProps<{ open: boolean; slug: string }>()
|
||||||
|
const emit = defineEmits<{ close: []; saved: [card: PaymentMethodCard | null] }>()
|
||||||
|
|
||||||
|
const { request } = useApiFetch()
|
||||||
|
|
||||||
|
const cardMount = ref<HTMLElement | null>(null)
|
||||||
|
const status = ref<'idle' | 'loading' | 'ready' | 'submitting'>('idle')
|
||||||
|
const errorMsg = ref('')
|
||||||
|
|
||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
let stripe: any = null
|
||||||
|
let cardEl: any = null
|
||||||
|
let clientSecret = ''
|
||||||
|
|
||||||
|
async function setup() {
|
||||||
|
status.value = 'loading'
|
||||||
|
errorMsg.value = ''
|
||||||
|
try {
|
||||||
|
const res = await request<{ clientSecret: string; publishableKey: string }>(
|
||||||
|
`/api/tenants/${props.slug}/payment-method/setup-intent`,
|
||||||
|
{ method: 'POST' },
|
||||||
|
)
|
||||||
|
if (!res.publishableKey) throw new Error('Billing is not configured')
|
||||||
|
clientSecret = res.clientSecret
|
||||||
|
|
||||||
|
const StripeCtor = (await loadStripeJs()) as any
|
||||||
|
if (!StripeCtor) throw new Error('Could not load Stripe')
|
||||||
|
stripe = StripeCtor(res.publishableKey)
|
||||||
|
|
||||||
|
// Pull theme colours so the iframe text matches light/dark.
|
||||||
|
const cs = getComputedStyle(document.documentElement)
|
||||||
|
const color = cs.getPropertyValue('--text').trim() || '#0A0A0A'
|
||||||
|
const placeholder = cs.getPropertyValue('--text-mute').trim() || '#9b9b9b'
|
||||||
|
|
||||||
|
const elements = stripe.elements()
|
||||||
|
// hidePostalCode: the Card Element's bundled postal field validates against a
|
||||||
|
// US 5-digit ZIP by default, so a valid 4-digit Danish postcode reads as
|
||||||
|
// "incomplete". We don't need postal for a SetupIntent, so drop the field.
|
||||||
|
cardEl = elements.create('card', {
|
||||||
|
hidePostalCode: true,
|
||||||
|
style: { base: { fontSize: '14px', color, fontFamily: 'inherit', '::placeholder': { color: placeholder } } },
|
||||||
|
})
|
||||||
|
await nextTick()
|
||||||
|
if (cardMount.value) cardEl.mount(cardMount.value)
|
||||||
|
cardEl.on('change', (e: any) => { errorMsg.value = e.error?.message ?? '' })
|
||||||
|
status.value = 'ready'
|
||||||
|
} catch (e: any) {
|
||||||
|
errorMsg.value = e?.data?.message || e?.message || 'Could not start card update'
|
||||||
|
status.value = 'idle'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function teardown() {
|
||||||
|
try { cardEl?.destroy() } catch { /* already gone */ }
|
||||||
|
cardEl = null
|
||||||
|
stripe = null
|
||||||
|
clientSecret = ''
|
||||||
|
status.value = 'idle'
|
||||||
|
errorMsg.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => props.open, (open) => (open ? setup() : teardown()))
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
if (status.value !== 'ready' || !stripe || !clientSecret) return
|
||||||
|
status.value = 'submitting'
|
||||||
|
errorMsg.value = ''
|
||||||
|
const { error, setupIntent } = await stripe.confirmCardSetup(clientSecret, { payment_method: { card: cardEl } })
|
||||||
|
if (error) {
|
||||||
|
errorMsg.value = error.message ?? 'Card could not be saved'
|
||||||
|
status.value = 'ready'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const card = await request<PaymentMethodCard | null>(
|
||||||
|
`/api/tenants/${props.slug}/payment-method/default`,
|
||||||
|
{ method: 'POST', body: { paymentMethodId: setupIntent.payment_method } },
|
||||||
|
)
|
||||||
|
emit('saved', card)
|
||||||
|
emit('close')
|
||||||
|
} catch (e: any) {
|
||||||
|
errorMsg.value = e?.data?.message || 'Card saved, but setting it as default failed'
|
||||||
|
status.value = 'ready'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/* eslint-enable @typescript-eslint/no-explicit-any */
|
||||||
|
|
||||||
|
onBeforeUnmount(teardown)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Modal :open="open" eyebrow="Billing · payment method" title="Update card" size="md" @close="emit('close')">
|
||||||
|
<div class="pm">
|
||||||
|
<label class="field">
|
||||||
|
<Eyebrow>Card details</Eyebrow>
|
||||||
|
<div ref="cardMount" class="card-input" />
|
||||||
|
</label>
|
||||||
|
<div v-if="status === 'loading'"><Mono dim>Loading secure card form…</Mono></div>
|
||||||
|
<div v-if="errorMsg" class="err">{{ errorMsg }}</div>
|
||||||
|
<div class="trust">
|
||||||
|
<UiIcon name="shield" :size="14" stroke="var(--ok)" />
|
||||||
|
<div>Card details go straight to Stripe — Dezky never sees your full card number or CVC. PCI DSS Level 1.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<template #footer>
|
||||||
|
<UiButton variant="ghost" @click="emit('close')">Cancel</UiButton>
|
||||||
|
<UiButton variant="primary" :disabled="status !== 'ready'" @click="submit">
|
||||||
|
<template #leading><UiIcon name="check" :size="13" /></template>
|
||||||
|
{{ status === 'submitting' ? 'Saving…' : 'Save card' }}
|
||||||
|
</UiButton>
|
||||||
|
</template>
|
||||||
|
</Modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.pm { display: flex; flex-direction: column; gap: 14px; }
|
||||||
|
.field { display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
.card-input {
|
||||||
|
min-height: 20px;
|
||||||
|
padding: 12px;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
.err { font-size: 12px; color: var(--bad); }
|
||||||
|
.trust {
|
||||||
|
padding: 12px;
|
||||||
|
background: var(--bg);
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: flex-start;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -15,6 +15,7 @@
|
|||||||
|
|
||||||
import type { IconName } from './UiIcon.vue'
|
import type { IconName } from './UiIcon.vue'
|
||||||
import type { PartnerTenantDoc } from '~/types/partner'
|
import type { PartnerTenantDoc } from '~/types/partner'
|
||||||
|
import type { TenantUserDoc } from '~/types/workspace'
|
||||||
|
|
||||||
interface NavItem {
|
interface NavItem {
|
||||||
id: string
|
id: string
|
||||||
@@ -156,6 +157,32 @@ const { data: partnerTenants } = await useFetch<PartnerTenantDoc[]>('/api/partne
|
|||||||
})
|
})
|
||||||
const partnerCustomerCount = computed(() => partnerTenants.value?.length ?? 0)
|
const partnerCustomerCount = computed(() => partnerTenants.value?.length ?? 0)
|
||||||
|
|
||||||
|
// The signed-in user's own workspace (for the customer switcher tile). Real
|
||||||
|
// name, plan and accent come from /api/me.
|
||||||
|
const { tenant: ownTenant, planLabel, seatLimit } = useTenant()
|
||||||
|
const ownSlug = computed(() => ownTenant.value?.slug ?? '')
|
||||||
|
|
||||||
|
// Seat usage for the switcher sub-line. Gated to non-partner members so the
|
||||||
|
// global shell never 403s the membership-scoped endpoint; shared key keeps it
|
||||||
|
// to one request.
|
||||||
|
const { data: ownUsers } = await useFetch<TenantUserDoc[]>(
|
||||||
|
() => `/api/tenants/${ownSlug.value}/users`,
|
||||||
|
{
|
||||||
|
key: 'sidebar-ws-users',
|
||||||
|
default: () => [],
|
||||||
|
immediate: !isPartnerStaff.value && !!ownSlug.value,
|
||||||
|
watch: [ownSlug],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
const seatsUsed = computed(() => (ownUsers.value ?? []).filter((u) => u.active !== false).length)
|
||||||
|
|
||||||
|
// Workspace mark colours. Default to the signal accent when no brandColor is
|
||||||
|
// saved (matches the Branding preview); readableOn flips the initial light on
|
||||||
|
// dark accents so it stays legible for any chosen colour.
|
||||||
|
const DEFAULT_BRAND = '#D4FF3A'
|
||||||
|
const brandBg = computed(() => ownTenant.value?.brandColor || DEFAULT_BRAND)
|
||||||
|
const brandFg = computed(() => readableOn(brandBg.value))
|
||||||
|
|
||||||
// Customer currently being acted-as (partner-in-customer mode), resolved from
|
// Customer currently being acted-as (partner-in-customer mode), resolved from
|
||||||
// the real tenant list by the _id stored in partner mode.
|
// the real tenant list by the _id stored in partner mode.
|
||||||
const activeCustomer = computed(() =>
|
const activeCustomer = computed(() =>
|
||||||
@@ -167,14 +194,15 @@ const activeCustomer = computed(() =>
|
|||||||
<aside class="sidebar" :class="{ collapsed }">
|
<aside class="sidebar" :class="{ collapsed }">
|
||||||
<!-- Workspace switcher -->
|
<!-- Workspace switcher -->
|
||||||
<button class="switcher" :title="collapsed ? 'Workspace' : undefined">
|
<button class="switcher" :title="collapsed ? 'Workspace' : undefined">
|
||||||
<!-- Customer admin: bone tile with node-mark -->
|
<!-- Customer admin: brand-colour tile with the workspace initial,
|
||||||
|
matching the Branding live-preview mark. -->
|
||||||
<template v-if="switcherKind === 'customer'">
|
<template v-if="switcherKind === 'customer'">
|
||||||
<span class="ws-tile bone">
|
<span class="ws-tile brand" :style="{ background: brandBg, color: brandFg }">
|
||||||
<NodeMark :size="28" fg="#0A0A0A" accent="var(--signal)" />
|
{{ (ownTenant?.name?.[0] || 'a').toLowerCase() }}
|
||||||
</span>
|
</span>
|
||||||
<div v-if="!collapsed" class="ws-text">
|
<div v-if="!collapsed" class="ws-text">
|
||||||
<div class="ws-name">baslund</div>
|
<div class="ws-name">{{ ownTenant?.name || 'Workspace' }}</div>
|
||||||
<div class="ws-sub">Business · 11/25</div>
|
<div class="ws-sub">{{ planLabel }}{{ seatLimit ? ` · ${seatsUsed}/${seatLimit}` : '' }}</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -225,18 +253,9 @@ const activeCustomer = computed(() =>
|
|||||||
</template>
|
</template>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<!-- User footer -->
|
<!-- Footer: collapse toggle only. The user identity block lives in the
|
||||||
|
topbar avatar menu — no need to duplicate it here. -->
|
||||||
<div class="foot">
|
<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 · ⌘['">
|
<button class="collapse" @click="toggle" :title="collapsed ? 'Expand · ⌘[' : 'Collapse · ⌘['">
|
||||||
<UiIcon :name="collapsed ? 'chevRight' : 'chevLeft'" :size="11" />
|
<UiIcon :name="collapsed ? 'chevRight' : 'chevLeft'" :size="11" />
|
||||||
<span v-if="!collapsed">collapse · ⌘[</span>
|
<span v-if="!collapsed">collapse · ⌘[</span>
|
||||||
@@ -298,6 +317,13 @@ const activeCustomer = computed(() =>
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
}
|
}
|
||||||
|
/* Customer workspace mark — brand colour bg + auto-contrast initial (bg + color
|
||||||
|
set inline; the initial flips light/dark by luminance). */
|
||||||
|
.ws-tile.brand {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
.ws-text { flex: 1; min-width: 0; }
|
.ws-text { flex: 1; min-width: 0; }
|
||||||
.ws-name {
|
.ws-name {
|
||||||
@@ -397,31 +423,6 @@ nav {
|
|||||||
border-top: 1px solid var(--side-border);
|
border-top: 1px solid var(--side-border);
|
||||||
padding: 8px;
|
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 toggle */
|
||||||
.collapse {
|
.collapse {
|
||||||
|
|||||||
@@ -7,6 +7,8 @@
|
|||||||
defineProps<{ open: boolean }>()
|
defineProps<{ open: boolean }>()
|
||||||
const emit = defineEmits<{ close: []; done: [] }>()
|
const emit = defineEmits<{ close: []; done: [] }>()
|
||||||
|
|
||||||
|
const { request } = useApiFetch()
|
||||||
|
|
||||||
// 5 steps. Branding was dropped because nothing on the backend persists it
|
// 5 steps. Branding was dropped because nothing on the backend persists it
|
||||||
// yet (no Tenant.branding field, no logo upload pipeline) — partners
|
// yet (no Tenant.branding field, no logo upload pipeline) — partners
|
||||||
// configure branding post-provisioning via /partner/branding once that
|
// configure branding post-provisioning via /partner/branding once that
|
||||||
@@ -214,7 +216,7 @@ async function submit() {
|
|||||||
// client so the wizard never sends half-filled admin payloads.
|
// client so the wizard never sends half-filled admin payloads.
|
||||||
...(adminName && adminEmail && { adminName, adminEmail }),
|
...(adminName && adminEmail && { adminName, adminEmail }),
|
||||||
}
|
}
|
||||||
const res = await $fetch<{
|
const res = await request<{
|
||||||
tenant: { name: string }
|
tenant: { name: string }
|
||||||
adminInvite?: AdminCredentials | { error: string }
|
adminInvite?: AdminCredentials | { error: string }
|
||||||
}>('/api/partner/tenants', { method: 'POST', body: payload })
|
}>('/api/partner/tenants', { method: 'POST', body: payload })
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
// Pick a legible foreground (#0A0A0A or #F4F3EE) for a given background colour
|
||||||
|
// using YIQ luminance. Used wherever we paint UI with a customer's brand
|
||||||
|
// colour (sidebar workspace mark, Branding live preview) so the text/marks
|
||||||
|
// stay readable for any accent — bright or dark. Auto-imported by Nuxt.
|
||||||
|
export function readableOn(hex: string): string {
|
||||||
|
let h = hex.replace('#', '').trim()
|
||||||
|
if (h.length === 3) h = h.split('').map((c) => c + c).join('')
|
||||||
|
if (h.length !== 6) return '#0A0A0A'
|
||||||
|
const r = parseInt(h.slice(0, 2), 16)
|
||||||
|
const g = parseInt(h.slice(2, 4), 16)
|
||||||
|
const b = parseInt(h.slice(4, 6), 16)
|
||||||
|
const yiq = (r * 299 + g * 587 + b * 114) / 1000
|
||||||
|
return yiq >= 140 ? '#0A0A0A' : '#F4F3EE'
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
// Wrapper around $fetch for authenticated WRITES from the client. The OIDC
|
||||||
|
// access token in the server-side session can lapse while a tab stays open;
|
||||||
|
// the next write then hits a proxy that finds no token and 401s (a page
|
||||||
|
// refresh "fixed" it only because navigation re-ran the session refresh).
|
||||||
|
//
|
||||||
|
// On a 401 we hit nuxt-oidc-auth's refresh endpoint DIRECTLY (POST
|
||||||
|
// /api/_auth/refresh) rather than useOidcAuth().refresh() — the composable
|
||||||
|
// falls back to a full login() *redirect* when the refresh token is missing or
|
||||||
|
// expired, which would navigate away mid-save and throw out the user's input.
|
||||||
|
// Here, a failed refresh just rejects: we surface a clear error and leave the
|
||||||
|
// user on the page with their form intact, so they can re-submit after signing
|
||||||
|
// in again. Sign-in stays an explicit, user-driven action.
|
||||||
|
//
|
||||||
|
// Call this in setup(); the returned `request` can be invoked later.
|
||||||
|
|
||||||
|
interface ApiOpts {
|
||||||
|
method?: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE'
|
||||||
|
body?: unknown
|
||||||
|
query?: Record<string, unknown>
|
||||||
|
headers?: Record<string, string>
|
||||||
|
}
|
||||||
|
|
||||||
|
function isUnauthorized(err: unknown): boolean {
|
||||||
|
const e = err as { statusCode?: number; response?: { status?: number } }
|
||||||
|
return e?.statusCode === 401 || e?.response?.status === 401
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useApiFetch() {
|
||||||
|
// Silent token refresh. Resolves true if the session now has a fresh access
|
||||||
|
// token, false if it couldn't be refreshed (no/expired refresh token).
|
||||||
|
async function refreshSession(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await $fetch('/api/_auth/refresh', { method: 'POST', headers: { Accept: 'text/json' } })
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function request<T>(url: string, opts: ApiOpts = {}): Promise<T> {
|
||||||
|
const fetchOpts = opts as Parameters<typeof $fetch>[1]
|
||||||
|
try {
|
||||||
|
return (await $fetch(url, fetchOpts)) as T
|
||||||
|
} catch (err) {
|
||||||
|
if (!isUnauthorized(err)) throw err
|
||||||
|
// Token lapsed mid-session — try a silent refresh, then retry once.
|
||||||
|
if (await refreshSession()) {
|
||||||
|
return (await $fetch(url, fetchOpts)) as T
|
||||||
|
}
|
||||||
|
// Refresh failed: the session is genuinely expired. Don't redirect (that
|
||||||
|
// would discard the user's input) — fail loudly so the caller keeps the
|
||||||
|
// form open and can show "sign in again to save".
|
||||||
|
throw createError({
|
||||||
|
statusCode: 401,
|
||||||
|
statusMessage: 'Session expired',
|
||||||
|
message: 'Your session expired. Please sign in again, then save your changes.',
|
||||||
|
data: { message: 'Your session expired. Please sign in again, then save your changes.' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { request }
|
||||||
|
}
|
||||||
@@ -17,10 +17,12 @@ interface MeProfile {
|
|||||||
lastLoginAt?: string
|
lastLoginAt?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
import type { TenantDoc, SubscriptionDoc } from '~/types/workspace'
|
||||||
|
|
||||||
interface MeResponse {
|
interface MeResponse {
|
||||||
profile: MeProfile
|
profile: MeProfile
|
||||||
tenants: unknown[]
|
tenants: TenantDoc[]
|
||||||
subscriptions: unknown[]
|
subscriptions: SubscriptionDoc[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useMe() {
|
export function useMe() {
|
||||||
@@ -28,17 +30,34 @@ export function useMe() {
|
|||||||
|
|
||||||
async function fetchMe(force = false): Promise<MeResponse | null> {
|
async function fetchMe(force = false): Promise<MeResponse | null> {
|
||||||
if (state.value && !force) return state.value
|
if (state.value && !force) return state.value
|
||||||
try {
|
|
||||||
// useRequestFetch on SSR forwards the incoming request's headers
|
// useRequestFetch on SSR forwards the incoming request's headers
|
||||||
// (including the nuxt-oidc-auth session cookie) when calling the
|
// (including the nuxt-oidc-auth session cookie) when calling the
|
||||||
// Nitro route. Bare $fetch on SSR has no cookie context, so /api/me
|
// Nitro route. Bare $fetch on SSR has no cookie context, so /api/me
|
||||||
// would 401, the middleware would skip the redirect, and the end-user
|
// would 401, the middleware would skip the redirect, and the end-user
|
||||||
// page would flash before client-side rehydration finally redirects.
|
// page would flash before client-side rehydration finally redirects.
|
||||||
const fetcher = useRequestFetch()
|
const fetcher = useRequestFetch()
|
||||||
|
try {
|
||||||
state.value = await fetcher<MeResponse>('/api/me')
|
state.value = await fetcher<MeResponse>('/api/me')
|
||||||
|
} catch (err) {
|
||||||
|
// If the access token lapsed, the proxy forwards a stale token and
|
||||||
|
// platform-api 401s. On the client we can refresh the session silently
|
||||||
|
// and retry once (same self-heal as useApiFetch for writes). On the
|
||||||
|
// server we can't trigger the OIDC refresh, so fall through to null and
|
||||||
|
// let the auth middleware bounce to sign-in.
|
||||||
|
const status = (err as { statusCode?: number; response?: { status?: number } })
|
||||||
|
const is401 = status?.statusCode === 401 || status?.response?.status === 401
|
||||||
|
if (import.meta.client && is401) {
|
||||||
|
try {
|
||||||
|
await $fetch('/api/_auth/refresh', { method: 'POST', headers: { Accept: 'text/json' } })
|
||||||
|
state.value = await fetcher<MeResponse>('/api/me')
|
||||||
|
return state.value
|
||||||
} catch {
|
} catch {
|
||||||
state.value = null
|
state.value = null
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
state.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
return state.value
|
return state.value
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,6 +65,11 @@ export function useMe() {
|
|||||||
const partner = computed(() => profile.value?.partner ?? null)
|
const partner = computed(() => profile.value?.partner ?? null)
|
||||||
const isPartnerStaff = computed(() => !!profile.value?.partnerId)
|
const isPartnerStaff = computed(() => !!profile.value?.partnerId)
|
||||||
const isPlatformAdmin = computed(() => !!profile.value?.platformAdmin)
|
const isPlatformAdmin = computed(() => !!profile.value?.platformAdmin)
|
||||||
|
// Customer admin of their own workspace — gates access to the /admin surface.
|
||||||
|
// `role` is 'owner' | 'admin' | 'member' from platform-api (User.role).
|
||||||
|
const isTenantAdmin = computed(
|
||||||
|
() => profile.value?.role === 'owner' || profile.value?.role === 'admin',
|
||||||
|
)
|
||||||
|
|
||||||
return { state, profile, partner, isPartnerStaff, isPlatformAdmin, fetchMe }
|
return { state, profile, partner, isPartnerStaff, isPlatformAdmin, isTenantAdmin, fetchMe }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,13 @@
|
|||||||
const activeCustomerId = ref<string | null>(null)
|
const activeCustomerId = ref<string | null>(null)
|
||||||
|
|
||||||
export const usePartnerMode = () => {
|
export const usePartnerMode = () => {
|
||||||
|
// Partner mode is only ever meaningful for partner staff. The active-customer
|
||||||
|
// id lives in sessionStorage, which is shared across whoever signs in on this
|
||||||
|
// device — so an admin or end-user could otherwise inherit a partner's leftover
|
||||||
|
// state and see partner-view chrome. We gate every read on isPartnerStaff so
|
||||||
|
// that can never happen, regardless of what's stored.
|
||||||
|
const { isPartnerStaff } = useMe()
|
||||||
|
|
||||||
function enter(customerId: string) {
|
function enter(customerId: string) {
|
||||||
activeCustomerId.value = customerId
|
activeCustomerId.value = customerId
|
||||||
if (import.meta.client) {
|
if (import.meta.client) {
|
||||||
@@ -23,13 +30,23 @@ export const usePartnerMode = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
function hydrate() {
|
function hydrate() {
|
||||||
if (!import.meta.client || activeCustomerId.value) return
|
if (!import.meta.client) return
|
||||||
|
// Non-partner accounts must never be in partner mode. Purge any stale
|
||||||
|
// entry left by a previous partner session on this same device.
|
||||||
|
if (!isPartnerStaff.value) {
|
||||||
|
sessionStorage.removeItem('dezky-partner-active-customer')
|
||||||
|
activeCustomerId.value = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (activeCustomerId.value) return
|
||||||
const stored = sessionStorage.getItem('dezky-partner-active-customer')
|
const stored = sessionStorage.getItem('dezky-partner-active-customer')
|
||||||
if (stored) activeCustomerId.value = stored
|
if (stored) activeCustomerId.value = stored
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
activeCustomerId,
|
activeCustomerId,
|
||||||
isActive: computed(() => activeCustomerId.value !== null),
|
isActive: computed(
|
||||||
|
() => isPartnerStaff.value && activeCustomerId.value !== null,
|
||||||
|
),
|
||||||
enter,
|
enter,
|
||||||
exit,
|
exit,
|
||||||
hydrate,
|
hydrate,
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
// Loads Stripe.js from js.stripe.com on demand. Stripe requires the library be
|
||||||
|
// served from their CDN (not bundled) so card data never touches our origin —
|
||||||
|
// that's what keeps PCI scope minimal. We inject the <script> once and cache
|
||||||
|
// the promise; `window.Stripe` is the global constructor it exposes.
|
||||||
|
//
|
||||||
|
// Typed as `any`: we deliberately don't pull in @stripe/stripe-js just for its
|
||||||
|
// types. The surface we use (elements, confirmCardSetup) is small and stable.
|
||||||
|
|
||||||
|
let stripeJsPromise: Promise<unknown> | null = null
|
||||||
|
|
||||||
|
export function loadStripeJs(): Promise<unknown> {
|
||||||
|
if (!import.meta.client) return Promise.resolve(null)
|
||||||
|
const w = window as unknown as { Stripe?: unknown }
|
||||||
|
if (w.Stripe) return Promise.resolve(w.Stripe)
|
||||||
|
if (!stripeJsPromise) {
|
||||||
|
stripeJsPromise = new Promise((resolve, reject) => {
|
||||||
|
const src = 'https://js.stripe.com/v3/'
|
||||||
|
const existing = document.querySelector<HTMLScriptElement>(`script[src="${src}"]`)
|
||||||
|
if (existing) {
|
||||||
|
existing.addEventListener('load', () => resolve(w.Stripe))
|
||||||
|
existing.addEventListener('error', () => reject(new Error('Failed to load Stripe.js')))
|
||||||
|
if (w.Stripe) resolve(w.Stripe)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const s = document.createElement('script')
|
||||||
|
s.src = src
|
||||||
|
s.async = true
|
||||||
|
s.onload = () => resolve(w.Stripe)
|
||||||
|
s.onerror = () => reject(new Error('Failed to load Stripe.js'))
|
||||||
|
document.head.appendChild(s)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return stripeJsPromise
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
// Resolves the customer admin's active workspace (tenant) + its subscription
|
||||||
|
// from the cached /api/me payload, and derives the license/billing figures the
|
||||||
|
// /admin surface needs. One round-trip (shared with useMe's cache) backs the
|
||||||
|
// whole admin shell.
|
||||||
|
//
|
||||||
|
// Scope note: this resolves the signed-in user's OWN tenant (tenants[0]). The
|
||||||
|
// partner "acting-as a customer" path uses the partner-scoped endpoints +
|
||||||
|
// usePartnerMode().activeCustomer instead, so it isn't handled here.
|
||||||
|
|
||||||
|
import type { SubscriptionDoc, TenantDoc } from '~/types/workspace'
|
||||||
|
|
||||||
|
const PLAN_LABEL: Record<string, string> = {
|
||||||
|
mvp: 'Starter',
|
||||||
|
pro: 'Business',
|
||||||
|
enterprise: 'Enterprise',
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subscription amounts are stored in MINOR units (øre/cents) and per BILLING
|
||||||
|
// CYCLE, not per month. This mirrors platform-api's normalizeToMonthly (used by
|
||||||
|
// the MRR/partner-billing aggregations) so the portal shows the same figures.
|
||||||
|
function cycleToMonthlyMinor(perCycleMinor: number, cycle: string): number {
|
||||||
|
if (cycle === 'quarterly') return Math.round(perCycleMinor / 3)
|
||||||
|
if (cycle === 'yearly') return Math.round(perCycleMinor / 12)
|
||||||
|
return perCycleMinor
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTenant() {
|
||||||
|
const { state, fetchMe } = useMe()
|
||||||
|
|
||||||
|
// A user can technically belong to several tenants; the admin surface acts on
|
||||||
|
// the first. Refine to an explicit picker if multi-tenant admins land later.
|
||||||
|
const tenant = computed<TenantDoc | null>(() => state.value?.tenants?.[0] ?? null)
|
||||||
|
|
||||||
|
const subscription = computed<SubscriptionDoc | null>(() => {
|
||||||
|
const t = tenant.value
|
||||||
|
const subs = state.value?.subscriptions ?? []
|
||||||
|
if (!t) return subs[0] ?? null
|
||||||
|
return subs.find((s) => s.tenantId === t._id) ?? subs[0] ?? null
|
||||||
|
})
|
||||||
|
|
||||||
|
const planKey = computed(() => subscription.value?.plan ?? tenant.value?.plan ?? 'mvp')
|
||||||
|
const planLabel = computed(() => PLAN_LABEL[planKey.value] ?? planKey.value)
|
||||||
|
const currency = computed(() => subscription.value?.currency ?? 'DKK')
|
||||||
|
|
||||||
|
// Billed seat limit (license cap). Falls back to the tenant's seat count.
|
||||||
|
const seatLimit = computed(() => subscription.value?.seats ?? tenant.value?.seats ?? 0)
|
||||||
|
|
||||||
|
// Per-seat cost normalized to monthly, in MAJOR units (e.g. DKK). Drives the
|
||||||
|
// add-seats modal math.
|
||||||
|
const perSeatMonthly = computed(() => {
|
||||||
|
const sub = subscription.value
|
||||||
|
if (!sub?.perSeatAmount) return 0
|
||||||
|
return cycleToMonthlyMinor(sub.perSeatAmount, sub.cycle) / 100
|
||||||
|
})
|
||||||
|
|
||||||
|
// Monthly recurring spend = per-seat × billed seats, cycle-normalized to
|
||||||
|
// monthly, converted minor → major. In `currency`.
|
||||||
|
const monthlySpend = computed(() => {
|
||||||
|
const sub = subscription.value
|
||||||
|
if (!sub?.perSeatAmount || !sub.seats) return 0
|
||||||
|
return cycleToMonthlyMinor(sub.perSeatAmount * sub.seats, sub.cycle) / 100
|
||||||
|
})
|
||||||
|
|
||||||
|
const primaryDomain = computed(() => tenant.value?.domains?.[0] ?? null)
|
||||||
|
const renewsAt = computed(() =>
|
||||||
|
subscription.value?.currentPeriodEnd
|
||||||
|
? new Date(subscription.value.currentPeriodEnd)
|
||||||
|
: null,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
tenant,
|
||||||
|
subscription,
|
||||||
|
fetchMe,
|
||||||
|
planKey,
|
||||||
|
planLabel,
|
||||||
|
currency,
|
||||||
|
seatLimit,
|
||||||
|
perSeatMonthly,
|
||||||
|
monthlySpend,
|
||||||
|
primaryDomain,
|
||||||
|
renewsAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,20 +1,26 @@
|
|||||||
// Routes signed-in users to the surface that matches their role:
|
// Routes signed-in users to the surface that matches their role:
|
||||||
// - partner staff (User.partnerId set) on '/' → /partner
|
// - partner staff (User.partnerId set) on '/' → /partner
|
||||||
// - non-partner-staff hitting /partner/* → /
|
// - non-partner-staff hitting /partner/* → /
|
||||||
|
// - non-admins hitting /admin/* → /
|
||||||
//
|
//
|
||||||
// Runs after the OIDC global middleware (00.auth.global from nuxt-oidc-auth)
|
// Runs after the OIDC global middleware (00.auth.global from nuxt-oidc-auth)
|
||||||
// so we know the user is authenticated by the time we get here. /me is
|
// so we know the user is authenticated by the time we get here. /me is
|
||||||
// fetched lazily via useMe() and cached in useState — first nav after sign-in
|
// fetched lazily via useMe() and cached in useState — first nav after sign-in
|
||||||
// pays one round-trip, subsequent navs read from cache.
|
// pays one round-trip, subsequent navs read from cache.
|
||||||
//
|
//
|
||||||
// The partner surface shares the dezky-portal OAuth client with ordinary
|
// The partner + admin surfaces share the dezky-portal OAuth client with ordinary
|
||||||
// tenant users (a tenant admin authenticates here legitimately), so there is
|
// tenant users (a tenant admin authenticates here legitimately), so there is
|
||||||
// no IdP-level gate the way the operator app has — this redirect plus the
|
// no IdP-level gate the way the operator app has — these redirects plus the
|
||||||
// platform-api's per-endpoint partnerId checks are the whole defense. Because
|
// platform-api's per-endpoint role/partnerId checks are the whole defense. Because
|
||||||
// of that, /partner/* must fail CLOSED: if we can't positively confirm the
|
// of that, /partner/* and /admin/* must fail CLOSED: if we can't positively
|
||||||
// caller is partner staff (e.g. /api/me errored transiently, so `me` is null),
|
// confirm the caller's role (e.g. /api/me errored transiently, so `me` is null),
|
||||||
// we keep them out rather than letting the page shell render. Data is always
|
// we keep them out rather than letting the page shell render. Data is always
|
||||||
// backend-guarded, but the shell shouldn't show to a non-partner.
|
// backend-guarded, but the shell shouldn't show to the wrong role.
|
||||||
|
//
|
||||||
|
// /admin is reachable by tenant admins/owners AND by partner staff (who act as
|
||||||
|
// a customer admin via partner-in-customer mode). We gate on the profile (role /
|
||||||
|
// partnerId), not partner-mode session state, since that isn't resolvable on a
|
||||||
|
// fresh SSR load.
|
||||||
//
|
//
|
||||||
// Auth pages (/auth/*, /signed-out) are skipped because they're public.
|
// Auth pages (/auth/*, /signed-out) are skipped because they're public.
|
||||||
|
|
||||||
@@ -22,15 +28,16 @@ export default defineNuxtRouteMiddleware(async (to) => {
|
|||||||
if (to.path.startsWith('/auth/') || to.path === '/signed-out') return
|
if (to.path.startsWith('/auth/') || to.path === '/signed-out') return
|
||||||
|
|
||||||
const onPartnerSurface = to.path.startsWith('/partner')
|
const onPartnerSurface = to.path.startsWith('/partner')
|
||||||
|
const onAdminSurface = to.path.startsWith('/admin')
|
||||||
|
|
||||||
const { fetchMe, isPartnerStaff } = useMe()
|
const { fetchMe, isPartnerStaff, isTenantAdmin } = useMe()
|
||||||
const me = await fetchMe()
|
const me = await fetchMe()
|
||||||
|
|
||||||
// Couldn't resolve identity. For non-partner routes, defer to the OIDC
|
// Couldn't resolve identity. For non-gated routes, defer to the OIDC
|
||||||
// middleware's bounce. For partner routes, fail closed — unconfirmed is
|
// middleware's bounce. For partner/admin routes, fail closed — unconfirmed
|
||||||
// not-partner.
|
// is not-authorized.
|
||||||
if (!me) {
|
if (!me) {
|
||||||
return onPartnerSurface ? navigateTo('/') : undefined
|
return onPartnerSurface || onAdminSurface ? navigateTo('/') : undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
if (to.path === '/' && isPartnerStaff.value) {
|
if (to.path === '/' && isPartnerStaff.value) {
|
||||||
@@ -39,4 +46,7 @@ export default defineNuxtRouteMiddleware(async (to) => {
|
|||||||
if (onPartnerSurface && !isPartnerStaff.value) {
|
if (onPartnerSurface && !isPartnerStaff.value) {
|
||||||
return navigateTo('/')
|
return navigateTo('/')
|
||||||
}
|
}
|
||||||
|
if (onAdminSurface && !isTenantAdmin.value && !isPartnerStaff.value) {
|
||||||
|
return navigateTo('/')
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -83,7 +83,12 @@ export default defineNuxtConfig({
|
|||||||
// Discovery URL — used by id_token validation to fetch JWKS + issuer
|
// Discovery URL — used by id_token validation to fetch JWKS + issuer
|
||||||
openIdConfiguration:
|
openIdConfiguration:
|
||||||
'https://auth.dezky.local/application/o/dezky-portal/.well-known/openid-configuration',
|
'https://auth.dezky.local/application/o/dezky-portal/.well-known/openid-configuration',
|
||||||
scope: ['openid', 'profile', 'email', 'groups'],
|
// offline_access asks Authentik for a refresh token. Without it there's
|
||||||
|
// nothing to refresh with, so session.automaticRefresh can't run and the
|
||||||
|
// module's refresh() falls back to a full login() redirect on token
|
||||||
|
// expiry — yanking the user to the dashboard mid-action and losing their
|
||||||
|
// input. With it, the access token renews silently in the background.
|
||||||
|
scope: ['openid', 'profile', 'email', 'groups', 'offline_access'],
|
||||||
userNameClaim: 'preferred_username',
|
userNameClaim: 'preferred_username',
|
||||||
responseType: 'code',
|
responseType: 'code',
|
||||||
grantType: 'authorization_code',
|
grantType: 'authorization_code',
|
||||||
|
|||||||
+211
-237
@@ -1,76 +1,130 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
// Strict port of project/platform-screens.jsx `BillingScreen` (lines 1134-1257)
|
// Subscription & invoices. Real data: the plan hero (plan, seats, spend,
|
||||||
// with UpdatePaymentMethodModal (1262), EditBillingDetailsModal (1357) and
|
// renewal), billing details (tenant.billingInfo) and the invoice history
|
||||||
// AddSeatsModal (1415). Hero plan card on a 1.4fr/1fr split with the payment
|
// (/api/tenants/:slug/invoices) all come from platform-api.
|
||||||
// + business sub-cards on the right.
|
//
|
||||||
|
// No real source yet → shown as "coming soon": the stored payment method
|
||||||
|
// (Stripe doesn't expose it to platform-api). Plan-change, pause and add-seats
|
||||||
|
// still toast-stub their mutations — subscription writes are operator-only, so
|
||||||
|
// a customer admin can't commit them yet. Figures shown are the real numbers.
|
||||||
|
|
||||||
|
import type { InvoiceDoc, PaymentMethodCard, TenantUserDoc } from '~/types/workspace'
|
||||||
|
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
|
const { request } = useApiFetch()
|
||||||
|
|
||||||
const paymentOpen = ref(false)
|
const { fetchMe } = useMe()
|
||||||
|
await fetchMe()
|
||||||
|
const { tenant, subscription, planLabel, currency, seatLimit, perSeatMonthly, monthlySpend, renewsAt } = useTenant()
|
||||||
|
const slug = computed(() => tenant.value?.slug ?? '')
|
||||||
|
|
||||||
|
const { data: users } = await useFetch<TenantUserDoc[]>(
|
||||||
|
() => `/api/tenants/${slug.value}/users`,
|
||||||
|
{ key: 'billing-users', default: () => [], immediate: !!slug.value, watch: [slug] },
|
||||||
|
)
|
||||||
|
const { data: invoices } = await useFetch<InvoiceDoc[]>(
|
||||||
|
() => `/api/tenants/${slug.value}/invoices`,
|
||||||
|
{ key: 'billing-invoices', default: () => [], immediate: !!slug.value, watch: [slug] },
|
||||||
|
)
|
||||||
|
const { data: paymentMethod } = await useFetch<PaymentMethodCard | null>(
|
||||||
|
() => `/api/tenants/${slug.value}/payment-method`,
|
||||||
|
{ key: 'billing-pm', default: () => null, immediate: !!slug.value, watch: [slug] },
|
||||||
|
)
|
||||||
|
|
||||||
|
const pmOpen = ref(false)
|
||||||
|
function onCardSaved(card: PaymentMethodCard | null) {
|
||||||
|
paymentMethod.value = card
|
||||||
|
toast.ok('Payment method updated')
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Edit billing details ─────────────────────────────────────────────────
|
||||||
const detailsOpen = ref(false)
|
const detailsOpen = ref(false)
|
||||||
|
const savingDetails = ref(false)
|
||||||
|
const det = reactive({ companyName: '', vatId: '', country: '', contactEmail: '' })
|
||||||
|
function openDetails() {
|
||||||
|
const b = billingInfo.value
|
||||||
|
det.companyName = b.companyName ?? ''
|
||||||
|
det.vatId = b.vatId ?? ''
|
||||||
|
det.country = b.country ?? ''
|
||||||
|
det.contactEmail = b.contactEmail ?? ''
|
||||||
|
detailsOpen.value = true
|
||||||
|
}
|
||||||
|
async function saveDetails() {
|
||||||
|
savingDetails.value = true
|
||||||
|
try {
|
||||||
|
await request(`/api/tenants/${slug.value}/billing-info`, { method: 'PATCH', body: { ...det } })
|
||||||
|
await fetchMe(true) // refresh cached /me so the displayed billingInfo updates
|
||||||
|
detailsOpen.value = false
|
||||||
|
toast.ok('Billing details saved')
|
||||||
|
} catch (e: unknown) {
|
||||||
|
const msg = (e as { data?: { message?: string } })?.data?.message
|
||||||
|
toast.bad('Could not save billing details', Array.isArray(msg) ? msg.join(', ') : msg)
|
||||||
|
} finally {
|
||||||
|
savingDetails.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function pmExpiry(c: PaymentMethodCard): string {
|
||||||
|
return `${String(c.expMonth).padStart(2, '0')}/${c.expYear}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const seatsUsed = computed(() => (users.value ?? []).filter((u) => u.active !== false).length)
|
||||||
|
const billingInfo = computed(() => tenant.value?.billingInfo ?? {})
|
||||||
|
|
||||||
|
const moneyFmt = computed(
|
||||||
|
() => new Intl.NumberFormat('da-DK', { style: 'currency', currency: currency.value, maximumFractionDigits: 0 }),
|
||||||
|
)
|
||||||
|
function fmtDate(d: Date | null, opts: Intl.DateTimeFormatOptions = { day: '2-digit', month: 'short', year: 'numeric' }): string {
|
||||||
|
return d ? d.toLocaleDateString('da-DK', opts) : '—'
|
||||||
|
}
|
||||||
|
const cycleLabel = computed(() => {
|
||||||
|
const c = subscription.value?.cycle
|
||||||
|
return c === 'quarterly' ? 'quarterly' : c === 'yearly' ? 'yearly' : 'monthly'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Invoice amounts carry their own currency and are in minor units.
|
||||||
|
function fmtMinor(minor: number, cur: string): string {
|
||||||
|
return new Intl.NumberFormat('da-DK', { style: 'currency', currency: cur, maximumFractionDigits: 0 }).format(
|
||||||
|
Math.round(minor / 100),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
function invDate(inv: InvoiceDoc): string {
|
||||||
|
const iso = inv.periodStart ?? inv.createdAt
|
||||||
|
return iso ? new Date(iso).toLocaleDateString('da-DK', { day: '2-digit', month: 'short', year: 'numeric' }) : '—'
|
||||||
|
}
|
||||||
|
const invStatusTone = (s: string): 'ok' | 'warn' | 'bad' | 'neutral' =>
|
||||||
|
s === 'paid' ? 'ok' : s === 'past_due' || s === 'uncollectible' ? 'bad' : s === 'void' ? 'neutral' : 'warn'
|
||||||
|
|
||||||
|
// ── Action modals (mutations not wired — stubs) ──────────────────────────
|
||||||
const seatsOpen = ref(false)
|
const seatsOpen = ref(false)
|
||||||
const pauseOpen = ref(false)
|
const pauseOpen = ref(false)
|
||||||
const planOpen = ref(false)
|
const planOpen = ref(false)
|
||||||
|
|
||||||
// AddSeats math
|
// Add-seats math, fed by the real subscription. perSeatMonthly is already
|
||||||
const used = 11
|
// cycle-normalized + in major units.
|
||||||
const current = 25
|
|
||||||
const pricePerSeat = 78
|
|
||||||
const daysUntilRenewal = 96
|
|
||||||
const extra = ref(5)
|
const extra = ref(5)
|
||||||
const totalSeats = computed(() => current + extra.value)
|
const pricePerSeat = computed(() => perSeatMonthly.value)
|
||||||
const monthly = computed(() => extra.value * pricePerSeat)
|
const daysUntilRenewal = computed(() => {
|
||||||
const prorated = computed(() => Math.round(monthly.value * (daysUntilRenewal / 30)))
|
if (!renewsAt.value) return 30
|
||||||
|
return Math.max(0, Math.round((renewsAt.value.getTime() - Date.now()) / 86_400_000))
|
||||||
// UpdatePaymentMethod modal state
|
|
||||||
type Method = 'card' | 'invoice' | 'sepa'
|
|
||||||
const method = ref<Method>('card')
|
|
||||||
const card = reactive({ number: '', name: 'Anne Baslund', exp: '', cvc: '', country: 'DK', zip: '1620' })
|
|
||||||
|
|
||||||
// Edit billing details state
|
|
||||||
const det = reactive({
|
|
||||||
company: 'Baslund ApS',
|
|
||||||
cvr: '42 18 09 33',
|
|
||||||
contact: 'Anne Baslund',
|
|
||||||
email: 'billing@dezky.com',
|
|
||||||
addr1: 'Vesterbrogade 14',
|
|
||||||
addr2: '',
|
|
||||||
zip: '1620',
|
|
||||||
city: 'København V',
|
|
||||||
country: 'DK',
|
|
||||||
vat: 'DK 42 18 09 33',
|
|
||||||
currency: 'DKK',
|
|
||||||
})
|
})
|
||||||
|
const totalSeats = computed(() => seatLimit.value + extra.value)
|
||||||
const invoices = [
|
const monthly = computed(() => extra.value * pricePerSeat.value)
|
||||||
{ id: 'INV-2026-005', date: '01 May 2026', amount: '1.940,00 DKK', status: 'Paid' },
|
const prorated = computed(() => Math.round(monthly.value * (daysUntilRenewal.value / 30)))
|
||||||
{ id: 'INV-2026-004', date: '01 Apr 2026', amount: '1.940,00 DKK', status: 'Paid' },
|
|
||||||
{ id: 'INV-2026-003', date: '01 Mar 2026', amount: '1.560,00 DKK', status: 'Paid' },
|
|
||||||
{ id: 'INV-2026-002', date: '01 Feb 2026', amount: '1.560,00 DKK', status: 'Paid' },
|
|
||||||
{ id: 'INV-2026-001', date: '01 Jan 2026', amount: '1.560,00 DKK', status: 'Paid' },
|
|
||||||
]
|
|
||||||
|
|
||||||
const payMethods = [
|
|
||||||
{ v: 'card' as const, l: 'Card', d: 'Visa · MC · Amex' },
|
|
||||||
{ v: 'invoice' as const, l: 'Invoice (EAN)', d: 'Net 14 · DK B2B' },
|
|
||||||
{ v: 'sepa' as const, l: 'SEPA · MobilePay', d: 'Direct debit · DK' },
|
|
||||||
]
|
|
||||||
|
|
||||||
function quickSet(n: number) { extra.value = n }
|
function quickSet(n: number) { extra.value = n }
|
||||||
|
|
||||||
function exportInvoices(format: 'OIOUBL' | 'CSV') {
|
function exportInvoices(format: 'OIOUBL' | 'CSV') {
|
||||||
toast.info(`Exporting invoices as ${format}…`, format === 'OIOUBL' ? 'B2B · Nemhandel' : 'comma-separated · UTF-8')
|
toast.info(`Exporting invoices as ${format}…`, format === 'OIOUBL' ? 'B2B · Nemhandel' : 'comma-separated · UTF-8')
|
||||||
}
|
}
|
||||||
function downloadInvoice(id: string) {
|
function openInvoice(inv: InvoiceDoc) {
|
||||||
toast.info('Downloading invoice…', id)
|
if (inv.pdfUrl || inv.hostedInvoiceUrl) {
|
||||||
|
window.open(inv.pdfUrl ?? inv.hostedInvoiceUrl, '_blank', 'noopener')
|
||||||
|
} else {
|
||||||
|
toast.info('Invoice document not available yet', inv.number ?? '')
|
||||||
}
|
}
|
||||||
function viewInvoice(id: string) {
|
|
||||||
toast.info('Opening invoice', id)
|
|
||||||
}
|
}
|
||||||
function confirmPause() {
|
function confirmPause() {
|
||||||
pauseOpen.value = false
|
pauseOpen.value = false
|
||||||
toast.ok('Subscription paused', 'Resumes automatically on 28 Aug 2026')
|
toast.ok('Subscription paused', renewsAt.value ? `Resumes on ${fmtDate(renewsAt.value)}` : undefined)
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -80,14 +134,7 @@ function confirmPause() {
|
|||||||
eyebrow="Billing"
|
eyebrow="Billing"
|
||||||
title="Subscription & invoices"
|
title="Subscription & invoices"
|
||||||
subtitle="Manage your plan, payment method, and tax-compliant invoices."
|
subtitle="Manage your plan, payment method, and tax-compliant invoices."
|
||||||
>
|
/>
|
||||||
<template #actions>
|
|
||||||
<UiButton variant="secondary" @click="toast.info('Bundling invoice ZIP…')">
|
|
||||||
<template #leading><UiIcon name="download" :size="14" /></template>
|
|
||||||
Download all (.zip)
|
|
||||||
</UiButton>
|
|
||||||
</template>
|
|
||||||
</PageHeader>
|
|
||||||
|
|
||||||
<div class="content">
|
<div class="content">
|
||||||
<div class="top-row">
|
<div class="top-row">
|
||||||
@@ -97,15 +144,15 @@ function confirmPause() {
|
|||||||
<div class="hero-head">
|
<div class="hero-head">
|
||||||
<div>
|
<div>
|
||||||
<div class="kicker">// current plan</div>
|
<div class="kicker">// current plan</div>
|
||||||
<div class="hero-title">Business</div>
|
<div class="hero-title">{{ planLabel }}</div>
|
||||||
<div class="hero-sub">25 seats · invoiced monthly</div>
|
<div class="hero-sub">{{ seatLimit }} seats · invoiced {{ cycleLabel }}</div>
|
||||||
</div>
|
</div>
|
||||||
<Badge tone="accent">Renews 28 Aug 2026</Badge>
|
<Badge tone="accent">{{ renewsAt ? `Renews ${fmtDate(renewsAt)}` : (subscription?.status ?? '—') }}</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div class="hero-stats">
|
<div class="hero-stats">
|
||||||
<div><div class="hero-label">Seats used</div><div class="hero-num">11 / 25</div></div>
|
<div><div class="hero-label">Seats used</div><div class="hero-num">{{ seatsUsed }} / {{ seatLimit }}</div></div>
|
||||||
<div><div class="hero-label">This month</div><div class="hero-num">1.940 DKK</div></div>
|
<div><div class="hero-label">Per month</div><div class="hero-num">{{ moneyFmt.format(monthlySpend) }}</div></div>
|
||||||
<div><div class="hero-label">Next invoice</div><div class="hero-num">01 Jun</div></div>
|
<div><div class="hero-label">Next invoice</div><div class="hero-num">{{ fmtDate(renewsAt, { day: '2-digit', month: 'short' }) }}</div></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="hero-actions">
|
<div class="hero-actions">
|
||||||
@@ -116,36 +163,40 @@ function confirmPause() {
|
|||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<!-- Payment + business details -->
|
<!-- Payment (coming soon) + business details (real) -->
|
||||||
<Card>
|
<Card>
|
||||||
<div class="card-head">
|
<div class="card-head">
|
||||||
<div>
|
<div>
|
||||||
<Eyebrow>Payment</Eyebrow>
|
<Eyebrow>Payment</Eyebrow>
|
||||||
<div class="card-title">Payment method</div>
|
<div class="card-title">Payment method</div>
|
||||||
</div>
|
</div>
|
||||||
<UiButton size="sm" variant="ghost" @click="paymentOpen = true">Update</UiButton>
|
<UiButton size="sm" variant="ghost" @click="pmOpen = true">{{ paymentMethod ? 'Update' : 'Add card' }}</UiButton>
|
||||||
</div>
|
</div>
|
||||||
<div class="visa-row">
|
<div v-if="paymentMethod" class="pm-row">
|
||||||
<div class="visa">VISA</div>
|
<div class="pm-chip">{{ paymentMethod.brand.toUpperCase() }}</div>
|
||||||
<div class="visa-meta">
|
<div class="pm-meta">
|
||||||
<div class="visa-num">•••• •••• •••• 4242</div>
|
<div class="pm-num">•••• •••• •••• {{ paymentMethod.last4 }}</div>
|
||||||
<div class="visa-sub">Expires 11/2028 · Anne Baslund</div>
|
<div class="pm-sub">Expires {{ pmExpiry(paymentMethod) }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-else class="soon-box">
|
||||||
|
<UiIcon name="card" :size="16" stroke="var(--text-mute)" />
|
||||||
|
<div>No card on file. Add one to pay invoices automatically — handled securely by Stripe.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="card-head">
|
<div class="card-head">
|
||||||
<div>
|
<div>
|
||||||
<Eyebrow>Business</Eyebrow>
|
<Eyebrow>Business</Eyebrow>
|
||||||
<div class="card-title">Billing details</div>
|
<div class="card-title">Billing details</div>
|
||||||
</div>
|
</div>
|
||||||
<UiButton size="sm" variant="ghost" @click="detailsOpen = true">Edit</UiButton>
|
<UiButton size="sm" variant="ghost" @click="openDetails">Edit</UiButton>
|
||||||
</div>
|
</div>
|
||||||
<dl class="def">
|
<dl class="def">
|
||||||
<div><dt>Company</dt><dd>Baslund ApS</dd></div>
|
<div><dt>Company</dt><dd>{{ billingInfo.companyName || tenant?.name || '—' }}</dd></div>
|
||||||
<div><dt>CVR</dt><dd>42 18 09 33</dd></div>
|
<div><dt>VAT</dt><dd>{{ billingInfo.vatId || '—' }}</dd></div>
|
||||||
<div><dt>Address</dt><dd>Vesterbrogade 14, 1620 København V</dd></div>
|
<div><dt>Country</dt><dd>{{ billingInfo.country || '—' }}</dd></div>
|
||||||
<div><dt>VAT</dt><dd>DK 42 18 09 33</dd></div>
|
<div><dt>Invoice email</dt><dd>{{ billingInfo.contactEmail || '—' }}</dd></div>
|
||||||
<div><dt>Currency</dt><dd>DKK · EUR available</dd></div>
|
<div><dt>Currency</dt><dd>{{ currency }}</dd></div>
|
||||||
</dl>
|
</dl>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
@@ -158,8 +209,8 @@ function confirmPause() {
|
|||||||
<div class="card-title">Invoices</div>
|
<div class="card-title">Invoices</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="invoices-actions">
|
<div class="invoices-actions">
|
||||||
<UiButton size="sm" variant="secondary" @click="exportInvoices('OIOUBL')">OIOUBL (B2B)</UiButton>
|
<UiButton size="sm" variant="secondary" :disabled="invoices.length === 0" @click="exportInvoices('OIOUBL')">OIOUBL (B2B)</UiButton>
|
||||||
<UiButton size="sm" variant="secondary" @click="exportInvoices('CSV')">CSV</UiButton>
|
<UiButton size="sm" variant="secondary" :disabled="invoices.length === 0" @click="exportInvoices('CSV')">CSV</UiButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<table class="inv-table">
|
<table class="inv-table">
|
||||||
@@ -169,14 +220,18 @@ function confirmPause() {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="inv in invoices" :key="inv.id">
|
<tr v-for="inv in invoices" :key="inv._id">
|
||||||
<td><Mono>{{ inv.id }}</Mono></td>
|
<td><Mono>{{ inv.number || inv._id.slice(-8) }}</Mono></td>
|
||||||
<td>{{ inv.date }}</td>
|
<td>{{ invDate(inv) }}</td>
|
||||||
<td><span class="amount">{{ inv.amount }}</span></td>
|
<td><span class="amount">{{ fmtMinor(inv.amountDue, inv.currency) }}</span></td>
|
||||||
<td><Badge tone="ok" dot>{{ inv.status.toLowerCase() }}</Badge></td>
|
<td><Badge :tone="invStatusTone(inv.status)" dot>{{ inv.status }}</Badge></td>
|
||||||
<td class="right">
|
<td class="right">
|
||||||
<UiButton size="sm" variant="ghost" @click="downloadInvoice(inv.id)"><template #leading><UiIcon name="download" :size="13" /></template>PDF</UiButton>
|
<UiButton size="sm" variant="ghost" @click="openInvoice(inv)"><template #leading><UiIcon name="external" :size="13" /></template>View</UiButton>
|
||||||
<UiButton size="sm" variant="ghost" @click="viewInvoice(inv.id)"><template #leading><UiIcon name="external" :size="13" /></template>View</UiButton>
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="invoices.length === 0" class="no-hover">
|
||||||
|
<td colspan="5" class="empty-row">
|
||||||
|
<Mono dim>No invoices yet. They'll appear here after your first billing cycle.</Mono>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -184,103 +239,6 @@ function confirmPause() {
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Update payment method modal -->
|
|
||||||
<Modal :open="paymentOpen" eyebrow="Billing · payment method" title="Update payment method" size="md" @close="paymentOpen = false">
|
|
||||||
<div class="pay">
|
|
||||||
<div>
|
|
||||||
<Eyebrow>Pay by</Eyebrow>
|
|
||||||
<div class="pay-options">
|
|
||||||
<button v-for="o in payMethods" :key="o.v" :class="{ active: method === o.v }" @click="method = o.v">
|
|
||||||
<div class="po-label">{{ o.l }}</div>
|
|
||||||
<Mono dim>{{ o.d }}</Mono>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<template v-if="method === 'card'">
|
|
||||||
<label class="field"><Eyebrow>Card number</Eyebrow>
|
|
||||||
<div class="input-row">
|
|
||||||
<UiIcon name="card" :size="15" stroke="var(--text-mute)" />
|
|
||||||
<input v-model="card.number" placeholder="4242 4242 4242 4242" />
|
|
||||||
<Mono dim>VISA · MC · AMEX</Mono>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
<label class="field"><Eyebrow>Name on card</Eyebrow><input class="input" v-model="card.name" /></label>
|
|
||||||
<div class="grid-2">
|
|
||||||
<label class="field"><Eyebrow>Expiry</Eyebrow><input class="input" v-model="card.exp" placeholder="MM / YY" /></label>
|
|
||||||
<label class="field"><Eyebrow>CVC</Eyebrow><input class="input" v-model="card.cvc" placeholder="3 digits" /></label>
|
|
||||||
</div>
|
|
||||||
<div class="grid-14-1">
|
|
||||||
<label class="field"><Eyebrow>Country</Eyebrow><CountrySelect v-model="card.country" /></label>
|
|
||||||
<label class="field"><Eyebrow>Postal code</Eyebrow><input class="input" v-model="card.zip" /></label>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template v-else-if="method === 'invoice'">
|
|
||||||
<label class="field"><Eyebrow>EAN number</Eyebrow><input class="input" placeholder="5790000000000" /></label>
|
|
||||||
<label class="field"><Eyebrow>Purchase order reference (optional)</Eyebrow><input class="input" placeholder="Internal PO # to include on invoice" /></label>
|
|
||||||
<div class="note">
|
|
||||||
<Mono dim>// OIOUBL · Nemhandel</Mono>
|
|
||||||
<div class="note-body">Invoices are delivered to your EAN via the Nemhandel network. Payment terms are <b>net 14 days</b> from invoice date. The first invoice arrives on the next billing cycle.</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template v-else>
|
|
||||||
<label class="field"><Eyebrow>IBAN</Eyebrow><input class="input" placeholder="DK00 0000 0000 0000 00" /></label>
|
|
||||||
<label class="field"><Eyebrow>Account holder name</Eyebrow><input class="input" value="Baslund ApS" /></label>
|
|
||||||
<label class="check"><input type="checkbox" checked /> I authorise Dezky to debit this account by SEPA Direct Debit</label>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<div class="trust">
|
|
||||||
<UiIcon name="shield" :size="14" stroke="var(--ok)" />
|
|
||||||
<div>Payment details are tokenised by our processor. Dezky never sees raw card numbers, IBANs, or CVC codes. PCI DSS Level 1.</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<template #footer>
|
|
||||||
<UiButton variant="ghost" @click="paymentOpen = false">Cancel</UiButton>
|
|
||||||
<UiButton variant="primary" @click="paymentOpen = false; toast.ok('Payment method saved')">
|
|
||||||
<template #leading><UiIcon name="check" :size="13" /></template>
|
|
||||||
Save payment method
|
|
||||||
</UiButton>
|
|
||||||
</template>
|
|
||||||
</Modal>
|
|
||||||
|
|
||||||
<!-- Edit billing details modal -->
|
|
||||||
<Modal :open="detailsOpen" eyebrow="Billing · business details" title="Edit billing details" size="lg" @close="detailsOpen = false">
|
|
||||||
<div class="details">
|
|
||||||
<div class="grid-co">
|
|
||||||
<label class="field"><Eyebrow>Company name</Eyebrow><input class="input" v-model="det.company" /></label>
|
|
||||||
<label class="field"><Eyebrow>CVR / org. number</Eyebrow><input class="input" v-model="det.cvr" /></label>
|
|
||||||
</div>
|
|
||||||
<div class="grid-2">
|
|
||||||
<label class="field"><Eyebrow>Billing contact</Eyebrow><input class="input" v-model="det.contact" /></label>
|
|
||||||
<label class="field"><Eyebrow>Invoice email</Eyebrow><input class="input" v-model="det.email" /></label>
|
|
||||||
</div>
|
|
||||||
<label class="field"><Eyebrow>Address line 1</Eyebrow><input class="input" v-model="det.addr1" /></label>
|
|
||||||
<label class="field"><Eyebrow>Address line 2 (optional)</Eyebrow><input class="input" v-model="det.addr2" placeholder="Floor, suite, c/o…" /></label>
|
|
||||||
<div class="grid-zip">
|
|
||||||
<label class="field"><Eyebrow>Postal code</Eyebrow><input class="input" v-model="det.zip" /></label>
|
|
||||||
<label class="field"><Eyebrow>City</Eyebrow><input class="input" v-model="det.city" /></label>
|
|
||||||
<label class="field"><Eyebrow>Country</Eyebrow><CountrySelect v-model="det.country" /></label>
|
|
||||||
</div>
|
|
||||||
<div class="grid-co">
|
|
||||||
<label class="field"><Eyebrow>VAT number</Eyebrow><input class="input" v-model="det.vat" /></label>
|
|
||||||
<label class="field"><Eyebrow>Currency</Eyebrow><input class="input" v-model="det.currency" /></label>
|
|
||||||
</div>
|
|
||||||
<div class="note">
|
|
||||||
<Mono dim>// VAT</Mono>
|
|
||||||
<div class="note-body">For Danish customers, the CVR + VAT must match. Reverse-charge applies for EU B2B customers outside Denmark (we won't charge VAT, you self-account).</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<template #footer>
|
|
||||||
<UiButton variant="ghost" @click="detailsOpen = false">Cancel</UiButton>
|
|
||||||
<UiButton variant="primary" @click="detailsOpen = false; toast.ok('Billing details saved')">
|
|
||||||
<template #leading><UiIcon name="check" :size="13" /></template>
|
|
||||||
Save details
|
|
||||||
</UiButton>
|
|
||||||
</template>
|
|
||||||
</Modal>
|
|
||||||
|
|
||||||
<!-- Pause subscription confirmation -->
|
<!-- Pause subscription confirmation -->
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
:open="pauseOpen"
|
:open="pauseOpen"
|
||||||
@@ -291,7 +249,7 @@ function confirmPause() {
|
|||||||
@close="pauseOpen = false"
|
@close="pauseOpen = false"
|
||||||
@confirm="confirmPause"
|
@confirm="confirmPause"
|
||||||
>
|
>
|
||||||
Members keep access until the end of the current billing cycle (28 Aug 2026), after
|
Members keep access until the end of the current billing cycle{{ renewsAt ? ` (${fmtDate(renewsAt)})` : '' }}, after
|
||||||
which sign-ins are blocked and data is held in cold storage. You can resume any time
|
which sign-ins are blocked and data is held in cold storage. You can resume any time
|
||||||
to restore full access.
|
to restore full access.
|
||||||
</ConfirmDialog>
|
</ConfirmDialog>
|
||||||
@@ -302,12 +260,11 @@ function confirmPause() {
|
|||||||
<div class="lead">Pick a new tier. We'll prorate the difference and apply it on your next invoice.</div>
|
<div class="lead">Pick a new tier. We'll prorate the difference and apply it on your next invoice.</div>
|
||||||
<div class="plan-options">
|
<div class="plan-options">
|
||||||
<button v-for="p in [
|
<button v-for="p in [
|
||||||
{ id: 'basic', name: 'Basic', price: '49 DKK / seat / mo', d: 'Mail · Drev · 50 GB', current: false },
|
{ id: 'mvp', name: 'Starter', d: 'Mail · Drev · 50 GB', current: planLabel === 'Starter' },
|
||||||
{ id: 'business', name: 'Business · current', price: '78 DKK / seat / mo', d: 'Everything in Basic + Møder + Chat · 200 GB', current: true },
|
{ id: 'pro', name: 'Business', d: 'Everything in Starter + Møder + Chat · 200 GB', current: planLabel === 'Business' },
|
||||||
{ id: 'enterprise', name: 'Enterprise', price: 'from 140 DKK / seat / mo', d: 'SSO contracts · audit log retention · 1 TB', current: false },
|
{ id: 'enterprise', name: 'Enterprise', d: 'SSO contracts · audit log retention · 1 TB', current: planLabel === 'Enterprise' },
|
||||||
]" :key="p.id" :class="['plan-card', { active: p.current }]">
|
]" :key="p.id" :class="['plan-card', { active: p.current }]">
|
||||||
<div class="plan-name">{{ p.name }}</div>
|
<div class="plan-name">{{ p.name }}{{ p.current ? ' · current' : '' }}</div>
|
||||||
<Mono dim>{{ p.price }}</Mono>
|
|
||||||
<div class="plan-d">{{ p.d }}</div>
|
<div class="plan-d">{{ p.d }}</div>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -325,8 +282,8 @@ function confirmPause() {
|
|||||||
<Modal :open="seatsOpen" eyebrow="Billing · seats" title="Add seats" size="md" @close="seatsOpen = false">
|
<Modal :open="seatsOpen" eyebrow="Billing · seats" title="Add seats" size="md" @close="seatsOpen = false">
|
||||||
<div class="seats">
|
<div class="seats">
|
||||||
<div class="seats-3col">
|
<div class="seats-3col">
|
||||||
<div><Eyebrow>Active users</Eyebrow><div class="big">{{ used }}</div></div>
|
<div><Eyebrow>Active users</Eyebrow><div class="big">{{ seatsUsed }}</div></div>
|
||||||
<div><Eyebrow>Current seats</Eyebrow><div class="big">{{ current }}</div></div>
|
<div><Eyebrow>Current seats</Eyebrow><div class="big">{{ seatLimit }}</div></div>
|
||||||
<div><Eyebrow>After change</Eyebrow><div class="big ok">{{ totalSeats }}</div></div>
|
<div><Eyebrow>After change</Eyebrow><div class="big ok">{{ totalSeats }}</div></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -344,25 +301,49 @@ function confirmPause() {
|
|||||||
|
|
||||||
<div class="bill-box">
|
<div class="bill-box">
|
||||||
<Eyebrow>What you'll pay</Eyebrow>
|
<Eyebrow>What you'll pay</Eyebrow>
|
||||||
<div class="bb-row"><span>{{ extra }} new seat{{ extra === 1 ? '' : 's' }} × {{ pricePerSeat }} DKK / month</span><Mono>{{ monthly.toLocaleString('da-DK') }} DKK / mo</Mono></div>
|
<div class="bb-row"><span>{{ extra }} new seat{{ extra === 1 ? '' : 's' }} × {{ moneyFmt.format(pricePerSeat) }} / month</span><Mono>{{ moneyFmt.format(monthly) }} / mo</Mono></div>
|
||||||
<div class="bb-row sep"><span class="dim">Prorated for current cycle ({{ daysUntilRenewal }} days until renewal)</span><Mono dim>{{ prorated.toLocaleString('da-DK') }} DKK</Mono></div>
|
<div class="bb-row sep"><span class="dim">Prorated for current cycle ({{ daysUntilRenewal }} days until renewal)</span><Mono dim>{{ moneyFmt.format(prorated) }}</Mono></div>
|
||||||
<div class="bb-row total"><span>Charged today</span><span class="hero-amount">{{ prorated.toLocaleString('da-DK') }} DKK</span></div>
|
<div class="bb-row total"><span>Charged today</span><span class="hero-amount">{{ moneyFmt.format(prorated) }}</span></div>
|
||||||
<div class="bb-row"><span class="dim">Next invoice on 01 Jun 2026</span><Mono dim>{{ (1940 + monthly).toLocaleString('da-DK') }} DKK</Mono></div>
|
<div class="bb-row"><span class="dim"><template v-if="renewsAt">Next invoice on {{ fmtDate(renewsAt) }}</template><template v-else>Next invoice</template></span><Mono dim>{{ moneyFmt.format(monthlySpend + monthly) }}</Mono></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="trust">
|
<div class="trust">
|
||||||
<UiIcon name="card" :size="14" stroke="var(--text-mute)" />
|
<UiIcon name="card" :size="14" stroke="var(--text-mute)" />
|
||||||
<div>Charged to <Mono>Visa •••• 4242</Mono>. Seats are added instantly — invitations can be sent right away.</div>
|
<div>Seats are added instantly — invitations can be sent right away.</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<UiButton variant="ghost" @click="seatsOpen = false">Cancel</UiButton>
|
<UiButton variant="ghost" @click="seatsOpen = false">Cancel</UiButton>
|
||||||
<UiButton variant="primary" @click="seatsOpen = false; toast.ok(`${extra} seats added · charged ${prorated.toLocaleString('da-DK')} DKK`)">
|
<UiButton variant="primary" @click="seatsOpen = false; toast.ok(`${extra} seats added · charged ${moneyFmt.format(prorated)}`)">
|
||||||
<template #leading><UiIcon name="plus" :size="13" /></template>
|
<template #leading><UiIcon name="plus" :size="13" /></template>
|
||||||
Add {{ extra }} seat{{ extra === 1 ? '' : 's' }}
|
Add {{ extra }} seat{{ extra === 1 ? '' : 's' }}
|
||||||
</UiButton>
|
</UiButton>
|
||||||
</template>
|
</template>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
<!-- Update payment method (Stripe Elements) -->
|
||||||
|
<PaymentMethodModal :open="pmOpen" :slug="slug" @close="pmOpen = false" @saved="onCardSaved" />
|
||||||
|
|
||||||
|
<!-- Edit billing details -->
|
||||||
|
<Modal :open="detailsOpen" eyebrow="Billing · business details" title="Edit billing details" size="md" @close="detailsOpen = false">
|
||||||
|
<div class="form-stack">
|
||||||
|
<label class="field"><Eyebrow>Company name</Eyebrow><input class="input" v-model="det.companyName" placeholder="Baslund ApS" /></label>
|
||||||
|
<label class="field"><Eyebrow>VAT / CVR number</Eyebrow><input class="input" v-model="det.vatId" placeholder="DK12345678" /></label>
|
||||||
|
<label class="field"><Eyebrow>Country</Eyebrow><CountrySelect v-model="det.country" placeholder="Select country" /></label>
|
||||||
|
<label class="field"><Eyebrow>Invoice email</Eyebrow><input class="input" v-model="det.contactEmail" placeholder="billing@company.com" /></label>
|
||||||
|
<div class="note">
|
||||||
|
<Mono dim>// VAT</Mono>
|
||||||
|
<div class="note-body">For Danish customers the CVR + VAT must match. EU B2B customers outside Denmark are reverse-charged (we won't add VAT; you self-account).</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<template #footer>
|
||||||
|
<UiButton variant="ghost" @click="detailsOpen = false">Cancel</UiButton>
|
||||||
|
<UiButton variant="primary" :disabled="savingDetails" @click="saveDetails">
|
||||||
|
<template #leading><UiIcon name="check" :size="13" /></template>
|
||||||
|
{{ savingDetails ? 'Saving…' : 'Save details' }}
|
||||||
|
</UiButton>
|
||||||
|
</template>
|
||||||
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -404,7 +385,21 @@ function confirmPause() {
|
|||||||
letter-spacing: -0.01em;
|
letter-spacing: -0.01em;
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
}
|
}
|
||||||
.visa-row {
|
.soon-box {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding: 14px;
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px dashed var(--border-hi, var(--border));
|
||||||
|
border-radius: 6px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pm-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
@@ -413,9 +408,10 @@ function confirmPause() {
|
|||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
.visa {
|
.pm-chip {
|
||||||
width: 40px;
|
|
||||||
height: 28px;
|
height: 28px;
|
||||||
|
min-width: 44px;
|
||||||
|
padding: 0 8px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
background: var(--text);
|
background: var(--text);
|
||||||
color: var(--bg);
|
color: var(--bg);
|
||||||
@@ -427,9 +423,9 @@ function confirmPause() {
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.05em;
|
||||||
}
|
}
|
||||||
.visa-meta { flex: 1; }
|
.pm-meta { flex: 1; }
|
||||||
.visa-num { font-family: var(--font-mono); font-size: 13px; }
|
.pm-num { font-family: var(--font-mono); font-size: 13px; }
|
||||||
.visa-sub { font-size: 11px; color: var(--text-mute); margin-top: 2px; }
|
.pm-sub { font-size: 11px; color: var(--text-mute); margin-top: 2px; }
|
||||||
|
|
||||||
.def { margin: 0; display: grid; grid-template-columns: 140px 1fr; row-gap: 12px; column-gap: 16px; }
|
.def { margin: 0; display: grid; grid-template-columns: 140px 1fr; row-gap: 12px; column-gap: 16px; }
|
||||||
.def > div { display: contents; }
|
.def > div { display: contents; }
|
||||||
@@ -460,39 +456,9 @@ function confirmPause() {
|
|||||||
.inv-table td { padding: 12px 16px; border-bottom: 1px solid var(--border); font-size: 13px; }
|
.inv-table td { padding: 12px 16px; border-bottom: 1px solid var(--border); font-size: 13px; }
|
||||||
.inv-table tr:last-child td { border-bottom: none; }
|
.inv-table tr:last-child td { border-bottom: none; }
|
||||||
.inv-table .right { text-align: right; display: flex; gap: 6px; justify-content: flex-end; }
|
.inv-table .right { text-align: right; display: flex; gap: 6px; justify-content: flex-end; }
|
||||||
|
.inv-table tr.no-hover td { cursor: default; }
|
||||||
.amount { font-family: var(--font-mono); font-size: 13px; font-weight: 500; }
|
.amount { font-family: var(--font-mono); font-size: 13px; font-weight: 500; }
|
||||||
|
.empty-row { text-align: center; padding: 40px 16px; }
|
||||||
/* Modal forms */
|
|
||||||
.field { display: flex; flex-direction: column; gap: 6px; }
|
|
||||||
.input { height: 36px; padding: 0 12px; background: var(--surface); border: 1px solid var(--border); border-radius: 6px; font-family: inherit; font-size: 13px; color: var(--text); outline: none; }
|
|
||||||
.input:focus { border-color: var(--text); }
|
|
||||||
.input-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 0 12px;
|
|
||||||
height: 36px;
|
|
||||||
background: var(--surface);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 6px;
|
|
||||||
}
|
|
||||||
.input-row input { flex: 1; border: none; outline: none; background: transparent; font-family: var(--font-mono); font-size: 13px; color: var(--text); letter-spacing: 0.05em; }
|
|
||||||
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
|
||||||
.grid-14-1 { display: grid; grid-template-columns: 1.4fr 1fr; gap: 12px; }
|
|
||||||
.grid-co { display: grid; grid-template-columns: 1fr 200px; gap: 12px; }
|
|
||||||
.grid-zip { display: grid; grid-template-columns: 120px 1fr 1fr; gap: 12px; }
|
|
||||||
|
|
||||||
.pay { display: flex; flex-direction: column; gap: 16px; }
|
|
||||||
.pay-options { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin-top: 8px; }
|
|
||||||
.pay-options button { padding: 12px; border-radius: 6px; text-align: left; font-family: inherit; cursor: pointer; border: 1px solid var(--border); background: var(--surface); }
|
|
||||||
.pay-options button.active { border-color: var(--text); background: var(--bg); }
|
|
||||||
.po-label { font-size: 13px; font-weight: 500; }
|
|
||||||
|
|
||||||
.details { display: flex; flex-direction: column; gap: 14px; }
|
|
||||||
.note { padding: 12px; background: var(--bg); border-radius: 6px; border: 1px solid var(--border); font-size: 12px; color: var(--text-dim); line-height: 1.55; }
|
|
||||||
.note-body { margin-top: 6px; }
|
|
||||||
|
|
||||||
.check { display: flex; align-items: center; gap: 8px; font-size: 13px; }
|
|
||||||
|
|
||||||
.trust {
|
.trust {
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
@@ -566,4 +532,12 @@ function confirmPause() {
|
|||||||
.plan-card.active { border-color: var(--text); background: var(--bg); }
|
.plan-card.active { border-color: var(--text); background: var(--bg); }
|
||||||
.plan-name { font-size: 14px; font-weight: 500; }
|
.plan-name { font-size: 14px; font-weight: 500; }
|
||||||
.plan-d { font-size: 12px; color: var(--text-mute); margin-top: 6px; }
|
.plan-d { font-size: 12px; color: var(--text-mute); margin-top: 6px; }
|
||||||
|
|
||||||
|
/* Edit billing details modal */
|
||||||
|
.form-stack { display: flex; flex-direction: column; gap: 14px; }
|
||||||
|
.field { display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
.input { height: 36px; padding: 0 12px; background: var(--surface); border: 1px solid var(--border); border-radius: 6px; font-family: inherit; font-size: 13px; color: var(--text); outline: none; }
|
||||||
|
.input:focus { border-color: var(--text); }
|
||||||
|
.note { padding: 12px; background: var(--bg); border-radius: 6px; border: 1px solid var(--border); font-size: 12px; color: var(--text-dim); line-height: 1.55; }
|
||||||
|
.note-body { margin-top: 6px; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,49 +1,63 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
// Strict port of project/platform-screens.jsx `BrandingScreen` (lines 1542-1668)
|
// Customer whitelabel branding. Real data: product name + accent colour (on the
|
||||||
// with BrandingPreview (1669), UploadAssetModal (1733), EditEmailTemplatePanel
|
// Tenant doc) and email-template overrides (TenantBranding), saved together via
|
||||||
// (1903), PublishBrandingModal (2031) and ResetBrandingModal (2148). Two-column
|
// PUT /api/tenants/:slug/branding. No backend yet for logo upload or custom-
|
||||||
// layout — controls on the left (420px), live preview on the right.
|
// domain verification, so those show honest "coming soon" states.
|
||||||
|
//
|
||||||
|
// Edit model: name/colour + the open template editor mutate LOCAL state; the
|
||||||
|
// header "Save changes" persists everything in one PUT. The per-template
|
||||||
|
// "Apply" just commits that template into local overrides.
|
||||||
|
|
||||||
|
import type { TenantBrandingView } from '~/types/workspace'
|
||||||
|
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
|
const { request } = useApiFetch()
|
||||||
|
const { fetchMe } = useMe()
|
||||||
|
await fetchMe()
|
||||||
|
const { tenant } = useTenant()
|
||||||
|
const slug = computed(() => tenant.value?.slug ?? '')
|
||||||
|
|
||||||
const color = ref('#D4FF3A')
|
const DEFAULT_COLOR = '#D4FF3A'
|
||||||
const name = ref('Acme Workspace')
|
const colorPalette = ['#D4FF3A', '#3F6BFF', '#FF6B4A', '#5B8C5A', '#9B59B6']
|
||||||
|
|
||||||
const uploadAsset = ref<typeof ASSETS[number] | null>(null)
|
const { data: branding } = await useFetch<TenantBrandingView>(
|
||||||
const uploaded = ref(false)
|
() => `/api/tenants/${slug.value}/branding`,
|
||||||
const dragOver = ref(false)
|
{ key: 'admin-branding', default: () => ({ name: '', emailTemplates: [] }), immediate: !!slug.value, watch: [slug] },
|
||||||
|
)
|
||||||
|
|
||||||
const editTemplate = ref<typeof TEMPLATES[number] | null>(null)
|
// Local editable state, seeded from the fetched branding.
|
||||||
const subject = ref('')
|
const name = ref('')
|
||||||
const body = ref('')
|
const color = ref(DEFAULT_COLOR)
|
||||||
const testSent = ref(false)
|
const overrides = reactive<Record<string, { subject: string; body: string }>>({})
|
||||||
function sendTest() {
|
function seed() {
|
||||||
testSent.value = true
|
const b = branding.value
|
||||||
setTimeout(() => (testSent.value = false), 2500)
|
name.value = b?.name ?? ''
|
||||||
|
color.value = b?.brandColor || DEFAULT_COLOR
|
||||||
|
for (const k of Object.keys(overrides)) delete overrides[k]
|
||||||
|
for (const t of b?.emailTemplates ?? []) overrides[t.key] = { subject: t.subject, body: t.body }
|
||||||
}
|
}
|
||||||
|
seed()
|
||||||
|
watch(branding, seed)
|
||||||
|
|
||||||
const publishOpen = ref(false)
|
const primaryDomain = computed(() => branding.value?.primaryDomain ?? '')
|
||||||
const publishState = ref<'confirm' | 'publishing' | 'done'>('confirm')
|
|
||||||
const resetOpen = ref(false)
|
|
||||||
|
|
||||||
const ASSETS = [
|
// Auto-contrast foreground for surfaces painted in the accent colour, so the
|
||||||
{ id: 'full', l: 'Full logo', d: 'horizontal · 4:1 · png/svg', ratio: '4:1', formats: 'png · svg', maxKb: 400, current: false, currentName: '', currentSize: '' },
|
// preview stays legible whether the accent is bright or dark.
|
||||||
{ id: 'mark', l: 'Square mark', d: '1:1 · transparent · png/svg', ratio: '1:1', formats: 'png · svg', maxKb: 200, current: true, currentName: 'acme-mark.svg', currentSize: '12 KB' },
|
const accentFg = computed(() => readableOn(color.value))
|
||||||
{ id: 'favicon', l: 'Favicon', d: '32×32 · ico/png', ratio: '1:1', formats: 'ico · png', maxKb: 50, current: true, currentName: 'favicon.ico', currentSize: '4 KB' },
|
const accentBtnText = computed(() => readableOn(accentFg.value))
|
||||||
] as const
|
|
||||||
|
|
||||||
|
// Canonical template defaults. Overrides (saved per tenant) win over these.
|
||||||
const TEMPLATES = [
|
const TEMPLATES = [
|
||||||
{ id: 'invitation', name: 'User invitation', subject: 'You’ve been invited to {{workspace.name}}', desc: 'sent when an admin invites a new user', edited: '3 days ago' },
|
{ id: 'invitation', name: 'User invitation', subject: 'You’ve been invited to {{workspace.name}}', desc: 'sent when an admin invites a new user' },
|
||||||
{ id: 'reset', name: 'Password reset', subject: 'Reset your {{workspace.name}} password', desc: 'sent on forgot-password requests', edited: 'default' },
|
{ id: 'reset', name: 'Password reset', subject: 'Reset your {{workspace.name}} password', desc: 'sent on forgot-password requests' },
|
||||||
{ id: 'digest', name: 'Notification digest', subject: 'Your weekly summary from {{workspace.name}}', desc: 'sent weekly to users opted-in for digests', edited: '2 weeks ago' },
|
{ id: 'digest', name: 'Notification digest', subject: 'Your weekly summary from {{workspace.name}}', desc: 'sent weekly to users opted-in for digests' },
|
||||||
{ id: 'trial', name: 'Trial expiring', subject: 'Your trial ends in {{trial.days_left}} days', desc: 'sent 7 / 3 / 1 days before trial expiry', edited: 'default' },
|
{ id: 'trial', name: 'Trial expiring', subject: 'Your trial ends in {{trial.days_left}} days', desc: 'sent 7 / 3 / 1 days before trial expiry' },
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
const TEMPLATE_BODIES: Record<string, string> = {
|
const TEMPLATE_BODIES: Record<string, string> = {
|
||||||
invitation: `Hi {{user.first_name}},
|
invitation: `Hi {{user.first_name}},
|
||||||
|
|
||||||
{{inviter.name}} has invited you to join {{workspace.name}} on dezky.
|
{{inviter.name}} has invited you to join {{workspace.name}}.
|
||||||
|
|
||||||
Click below to set up your account — the link expires in 7 days.
|
Click below to set up your account — the link expires in 7 days.
|
||||||
|
|
||||||
@@ -92,42 +106,51 @@ const TEMPLATE_MERGE_TAGS: Record<string, string[]> = {
|
|||||||
trial: ['user.first_name', 'workspace.name', 'trial.days_left', 'stats.users', 'stats.gb', 'billing.url'],
|
trial: ['user.first_name', 'workspace.name', 'trial.days_left', 'stats.users', 'stats.gb', 'billing.url'],
|
||||||
}
|
}
|
||||||
|
|
||||||
const colorPalette = ['#D4FF3A', '#3F6BFF', '#FF6B4A', '#5B8C5A', '#9B59B6']
|
function isEdited(id: string): boolean {
|
||||||
|
return !!overrides[id]
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Template editor (side panel) ─────────────────────────────────────────
|
||||||
|
const editTemplate = ref<typeof TEMPLATES[number] | null>(null)
|
||||||
|
const subject = ref('')
|
||||||
|
const body = ref('')
|
||||||
|
const testSent = ref(false)
|
||||||
|
|
||||||
function openTemplate(t: typeof TEMPLATES[number]) {
|
function openTemplate(t: typeof TEMPLATES[number]) {
|
||||||
editTemplate.value = t
|
editTemplate.value = t
|
||||||
subject.value = t.subject
|
subject.value = overrides[t.id]?.subject ?? t.subject
|
||||||
body.value = TEMPLATE_BODIES[t.id] || ''
|
body.value = overrides[t.id]?.body ?? (TEMPLATE_BODIES[t.id] || '')
|
||||||
testSent.value = false
|
testSent.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
function insertTag(tag: string) {
|
// Wrap a merge-tag name in mustaches via JS so the template never nests
|
||||||
body.value += `{{${tag}}}`
|
// `{{ ... }}` inside `{{ ... }}` (Vue's parser scans positionally and breaks).
|
||||||
}
|
|
||||||
|
|
||||||
// Reset the currently-open template's subject + body to the canonical default.
|
|
||||||
function resetTemplate() {
|
|
||||||
if (!editTemplate.value) return
|
|
||||||
subject.value = editTemplate.value.subject
|
|
||||||
body.value = TEMPLATE_BODIES[editTemplate.value.id] || ''
|
|
||||||
toast.info('Template reset to default')
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wrap a merge-tag name in mustaches via JS so the template doesn't have to
|
|
||||||
// nest `{{ ... }}` inside `{{ ... }}` (which Vue's parser scans positionally
|
|
||||||
// and breaks on).
|
|
||||||
function wrapTag(tag: string) {
|
function wrapTag(tag: string) {
|
||||||
return '{' + '{' + tag + '}' + '}'
|
return '{' + '{' + tag + '}' + '}'
|
||||||
}
|
}
|
||||||
|
function insertTag(tag: string) {
|
||||||
function startPublish() {
|
body.value += wrapTag(tag)
|
||||||
publishState.value = 'publishing'
|
|
||||||
setTimeout(() => { publishState.value = 'done' }, 1800)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function openPublish() {
|
// Commit the open template into local overrides (header Save persists).
|
||||||
publishOpen.value = true
|
function applyTemplate() {
|
||||||
publishState.value = 'confirm'
|
if (!editTemplate.value) return
|
||||||
|
overrides[editTemplate.value.id] = { subject: subject.value, body: body.value }
|
||||||
|
editTemplate.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drop the override → revert to the canonical default (persisted on Save).
|
||||||
|
function resetTemplate() {
|
||||||
|
if (!editTemplate.value) return
|
||||||
|
delete overrides[editTemplate.value.id]
|
||||||
|
subject.value = editTemplate.value.subject
|
||||||
|
body.value = TEMPLATE_BODIES[editTemplate.value.id] || ''
|
||||||
|
toast.info('Reverted to default', 'Save changes to apply')
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendTest() {
|
||||||
|
testSent.value = true
|
||||||
|
setTimeout(() => (testSent.value = false), 2500)
|
||||||
}
|
}
|
||||||
|
|
||||||
const renderedSubject = computed(() =>
|
const renderedSubject = computed(() =>
|
||||||
@@ -139,13 +162,13 @@ const renderedSubject = computed(() =>
|
|||||||
const renderedBody = computed(() =>
|
const renderedBody = computed(() =>
|
||||||
body.value
|
body.value
|
||||||
.replace(/\{\{workspace\.name\}\}/g, name.value)
|
.replace(/\{\{workspace\.name\}\}/g, name.value)
|
||||||
.replace(/\{\{workspace\.url\}\}/g, 'workspace.acme.dk')
|
.replace(/\{\{workspace\.url\}\}/g, primaryDomain.value || 'your-workspace')
|
||||||
.replace(/\{\{user\.first_name\}\}/g, 'Anne')
|
.replace(/\{\{user\.first_name\}\}/g, 'Anne')
|
||||||
.replace(/\{\{user\.email\}\}/g, 'anne@acme.dk')
|
.replace(/\{\{user\.email\}\}/g, 'anne@example.com')
|
||||||
.replace(/\{\{inviter\.name\}\}/g, 'Mikkel Nørgaard')
|
.replace(/\{\{inviter\.name\}\}/g, 'Mikkel Nørgaard')
|
||||||
.replace(/\{\{invite\.url\}\}/g, 'workspace.acme.dk/accept/x9k2a')
|
.replace(/\{\{invite\.url\}\}/g, `${primaryDomain.value || 'your-workspace'}/accept/x9k2a`)
|
||||||
.replace(/\{\{reset\.url\}\}/g, 'workspace.acme.dk/reset/p2b7c')
|
.replace(/\{\{reset\.url\}\}/g, `${primaryDomain.value || 'your-workspace'}/reset/p2b7c`)
|
||||||
.replace(/\{\{billing\.url\}\}/g, 'workspace.acme.dk/billing')
|
.replace(/\{\{billing\.url\}\}/g, `${primaryDomain.value || 'your-workspace'}/billing`)
|
||||||
.replace(/\{\{trial\.days_left\}\}/g, '3')
|
.replace(/\{\{trial\.days_left\}\}/g, '3')
|
||||||
.replace(/\{\{stats\.messages\}\}/g, '1.840')
|
.replace(/\{\{stats\.messages\}\}/g, '1.840')
|
||||||
.replace(/\{\{stats\.files\}\}/g, '24')
|
.replace(/\{\{stats\.files\}\}/g, '24')
|
||||||
@@ -153,6 +176,31 @@ const renderedBody = computed(() =>
|
|||||||
.replace(/\{\{stats\.users\}\}/g, '8')
|
.replace(/\{\{stats\.users\}\}/g, '8')
|
||||||
.replace(/\{\{stats\.gb\}\}/g, '14'),
|
.replace(/\{\{stats\.gb\}\}/g, '14'),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ── Save ─────────────────────────────────────────────────────────────────
|
||||||
|
const saving = ref(false)
|
||||||
|
async function save() {
|
||||||
|
if (!slug.value) return
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
name: name.value,
|
||||||
|
brandColor: color.value,
|
||||||
|
emailTemplates: Object.entries(overrides).map(([key, v]) => ({ key, subject: v.subject, body: v.body })),
|
||||||
|
}
|
||||||
|
branding.value = await request<TenantBrandingView>(`/api/tenants/${slug.value}/branding`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: payload,
|
||||||
|
})
|
||||||
|
await fetchMe(true) // name lives on the tenant → refresh dashboard/sidebar identity
|
||||||
|
toast.ok('Branding saved')
|
||||||
|
} catch (e: unknown) {
|
||||||
|
const msg = (e as { data?: { message?: string | string[] } })?.data?.message
|
||||||
|
toast.bad('Could not save branding', Array.isArray(msg) ? msg.join(', ') : msg)
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -160,11 +208,13 @@ const renderedBody = computed(() =>
|
|||||||
<PageHeader
|
<PageHeader
|
||||||
eyebrow="Whitelabel"
|
eyebrow="Whitelabel"
|
||||||
title="Branding"
|
title="Branding"
|
||||||
subtitle="Replace the dezky shell with your own logo, color, and product name. Changes propagate everywhere."
|
subtitle="Give your workspace its own name, accent colour, and email copy."
|
||||||
>
|
>
|
||||||
<template #actions>
|
<template #actions>
|
||||||
<UiButton variant="ghost" @click="resetOpen = true">Reset</UiButton>
|
<UiButton variant="primary" :disabled="saving || !slug" @click="save">
|
||||||
<UiButton variant="primary" @click="openPublish">Publish</UiButton>
|
<template #leading><UiIcon name="check" :size="14" /></template>
|
||||||
|
{{ saving ? 'Saving…' : 'Save changes' }}
|
||||||
|
</UiButton>
|
||||||
</template>
|
</template>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
|
||||||
@@ -175,9 +225,11 @@ const renderedBody = computed(() =>
|
|||||||
<div class="card-head"><Eyebrow>Identity</Eyebrow><div class="card-title">Product identity</div></div>
|
<div class="card-head"><Eyebrow>Identity</Eyebrow><div class="card-title">Product identity</div></div>
|
||||||
<label class="field"><Eyebrow>Product name (shown to users)</Eyebrow><input class="input" v-model="name" /></label>
|
<label class="field"><Eyebrow>Product name (shown to users)</Eyebrow><input class="input" v-model="name" /></label>
|
||||||
<label class="field"><Eyebrow>Custom domain</Eyebrow>
|
<label class="field"><Eyebrow>Custom domain</Eyebrow>
|
||||||
<div class="input-row">
|
<div v-if="primaryDomain" class="input-row">
|
||||||
<input value="workspace.acme.dk" readonly />
|
<input :value="primaryDomain" readonly />
|
||||||
<UiIcon name="check" :size="12" stroke="var(--ok)" :stroke-width="2.5" />
|
</div>
|
||||||
|
<div v-else class="soon-inline">
|
||||||
|
<Mono dim>Custom domains coming soon</Mono>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -199,32 +251,22 @@ const renderedBody = computed(() =>
|
|||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<div class="card-head"><Eyebrow>Assets</Eyebrow><div class="card-title">Logo upload</div></div>
|
<div class="card-head"><Eyebrow>Assets</Eyebrow><div class="card-title">Logo upload</div></div>
|
||||||
<div class="assets">
|
<div class="soon-box">
|
||||||
<div v-for="a in ASSETS" :key="a.id" class="asset" :class="{ has: a.current }">
|
<UiIcon name="upload" :size="16" stroke="var(--text-mute)" />
|
||||||
<div class="asset-icon" :style="{ color: a.current ? 'var(--ok)' : 'var(--text-mute)' }">
|
<div>Logo, square mark and favicon upload is coming soon. For now your accent colour and product name drive the workspace look.</div>
|
||||||
<UiIcon :name="a.current ? 'check' : 'upload'" :size="16" :stroke-width="a.current ? 2.5 : 2" />
|
|
||||||
</div>
|
|
||||||
<div class="asset-meta">
|
|
||||||
<div class="asset-l">{{ a.l }}</div>
|
|
||||||
<Mono dim>{{ a.current ? `${a.currentName} · ${a.currentSize}` : a.d }}</Mono>
|
|
||||||
</div>
|
|
||||||
<UiButton size="sm" :variant="a.current ? 'ghost' : 'secondary'" @click="uploadAsset = a as any; uploaded = false">
|
|
||||||
{{ a.current ? 'Replace' : 'Upload' }}
|
|
||||||
</UiButton>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<div class="card-head"><Eyebrow>Templates</Eyebrow><div class="card-title">Email templates</div></div>
|
<div class="card-head"><Eyebrow>Templates</Eyebrow><div class="card-title">Email templates</div></div>
|
||||||
<div class="templates">
|
<div class="templates">
|
||||||
<button v-for="t in TEMPLATES" :key="t.id" class="tmpl-row" @click="openTemplate(t as any)">
|
<button v-for="t in TEMPLATES" :key="t.id" class="tmpl-row" @click="openTemplate(t)">
|
||||||
<div class="tmpl-meta">
|
<div class="tmpl-meta">
|
||||||
<div class="tmpl-name-row">
|
<div class="tmpl-name-row">
|
||||||
<span class="tmpl-name">{{ t.name }}</span>
|
<span class="tmpl-name">{{ t.name }}</span>
|
||||||
<Badge :tone="t.edited === 'default' ? 'neutral' : 'info'">{{ t.edited === 'default' ? 'default' : 'edited' }}</Badge>
|
<Badge :tone="isEdited(t.id) ? 'info' : 'neutral'">{{ isEdited(t.id) ? 'edited' : 'default' }}</Badge>
|
||||||
</div>
|
</div>
|
||||||
<Mono dim>edited {{ t.edited }}</Mono>
|
<Mono dim>{{ t.desc }}</Mono>
|
||||||
</div>
|
</div>
|
||||||
<UiIcon name="chevRight" :size="14" stroke="var(--text-mute)" />
|
<UiIcon name="chevRight" :size="14" stroke="var(--text-mute)" />
|
||||||
</button>
|
</button>
|
||||||
@@ -236,18 +278,18 @@ const renderedBody = computed(() =>
|
|||||||
<div class="preview-col">
|
<div class="preview-col">
|
||||||
<div class="preview-head">
|
<div class="preview-head">
|
||||||
<Eyebrow>Live preview</Eyebrow>
|
<Eyebrow>Live preview</Eyebrow>
|
||||||
<Mono dim>workspace.acme.dk</Mono>
|
<Mono dim>{{ primaryDomain || slug }}</Mono>
|
||||||
</div>
|
</div>
|
||||||
<div class="preview-frame">
|
<div class="preview-frame">
|
||||||
<div class="frame-topbar">
|
<div class="frame-topbar">
|
||||||
<div class="frame-mark" :style="{ background: color }">{{ name[0]?.toLowerCase() || 'a' }}</div>
|
<div class="frame-mark" :style="{ background: color, color: accentFg }">{{ name[0]?.toLowerCase() || 'a' }}</div>
|
||||||
<div class="frame-brand">{{ name.toLowerCase() }}</div>
|
<div class="frame-brand">{{ name.toLowerCase() }}</div>
|
||||||
<div class="frame-spacer" />
|
<div class="frame-spacer" />
|
||||||
<div class="frame-user">anne@acme.dk</div>
|
<div class="frame-user">{{ primaryDomain ? `you@${primaryDomain}` : 'your team' }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="frame-hero">
|
<div class="frame-hero">
|
||||||
<div class="frame-eyebrow">Dashboard</div>
|
<div class="frame-eyebrow">Dashboard</div>
|
||||||
<div class="frame-title">Good morning, Anne.</div>
|
<div class="frame-title">Good morning.</div>
|
||||||
<div class="frame-tiles">
|
<div class="frame-tiles">
|
||||||
<div v-for="n in ['Mail', 'Drev', 'Møder', 'Chat']" :key="n" class="frame-tile">
|
<div v-for="n in ['Mail', 'Drev', 'Møder', 'Chat']" :key="n" class="frame-tile">
|
||||||
<div class="frame-tile-icon">{{ n[0] }}</div>
|
<div class="frame-tile-icon">{{ n[0] }}</div>
|
||||||
@@ -256,10 +298,10 @@ const renderedBody = computed(() =>
|
|||||||
</div>
|
</div>
|
||||||
<div class="frame-cta" :style="{ background: color }">
|
<div class="frame-cta" :style="{ background: color }">
|
||||||
<div>
|
<div>
|
||||||
<div class="frame-cta-title">Welcome to {{ name }}.</div>
|
<div class="frame-cta-title" :style="{ color: accentFg }">Welcome to {{ name || 'your workspace' }}.</div>
|
||||||
<div class="frame-cta-sub">Your team's workspace is ready.</div>
|
<div class="frame-cta-sub" :style="{ color: accentFg, opacity: 0.75 }">Your team's workspace is ready.</div>
|
||||||
</div>
|
</div>
|
||||||
<button class="frame-cta-btn">Get started</button>
|
<button class="frame-cta-btn" :style="{ background: accentFg, color: accentBtnText }">Get started</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="frame-foot">
|
<div class="frame-foot">
|
||||||
@@ -270,86 +312,6 @@ const renderedBody = computed(() =>
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Upload asset modal -->
|
|
||||||
<Modal :open="!!uploadAsset" :eyebrow="uploadAsset ? `Branding · ${uploadAsset.l.toLowerCase()}` : ''" :title="uploadAsset ? `Upload ${uploadAsset.l.toLowerCase()}` : ''" size="md" @close="uploadAsset = null">
|
|
||||||
<div v-if="uploadAsset" class="upload">
|
|
||||||
<button v-if="!uploaded" class="dropzone" :class="{ over: dragOver }"
|
|
||||||
@dragover.prevent="dragOver = true"
|
|
||||||
@dragleave="dragOver = false"
|
|
||||||
@drop.prevent="dragOver = false; uploaded = true"
|
|
||||||
@click="uploaded = true">
|
|
||||||
<UiIcon name="upload" :size="28" stroke="var(--text-mute)" />
|
|
||||||
<div class="drop-text">
|
|
||||||
<div class="drop-title">Drop {{ uploadAsset.l.toLowerCase() }} here, or click to browse</div>
|
|
||||||
<Mono dim>{{ uploadAsset.formats }} · {{ uploadAsset.ratio }} ratio · up to {{ uploadAsset.maxKb }} KB</Mono>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<template v-if="uploaded">
|
|
||||||
<div class="upload-preview">
|
|
||||||
<div class="upload-mark" :style="{ width: uploadAsset.id === 'full' ? '96px' : '56px' }">
|
|
||||||
{{ uploadAsset.id === 'full' ? 'acme' : 'a' }}
|
|
||||||
</div>
|
|
||||||
<div class="upload-meta">
|
|
||||||
<div class="upload-name">{{ uploadAsset.id === 'favicon' ? 'favicon-new.png' : uploadAsset.id === 'mark' ? 'acme-mark-v2.svg' : 'acme-logo.svg' }}</div>
|
|
||||||
<Mono dim>{{ uploadAsset.id === 'favicon' ? '6 KB' : uploadAsset.id === 'mark' ? '14 KB' : '38 KB' }} · {{ uploadAsset.id === 'favicon' ? '32×32' : uploadAsset.id === 'mark' ? '512×512' : '1200×300' }} · clean alpha</Mono>
|
|
||||||
</div>
|
|
||||||
<UiButton size="sm" variant="ghost" @click="uploaded = false">Replace</UiButton>
|
|
||||||
</div>
|
|
||||||
<Eyebrow>Looks good</Eyebrow>
|
|
||||||
<div class="check-list">
|
|
||||||
<div class="check-row">
|
|
||||||
<UiIcon name="check" :size="12" stroke="var(--ok)" :stroke-width="2.5" />
|
|
||||||
<Mono dim>Format</Mono>
|
|
||||||
<span>{{ uploadAsset.formats.split(' · ')[0] }} ✓</span>
|
|
||||||
</div>
|
|
||||||
<div class="check-row">
|
|
||||||
<UiIcon name="check" :size="12" stroke="var(--ok)" :stroke-width="2.5" />
|
|
||||||
<Mono dim>Dimensions</Mono>
|
|
||||||
<span>{{ uploadAsset.id === 'favicon' ? '32×32 ✓' : uploadAsset.ratio + ' ✓' }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="check-row">
|
|
||||||
<UiIcon name="check" :size="12" stroke="var(--ok)" :stroke-width="2.5" />
|
|
||||||
<Mono dim>Size</Mono>
|
|
||||||
<span>{{ uploadAsset.id === 'favicon' ? '6 KB' : uploadAsset.id === 'mark' ? '14 KB' : '38 KB' }} (under {{ uploadAsset.maxKb }} KB)</span>
|
|
||||||
</div>
|
|
||||||
<div class="check-row">
|
|
||||||
<UiIcon name="check" :size="12" stroke="var(--ok)" :stroke-width="2.5" />
|
|
||||||
<Mono dim>Transparency</Mono>
|
|
||||||
<span>{{ uploadAsset.id === 'favicon' ? 'opaque background OK' : 'transparent background ✓' }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="ld-preview">
|
|
||||||
<Eyebrow>Preview · on light + dark</Eyebrow>
|
|
||||||
<div class="ld-grid">
|
|
||||||
<div class="ld-light">
|
|
||||||
<div class="ld-mark dark" :style="{ width: uploadAsset.id === 'full' ? '80px' : '32px' }">{{ uploadAsset.id === 'full' ? 'acme' : 'a' }}</div>
|
|
||||||
</div>
|
|
||||||
<div class="ld-dark">
|
|
||||||
<div class="ld-mark light" :style="{ width: uploadAsset.id === 'full' ? '80px' : '32px' }">{{ uploadAsset.id === 'full' ? 'acme' : 'a' }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<div class="req-box">
|
|
||||||
<Mono dim>// requirements</Mono>
|
|
||||||
<div class="req-body">
|
|
||||||
<template v-if="uploadAsset.id === 'full'">Used in the top navigation bar, login screen, and email headers. Roughly 200×50 displayed — supply at 2× minimum.</template>
|
|
||||||
<template v-else-if="uploadAsset.id === 'mark'">Used as the app icon, favicon fallback, and any compact context (PWA install, notifications). Must read at 24×24.</template>
|
|
||||||
<template v-else>Browser tab icon and bookmark badge. 32×32 is the standard size — modern browsers use the same file at 16×16.</template>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<template #footer>
|
|
||||||
<UiButton variant="ghost" @click="uploadAsset = null">Cancel</UiButton>
|
|
||||||
<UiButton variant="primary" :disabled="!uploaded" @click="uploadAsset = null">
|
|
||||||
<template #leading><UiIcon name="check" :size="13" /></template>
|
|
||||||
{{ uploaded ? 'Use this asset' : 'Select a file to continue' }}
|
|
||||||
</UiButton>
|
|
||||||
</template>
|
|
||||||
</Modal>
|
|
||||||
|
|
||||||
<!-- Edit email template side panel -->
|
<!-- Edit email template side panel -->
|
||||||
<SidePanel :open="!!editTemplate" :eyebrow="'Email template'" :title="editTemplate?.name || ''" width="lg" @close="editTemplate = null">
|
<SidePanel :open="!!editTemplate" :eyebrow="'Email template'" :title="editTemplate?.name || ''" width="lg" @close="editTemplate = null">
|
||||||
<div v-if="editTemplate" class="tmpl-edit">
|
<div v-if="editTemplate" class="tmpl-edit">
|
||||||
@@ -372,12 +334,12 @@ const renderedBody = computed(() =>
|
|||||||
<div class="email-head">
|
<div class="email-head">
|
||||||
<div class="from-row">
|
<div class="from-row">
|
||||||
<div class="from-mark" :style="{ background: '#0A0A0A', color }">{{ name[0]?.toLowerCase() || 'a' }}</div>
|
<div class="from-mark" :style="{ background: '#0A0A0A', color }">{{ name[0]?.toLowerCase() || 'a' }}</div>
|
||||||
<Mono dim>From: {{ name.toLowerCase().replace(/\s+/g, '-') }}@dezky.com</Mono>
|
<Mono dim>From: {{ (name || 'workspace').toLowerCase().replace(/\s+/g, '-') }}</Mono>
|
||||||
</div>
|
</div>
|
||||||
<div class="email-subj">{{ renderedSubject }}</div>
|
<div class="email-subj">{{ renderedSubject }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="email-body">{{ renderedBody }}</div>
|
<div class="email-body">{{ renderedBody }}</div>
|
||||||
<div class="email-foot" :style="{ background: color }">{{ name }} · workspace.acme.dk</div>
|
<div class="email-foot" :style="{ background: color, color: accentFg }">{{ name || 'Your workspace' }}{{ primaryDomain ? ` · ${primaryDomain}` : '' }}</div>
|
||||||
</div>
|
</div>
|
||||||
<Mono dim style="text-align: center; display: block;">preview substitutes sample data · real send uses recipient's data</Mono>
|
<Mono dim style="text-align: center; display: block;">preview substitutes sample data · real send uses recipient's data</Mono>
|
||||||
</div>
|
</div>
|
||||||
@@ -390,123 +352,14 @@ const renderedBody = computed(() =>
|
|||||||
<div style="flex: 1" />
|
<div style="flex: 1" />
|
||||||
<UiButton variant="secondary" @click="sendTest">
|
<UiButton variant="secondary" @click="sendTest">
|
||||||
<template #leading><UiIcon name="mail" :size="13" /></template>
|
<template #leading><UiIcon name="mail" :size="13" /></template>
|
||||||
{{ testSent ? 'Sent to anne@dezky.com ✓' : 'Send test to me' }}
|
{{ testSent ? 'Test queued ✓' : 'Send test to me' }}
|
||||||
</UiButton>
|
</UiButton>
|
||||||
<UiButton variant="primary" @click="editTemplate = null">
|
<UiButton variant="primary" @click="applyTemplate">
|
||||||
<template #leading><UiIcon name="check" :size="13" /></template>
|
<template #leading><UiIcon name="check" :size="13" /></template>
|
||||||
Save template
|
Apply
|
||||||
</UiButton>
|
</UiButton>
|
||||||
</template>
|
</template>
|
||||||
</SidePanel>
|
</SidePanel>
|
||||||
|
|
||||||
<!-- Publish modal -->
|
|
||||||
<Modal :open="publishOpen" eyebrow="Branding · publish" :title="publishState === 'done' ? 'Branding published' : 'Publish branding changes?'" size="md" @close="publishState !== 'publishing' ? (publishOpen = false) : null">
|
|
||||||
<template v-if="publishState === 'confirm'">
|
|
||||||
<div class="publish-intro">These changes will replace dezky's branding for everyone in your workspace within ~30 seconds.</div>
|
|
||||||
<Eyebrow>Will go live</Eyebrow>
|
|
||||||
<div class="publish-summary">
|
|
||||||
<div class="ps-row"><Mono dim>Product name</Mono><span>{{ name }}</span></div>
|
|
||||||
<div class="ps-row">
|
|
||||||
<Mono dim>Primary color</Mono>
|
|
||||||
<span class="color-line">
|
|
||||||
<span class="color-chip" :style="{ background: color }" />
|
|
||||||
<Mono>{{ color }}</Mono>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="ps-row">
|
|
||||||
<Mono dim>Custom domain</Mono>
|
|
||||||
<Mono>workspace.acme.dk</Mono>
|
|
||||||
<Badge tone="ok" dot>verified</Badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Eyebrow>Propagates to</Eyebrow>
|
|
||||||
<div class="prop-grid">
|
|
||||||
<div v-for="[k, t] in [
|
|
||||||
['Web app · workspace shell', '~10s'],
|
|
||||||
['Login + auth pages', '~10s'],
|
|
||||||
['Outbound email templates', '~30s'],
|
|
||||||
['Mobile app · next session', 'on next launch'],
|
|
||||||
['Status page', '~30s'],
|
|
||||||
['PDF invoices', 'next billing cycle'],
|
|
||||||
]" :key="k" class="prop-cell">
|
|
||||||
<UiIcon name="check" :size="11" stroke="var(--ok)" :stroke-width="2.5" />
|
|
||||||
<span>{{ k }}</span>
|
|
||||||
<Mono dim>{{ t }}</Mono>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="publish-warn">
|
|
||||||
<UiIcon name="shield" :size="14" stroke="var(--warn)" />
|
|
||||||
<div>Users may need to hard-refresh to see the new branding immediately. You can revert with one click for the next 7 days.</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<template v-else-if="publishState === 'publishing'">
|
|
||||||
<div class="publishing">
|
|
||||||
<div class="spinner" />
|
|
||||||
<div class="publish-title">Publishing across services…</div>
|
|
||||||
<Mono dim>web shell · auth · mail templates · CDN</Mono>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<template v-else>
|
|
||||||
<div class="done-head">
|
|
||||||
<div class="done-badge" :style="{ background: color }">
|
|
||||||
<UiIcon name="check" :size="20" :stroke-width="2.5" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div class="publish-title">{{ name }} branding is live</div>
|
|
||||||
<Mono dim>5 services updated · 1 queued for next cycle</Mono>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="done-list">
|
|
||||||
<dl class="def">
|
|
||||||
<div><dt>Web app + auth</dt><dd>live · 8 seconds</dd></div>
|
|
||||||
<div><dt>Email templates</dt><dd>live · 18 seconds</dd></div>
|
|
||||||
<div><dt>Mobile · status · CDN</dt><dd>queued · ~30s</dd></div>
|
|
||||||
<div><dt>PDF invoices</dt><dd>starts 01 Jun 2026</dd></div>
|
|
||||||
</dl>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<template #footer>
|
|
||||||
<template v-if="publishState === 'confirm'">
|
|
||||||
<UiButton variant="ghost" @click="publishOpen = false">Cancel</UiButton>
|
|
||||||
<UiButton variant="primary" @click="startPublish">
|
|
||||||
<template #leading><UiIcon name="external" :size="13" /></template>
|
|
||||||
Publish now
|
|
||||||
</UiButton>
|
|
||||||
</template>
|
|
||||||
<template v-else-if="publishState === 'publishing'">
|
|
||||||
<UiButton variant="ghost" disabled>Publishing…</UiButton>
|
|
||||||
</template>
|
|
||||||
<template v-else>
|
|
||||||
<UiButton variant="primary" @click="publishOpen = false">Done</UiButton>
|
|
||||||
</template>
|
|
||||||
</template>
|
|
||||||
</Modal>
|
|
||||||
|
|
||||||
<!-- Reset branding modal -->
|
|
||||||
<Modal :open="resetOpen" eyebrow="Destructive · reverts to defaults" title="Reset branding to dezky defaults?" size="sm" @close="resetOpen = false">
|
|
||||||
<div class="reset-box bad">
|
|
||||||
<UiIcon name="shield" :size="16" stroke="var(--bad)" />
|
|
||||||
<div>Reverts product name, colors, logos, and email templates to dezky defaults. Your custom domain stays connected. Edits made today are kept for 7 days and can be restored from your audit log.</div>
|
|
||||||
</div>
|
|
||||||
<div class="reset-list">
|
|
||||||
<dl class="def">
|
|
||||||
<div><dt>Product name</dt><dd>Acme Workspace → dezky</dd></div>
|
|
||||||
<div><dt>Primary color</dt><dd>#D4FF3A → #D4FF3A (default)</dd></div>
|
|
||||||
<div><dt>Full logo</dt><dd>will be removed</dd></div>
|
|
||||||
<div><dt>Square mark</dt><dd>will be removed</dd></div>
|
|
||||||
<div><dt>Favicon</dt><dd>will be removed</dd></div>
|
|
||||||
<div><dt>Email templates</dt><dd>2 edited templates → defaults</dd></div>
|
|
||||||
<div><dt>Custom domain</dt><dd>workspace.acme.dk · kept</dd></div>
|
|
||||||
</dl>
|
|
||||||
</div>
|
|
||||||
<template #footer>
|
|
||||||
<UiButton variant="ghost" @click="resetOpen = false">Cancel</UiButton>
|
|
||||||
<UiButton variant="danger" @click="resetOpen = false">
|
|
||||||
<template #leading><UiIcon name="refresh" :size="13" /></template>
|
|
||||||
Reset everything
|
|
||||||
</UiButton>
|
|
||||||
</template>
|
|
||||||
</Modal>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -534,24 +387,24 @@ const renderedBody = computed(() =>
|
|||||||
}
|
}
|
||||||
.input-row input { flex: 1; border: none; outline: none; background: transparent; font-family: inherit; font-size: 13px; color: var(--text); }
|
.input-row input { flex: 1; border: none; outline: none; background: transparent; font-family: inherit; font-size: 13px; color: var(--text); }
|
||||||
.color-preview { width: 36px; height: 36px; border-radius: 6px; border: 1px solid var(--border); flex-shrink: 0; }
|
.color-preview { width: 36px; height: 36px; border-radius: 6px; border: 1px solid var(--border); flex-shrink: 0; }
|
||||||
|
.soon-inline { padding: 8px 0; }
|
||||||
|
|
||||||
|
.soon-box {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding: 14px;
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px dashed var(--border-hi, var(--border));
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
.swatches { display: flex; gap: 10px; margin-bottom: 14px; }
|
.swatches { display: flex; gap: 10px; margin-bottom: 14px; }
|
||||||
.swatches button { width: 38px; height: 38px; border-radius: 6px; cursor: pointer; }
|
.swatches button { width: 38px; height: 38px; border-radius: 6px; cursor: pointer; }
|
||||||
|
|
||||||
.assets { display: flex; flex-direction: column; gap: 10px; }
|
|
||||||
.asset {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
padding: 12px;
|
|
||||||
border: 1px dashed var(--border);
|
|
||||||
border-radius: 6px;
|
|
||||||
}
|
|
||||||
.asset.has { background: var(--surface); border-style: solid; }
|
|
||||||
.asset-icon { width: 40px; height: 40px; border-radius: 6px; background: var(--bg); display: inline-flex; align-items: center; justify-content: center; }
|
|
||||||
.asset-meta { flex: 1; min-width: 0; }
|
|
||||||
.asset-l { font-size: 13px; font-weight: 500; }
|
|
||||||
|
|
||||||
.templates { display: flex; flex-direction: column; }
|
.templates { display: flex; flex-direction: column; }
|
||||||
.tmpl-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 12px 0; border-bottom: 1px solid var(--border); background: transparent; border-left: none; border-right: none; border-top: none; text-align: left; color: var(--text); font-family: inherit; font-size: 13px; cursor: pointer; }
|
.tmpl-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 12px 0; border-bottom: 1px solid var(--border); background: transparent; border-left: none; border-right: none; border-top: none; text-align: left; color: var(--text); font-family: inherit; font-size: 13px; cursor: pointer; }
|
||||||
.tmpl-row:last-child { border-bottom: none; }
|
.tmpl-row:last-child { border-bottom: none; }
|
||||||
@@ -618,70 +471,6 @@ const renderedBody = computed(() =>
|
|||||||
.frame-cta-btn { height: 32px; padding: 0 14px; border-radius: 5px; border: none; background: #0A0A0A; color: #F4F3EE; font-weight: 600; font-size: 12px; cursor: pointer; }
|
.frame-cta-btn { height: 32px; padding: 0 14px; border-radius: 5px; border: none; background: #0A0A0A; color: #F4F3EE; font-weight: 600; font-size: 12px; cursor: pointer; }
|
||||||
.frame-foot { padding: 12px 32px; border-top: 1px solid #E6E4DC; background: #F4F3EE; font-size: 11px; color: #5A5A55; font-family: var(--font-mono); display: flex; justify-content: space-between; }
|
.frame-foot { padding: 12px 32px; border-top: 1px solid #E6E4DC; background: #F4F3EE; font-size: 11px; color: #5A5A55; font-family: var(--font-mono); display: flex; justify-content: space-between; }
|
||||||
|
|
||||||
/* Upload modal */
|
|
||||||
.upload { display: flex; flex-direction: column; gap: 14px; }
|
|
||||||
.dropzone {
|
|
||||||
padding: 48px 24px;
|
|
||||||
background: var(--bg);
|
|
||||||
border: 2px dashed var(--border);
|
|
||||||
border-radius: 10px;
|
|
||||||
cursor: pointer;
|
|
||||||
font-family: inherit;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
gap: 14px;
|
|
||||||
}
|
|
||||||
.dropzone.over { background: var(--surface); border-color: var(--text); }
|
|
||||||
.drop-text { text-align: center; }
|
|
||||||
.drop-title { font-size: 14px; font-weight: 500; color: var(--text); }
|
|
||||||
.upload-preview {
|
|
||||||
padding: 16px;
|
|
||||||
background: var(--surface);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 8px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 14px;
|
|
||||||
}
|
|
||||||
.upload-mark {
|
|
||||||
height: 56px;
|
|
||||||
background: var(--text);
|
|
||||||
color: var(--bg);
|
|
||||||
border-radius: 6px;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-weight: 700;
|
|
||||||
font-size: 18px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.upload-meta { flex: 1; min-width: 0; }
|
|
||||||
.upload-name { font-size: 13px; font-weight: 500; }
|
|
||||||
.check-list { display: flex; flex-direction: column; gap: 6px; }
|
|
||||||
.check-row { display: flex; align-items: center; gap: 10px; padding: 8px 12px; background: var(--bg); border-radius: 6px; border: 1px solid var(--border); font-size: 12px; }
|
|
||||||
.check-row > :first-of-type { flex-shrink: 0; }
|
|
||||||
.ld-preview { padding: 12px; background: var(--bg); border-radius: 6px; border: 1px solid var(--border); }
|
|
||||||
.ld-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-top: 8px; }
|
|
||||||
.ld-light, .ld-dark { border-radius: 6px; padding: 18px; display: flex; align-items: center; justify-content: center; }
|
|
||||||
.ld-light { background: #FAFAF7; }
|
|
||||||
.ld-dark { background: #0A0A0A; }
|
|
||||||
.ld-mark {
|
|
||||||
height: 32px;
|
|
||||||
border-radius: 4px;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-weight: 700;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
.ld-mark.dark { background: #0A0A0A; color: #F4F3EE; }
|
|
||||||
.ld-mark.light { background: #F4F3EE; color: #0A0A0A; }
|
|
||||||
.req-box { padding: 12px; background: var(--bg); border-radius: 6px; border: 1px solid var(--border); font-size: 12px; color: var(--text-mute); line-height: 1.55; }
|
|
||||||
.req-body { margin-top: 6px; }
|
|
||||||
|
|
||||||
/* Email template editor */
|
/* Email template editor */
|
||||||
.tmpl-edit { display: grid; grid-template-columns: 1fr 1fr; min-height: 0; height: 100%; }
|
.tmpl-edit { display: grid; grid-template-columns: 1fr 1fr; min-height: 0; height: 100%; }
|
||||||
.tmpl-col { padding: 24px; border-right: 1px solid var(--border); display: flex; flex-direction: column; gap: 14px; }
|
.tmpl-col { padding: 24px; border-right: 1px solid var(--border); display: flex; flex-direction: column; gap: 14px; }
|
||||||
@@ -740,49 +529,4 @@ const renderedBody = computed(() =>
|
|||||||
.email-subj { font-family: var(--font-display); font-weight: 600; font-size: 16px; margin-top: 10px; color: #0A0A0A; }
|
.email-subj { font-family: var(--font-display); font-weight: 600; font-size: 16px; margin-top: 10px; color: #0A0A0A; }
|
||||||
.email-body { padding: 20px; font-size: 13px; line-height: 1.65; color: #3A3A35; white-space: pre-wrap; flex: 1; overflow-y: auto; }
|
.email-body { padding: 20px; font-size: 13px; line-height: 1.65; color: #3A3A35; white-space: pre-wrap; flex: 1; overflow-y: auto; }
|
||||||
.email-foot { padding: 14px 20px; border-top: 1px solid #E6E4DC; color: #0A0A0A; font-size: 11px; font-family: var(--font-mono); text-align: center; }
|
.email-foot { padding: 14px 20px; border-top: 1px solid #E6E4DC; color: #0A0A0A; font-size: 11px; font-family: var(--font-mono); text-align: center; }
|
||||||
|
|
||||||
/* Publish modal */
|
|
||||||
.publish-intro { font-size: 13px; color: var(--text-dim); line-height: 1.55; margin-bottom: 14px; }
|
|
||||||
.publish-summary { padding: 14px; background: var(--bg); border-radius: 6px; border: 1px solid var(--border); display: flex; flex-direction: column; gap: 10px; margin-top: 8px; margin-bottom: 14px; }
|
|
||||||
.ps-row { display: flex; align-items: center; gap: 12px; }
|
|
||||||
.ps-row > :first-child { width: 100px; }
|
|
||||||
.color-line { display: inline-flex; align-items: center; gap: 8px; font-size: 13px; }
|
|
||||||
.color-chip { width: 14px; height: 14px; border-radius: 3px; border: 1px solid var(--border); }
|
|
||||||
.prop-grid { padding: 14px; background: var(--bg); border-radius: 6px; border: 1px solid var(--border); display: grid; grid-template-columns: 1fr 1fr; gap: 10px; font-size: 12px; margin-top: 8px; margin-bottom: 14px; }
|
|
||||||
.prop-cell { display: flex; align-items: center; gap: 8px; }
|
|
||||||
.prop-cell span:first-of-type { flex: 1; }
|
|
||||||
.publish-warn { padding: 12px; background: rgba(232, 154, 31, 0.06); border-radius: 6px; border: 1px solid rgba(232, 154, 31, 0.2); font-size: 12px; color: var(--text-dim); line-height: 1.55; display: flex; gap: 10px; }
|
|
||||||
.publishing { padding: 32px 0; text-align: center; }
|
|
||||||
.spinner {
|
|
||||||
width: 56px;
|
|
||||||
height: 56px;
|
|
||||||
margin: 0 auto 18px auto;
|
|
||||||
border-radius: 999px;
|
|
||||||
border: 3px solid var(--border);
|
|
||||||
border-top-color: var(--accent);
|
|
||||||
animation: spin 1s linear infinite;
|
|
||||||
}
|
|
||||||
@keyframes spin { to { transform: rotate(360deg) } }
|
|
||||||
.publish-title { font-family: var(--font-display); font-size: 20px; font-weight: 600; }
|
|
||||||
.done-head { display: flex; align-items: center; gap: 14px; margin-bottom: 14px; }
|
|
||||||
.done-badge {
|
|
||||||
width: 44px;
|
|
||||||
height: 44px;
|
|
||||||
border-radius: 10px;
|
|
||||||
color: #0A0A0A;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
.done-list { padding: 14px; background: var(--bg); border-radius: 6px; border: 1px solid var(--border); }
|
|
||||||
.def { margin: 0; display: grid; grid-template-columns: 140px 1fr; row-gap: 12px; column-gap: 16px; }
|
|
||||||
.def > div { display: contents; }
|
|
||||||
.def dt { font-family: var(--font-mono); font-size: 10px; letter-spacing: 0.12em; text-transform: uppercase; color: var(--text-mute); }
|
|
||||||
.def dd { margin: 0; font-size: 13px; color: var(--text); }
|
|
||||||
|
|
||||||
/* Reset modal */
|
|
||||||
.reset-box { padding: 14px; border-radius: 6px; display: flex; gap: 10px; align-items: flex-start; margin-bottom: 14px; }
|
|
||||||
.reset-box.bad { background: rgba(226, 48, 48, 0.06); border: 1px solid rgba(226, 48, 48, 0.2); }
|
|
||||||
.reset-box > div { font-size: 13px; color: var(--text-dim); line-height: 1.5; }
|
|
||||||
.reset-list { padding: 12px; background: var(--bg); border-radius: 6px; border: 1px solid var(--border); }
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
+101
-110
@@ -1,61 +1,77 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
// Strict port of project/platform-screens.jsx `AdminDashboard` (lines 447-605).
|
// Customer-admin dashboard. Layout descends from project/platform-screens.jsx
|
||||||
// Keep spacing tokens (24px 40px 64px 40px content, 16 gaps), the 4-column
|
// `AdminDashboard`, but the data is real: workspace identity, seats, spend,
|
||||||
// stat strip in a single Card with per-column borders, the two-up
|
// plan and recent admin events all come from /api/me + /api/tenants/:slug/*.
|
||||||
// 1.4fr / 1fr blocks (License + Recent admin events; Issues + Quick actions),
|
//
|
||||||
// the source's exact issue rows, audit slice, and quick-action buttons.
|
// Sections without a real backend source yet (storage usage, mail-flow health,
|
||||||
|
// "open issues" like DMARC/failed-login heuristics) were removed rather than
|
||||||
|
// faked — they return when their backends (OCIS metrics, Stalwart metrics, a
|
||||||
|
// domain-health checker) exist.
|
||||||
|
|
||||||
import type { IconName } from '~/components/UiIcon.vue'
|
import type { IconName } from '~/components/UiIcon.vue'
|
||||||
import { sampleAudit } from '~/data/workspace'
|
import type { AuditEventDoc, TenantUserDoc } from '~/types/workspace'
|
||||||
|
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
|
const { fetchMe } = useMe()
|
||||||
|
await fetchMe()
|
||||||
|
const { tenant, subscription, planLabel, currency, seatLimit, perSeatMonthly, monthlySpend, primaryDomain, renewsAt } = useTenant()
|
||||||
|
const slug = computed(() => tenant.value?.slug ?? '')
|
||||||
|
|
||||||
|
// Workspace users (seat usage) + recent audit, both tenant-scoped. Gated on a
|
||||||
|
// resolved slug so we don't fire against /api/tenants//... before /me lands.
|
||||||
|
const { data: users } = await useFetch<TenantUserDoc[]>(
|
||||||
|
() => `/api/tenants/${slug.value}/users`,
|
||||||
|
{ key: 'admin-dash-users', default: () => [], immediate: !!slug.value, watch: [slug] },
|
||||||
|
)
|
||||||
|
const { data: auditRaw } = await useFetch<AuditEventDoc[]>(
|
||||||
|
() => `/api/tenants/${slug.value}/audit?limit=6`,
|
||||||
|
{ key: 'admin-dash-audit', default: () => [], immediate: !!slug.value, watch: [slug] },
|
||||||
|
)
|
||||||
|
|
||||||
|
const seatsUsed = computed(() => (users.value ?? []).filter((u) => u.active !== false).length)
|
||||||
|
const seatsAvailable = computed(() => Math.max(0, seatLimit.value - seatsUsed.value))
|
||||||
|
const seatPct = computed(() =>
|
||||||
|
seatLimit.value ? Math.min(100, Math.round((seatsUsed.value / seatLimit.value) * 100)) : 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
const moneyFmt = computed(
|
||||||
|
() => new Intl.NumberFormat('da-DK', { style: 'currency', currency: currency.value, maximumFractionDigits: 0 }),
|
||||||
|
)
|
||||||
|
function fmtDate(d: Date | null): string {
|
||||||
|
return d ? d.toLocaleDateString('da-DK', { day: '2-digit', month: 'long', year: 'numeric' }) : '—'
|
||||||
|
}
|
||||||
|
const statusLabel = computed(() => {
|
||||||
|
const s = tenant.value?.status ?? 'pending'
|
||||||
|
return s.charAt(0).toUpperCase() + s.slice(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
const stats = computed<Array<{ label: string; value: string; hint: string }>>(() => [
|
||||||
|
{ label: 'Seats used', value: `${seatsUsed.value} / ${seatLimit.value}`, hint: `${seatsAvailable.value} available` },
|
||||||
|
{ label: 'Monthly spend', value: moneyFmt.value.format(monthlySpend.value), hint: renewsAt.value ? `renews ${fmtDate(renewsAt.value)}` : '' },
|
||||||
|
{ label: 'Plan', value: planLabel.value, hint: subscription.value?.cycle ?? '' },
|
||||||
|
{ label: 'Status', value: statusLabel.value, hint: `${tenant.value?.domains?.length ?? 0} domain${(tenant.value?.domains?.length ?? 0) === 1 ? '' : 's'}` },
|
||||||
|
])
|
||||||
|
|
||||||
|
// Map raw audit events onto the row shape the activity list renders. Tone is
|
||||||
|
// derived from outcome (failed actions read red); everything else is neutral.
|
||||||
|
const recent = computed(() =>
|
||||||
|
(auditRaw.value ?? []).map((e) => ({
|
||||||
|
id: e._id,
|
||||||
|
when: new Date(e.at).toLocaleTimeString('da-DK', { hour: '2-digit', minute: '2-digit' }),
|
||||||
|
actor: e.actorType === 'system' ? 'system' : e.actorEmail ?? '—',
|
||||||
|
action: e.action,
|
||||||
|
target: e.resourceName ?? e.resourceId ?? '',
|
||||||
|
tone: e.outcome === 'failure' ? 'bad' : 'info',
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
|
||||||
const inviteOpen = ref(false)
|
const inviteOpen = ref(false)
|
||||||
const inviteStep = ref(1)
|
const inviteStep = ref(1)
|
||||||
const seatsOpen = ref(false)
|
const seatsOpen = ref(false)
|
||||||
const seatsExtra = ref(5)
|
const seatsExtra = ref(5)
|
||||||
|
|
||||||
const stats: Array<{
|
|
||||||
label: string
|
|
||||||
value: string
|
|
||||||
delta?: string
|
|
||||||
deltaTone?: 'up' | 'down'
|
|
||||||
hint: string
|
|
||||||
}> = [
|
|
||||||
{ label: 'Seats used', value: '11 / 25', delta: '+2 this week', deltaTone: 'up', hint: '' },
|
|
||||||
{ label: 'Storage', value: '1.4 TB', delta: '64% of 2.2 TB', hint: '' },
|
|
||||||
{ label: 'Mail flow', value: 'Healthy', hint: '99.98% · last 7d' },
|
|
||||||
{ label: 'Monthly spend', value: '1.940 DKK', hint: 'next invoice 01 Jun' },
|
|
||||||
]
|
|
||||||
|
|
||||||
const recent = sampleAudit.slice(0, 6)
|
|
||||||
|
|
||||||
const issues = [
|
|
||||||
{
|
|
||||||
tone: 'warn' as const,
|
|
||||||
title: 'DMARC record missing on baslund.dk',
|
|
||||||
body: 'Mail from this domain may fail Gmail / Outlook spam checks.',
|
|
||||||
action: 'Fix record',
|
|
||||||
onAction: () => router.push('/admin/domains'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
tone: 'bad' as const,
|
|
||||||
title: 'Failed login attempts from 203.0.113.4',
|
|
||||||
body: '3 attempts on oliver@dezky.com in the last hour. Consider IP blocklist.',
|
|
||||||
action: 'Review',
|
|
||||||
onAction: () => router.push('/admin/security'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
tone: 'info' as const,
|
|
||||||
title: '2 invitations pending',
|
|
||||||
body: 'Magnus Eriksen and Emma Skov haven’t accepted yet.',
|
|
||||||
action: 'Resend',
|
|
||||||
onAction: () => toast.ok('Invitation resent to Magnus and Emma'),
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
const quickActions: { icon: IconName; label: string; onClick: () => void }[] = [
|
const quickActions: { icon: IconName; label: string; onClick: () => void }[] = [
|
||||||
{ icon: 'users', label: 'Invite user', onClick: () => { inviteOpen.value = true } },
|
{ icon: 'users', label: 'Invite user', onClick: () => { inviteOpen.value = true } },
|
||||||
{ icon: 'globe', label: 'Verify domain', onClick: () => router.push('/admin/domains') },
|
{ icon: 'globe', label: 'Verify domain', onClick: () => router.push('/admin/domains') },
|
||||||
@@ -71,16 +87,24 @@ function sendInvite() {
|
|||||||
toast.ok('Invitation sent to magnus@dezky.com')
|
toast.ok('Invitation sent to magnus@dezky.com')
|
||||||
}
|
}
|
||||||
|
|
||||||
const pricePerSeat = 78
|
// Add-seats modal math, fed by the real subscription. The seat-change mutation
|
||||||
const daysUntilRenewal = 96
|
// itself isn't wired yet (subscription PATCH is operator-only), so confirming
|
||||||
const monthly = computed(() => seatsExtra.value * pricePerSeat)
|
// still toasts — but the figures shown are the customer's real numbers.
|
||||||
const prorated = computed(() => Math.round(monthly.value * (daysUntilRenewal / 30)))
|
// perSeatMonthly is already cycle-normalized + in major units.
|
||||||
|
const pricePerSeat = computed(() => perSeatMonthly.value)
|
||||||
|
const daysUntilRenewal = computed(() => {
|
||||||
|
if (!renewsAt.value) return 30
|
||||||
|
const ms = renewsAt.value.getTime() - Date.now()
|
||||||
|
return Math.max(0, Math.round(ms / 86_400_000))
|
||||||
|
})
|
||||||
|
const monthly = computed(() => seatsExtra.value * pricePerSeat.value)
|
||||||
|
const prorated = computed(() => Math.round(monthly.value * (daysUntilRenewal.value / 30)))
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
eyebrow="Acme Workspace · dezky.com"
|
:eyebrow="tenant ? `${tenant.name}${primaryDomain ? ` · ${primaryDomain}` : ''}` : 'Workspace'"
|
||||||
title="Dashboard"
|
title="Dashboard"
|
||||||
subtitle="Health, activity, and quick actions across your workspace."
|
subtitle="Health, activity, and quick actions across your workspace."
|
||||||
>
|
>
|
||||||
@@ -104,8 +128,6 @@ const prorated = computed(() => Math.round(monthly.value * (daysUntilRenewal / 3
|
|||||||
<Stat
|
<Stat
|
||||||
:label="s.label"
|
:label="s.label"
|
||||||
:value="s.value"
|
:value="s.value"
|
||||||
:delta="s.delta"
|
|
||||||
:delta-tone="s.deltaTone"
|
|
||||||
:hint="s.hint"
|
:hint="s.hint"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -118,17 +140,19 @@ const prorated = computed(() => Math.round(monthly.value * (daysUntilRenewal / 3
|
|||||||
<div class="card-head card-head-inline">
|
<div class="card-head card-head-inline">
|
||||||
<div>
|
<div>
|
||||||
<Eyebrow>Plan</Eyebrow>
|
<Eyebrow>Plan</Eyebrow>
|
||||||
<div class="card-title">Business · 25 seats</div>
|
<div class="card-title">{{ planLabel }} · {{ seatLimit }} seats</div>
|
||||||
<div class="card-sub">Renewing 28 August 2026 · 1.940 DKK / month</div>
|
<div class="card-sub">
|
||||||
|
<template v-if="renewsAt">Renewing {{ fmtDate(renewsAt) }} · </template>{{ moneyFmt.format(monthlySpend) }} / month
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<UiButton size="sm" variant="secondary" @click="router.push('/admin/billing')">Manage plan</UiButton>
|
<UiButton size="sm" variant="secondary" @click="router.push('/admin/billing')">Manage plan</UiButton>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="progress-block">
|
<div class="progress-block">
|
||||||
<div class="progress-bar"><span style="width: 44%" /></div>
|
<div class="progress-bar"><span :style="{ width: `${seatPct}%` }" /></div>
|
||||||
<div class="progress-legend">
|
<div class="progress-legend">
|
||||||
<span>11 active</span>
|
<span>{{ seatsUsed }} active</span>
|
||||||
<span>14 available</span>
|
<span>{{ seatsAvailable }} available</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -160,31 +184,15 @@ const prorated = computed(() => Math.round(monthly.value * (daysUntilRenewal / 3
|
|||||||
</div>
|
</div>
|
||||||
<Mono dim>{{ a.when }}</Mono>
|
<Mono dim>{{ a.when }}</Mono>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="recent.length === 0" class="audit-empty">
|
||||||
|
<Mono dim>No recent activity yet.</Mono>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Open issues + Quick actions -->
|
<!-- Quick actions -->
|
||||||
<div class="row two-col-11">
|
<div class="row">
|
||||||
<Card>
|
|
||||||
<div class="card-head card-head-inline">
|
|
||||||
<div>
|
|
||||||
<Eyebrow>Health</Eyebrow>
|
|
||||||
<div class="card-title">Open issues</div>
|
|
||||||
</div>
|
|
||||||
<Badge tone="warn">2 to review</Badge>
|
|
||||||
</div>
|
|
||||||
<div class="issues">
|
|
||||||
<div v-for="it in issues" :key="it.title" class="issue" :data-tone="it.tone">
|
|
||||||
<div class="issue-body">
|
|
||||||
<div class="issue-title">{{ it.title }}</div>
|
|
||||||
<div class="issue-sub">{{ it.body }}</div>
|
|
||||||
</div>
|
|
||||||
<UiButton size="sm" variant="secondary" @click="it.onAction()">{{ it.action }}</UiButton>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<div class="card-head card-head-inline">
|
<div class="card-head card-head-inline">
|
||||||
<div>
|
<div>
|
||||||
@@ -192,7 +200,7 @@ const prorated = computed(() => Math.round(monthly.value * (daysUntilRenewal / 3
|
|||||||
<div class="card-title">Common tasks</div>
|
<div class="card-title">Common tasks</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="qa-grid">
|
<div class="qa-grid qa-grid-wide">
|
||||||
<button v-for="a in quickActions" :key="a.label" class="qa" @click="a.onClick">
|
<button v-for="a in quickActions" :key="a.label" class="qa" @click="a.onClick">
|
||||||
<UiIcon :name="a.icon" :size="15" stroke="var(--text-mute)" />
|
<UiIcon :name="a.icon" :size="15" stroke="var(--text-mute)" />
|
||||||
{{ a.label }}
|
{{ a.label }}
|
||||||
@@ -262,9 +270,9 @@ const prorated = computed(() => Math.round(monthly.value * (daysUntilRenewal / 3
|
|||||||
<Modal :open="seatsOpen" title="Add seats" eyebrow="Billing · seats" size="md" @close="seatsOpen = false">
|
<Modal :open="seatsOpen" title="Add seats" eyebrow="Billing · seats" size="md" @close="seatsOpen = false">
|
||||||
<div class="seats">
|
<div class="seats">
|
||||||
<div class="seats-grid">
|
<div class="seats-grid">
|
||||||
<div class="seats-cell"><Eyebrow>Active users</Eyebrow><div class="seats-big">11</div></div>
|
<div class="seats-cell"><Eyebrow>Active users</Eyebrow><div class="seats-big">{{ seatsUsed }}</div></div>
|
||||||
<div class="seats-cell"><Eyebrow>Current seats</Eyebrow><div class="seats-big">25</div></div>
|
<div class="seats-cell"><Eyebrow>Current seats</Eyebrow><div class="seats-big">{{ seatLimit }}</div></div>
|
||||||
<div class="seats-cell"><Eyebrow>After change</Eyebrow><div class="seats-big ok">{{ 25 + seatsExtra }}</div></div>
|
<div class="seats-cell"><Eyebrow>After change</Eyebrow><div class="seats-big ok">{{ seatLimit + seatsExtra }}</div></div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Eyebrow>How many seats to add</Eyebrow>
|
<Eyebrow>How many seats to add</Eyebrow>
|
||||||
@@ -279,19 +287,19 @@ const prorated = computed(() => Math.round(monthly.value * (daysUntilRenewal / 3
|
|||||||
</div>
|
</div>
|
||||||
<div class="charge-summary">
|
<div class="charge-summary">
|
||||||
<Eyebrow>What you'll pay</Eyebrow>
|
<Eyebrow>What you'll pay</Eyebrow>
|
||||||
<div class="charge-row"><span>{{ seatsExtra }} new seat{{ seatsExtra === 1 ? '' : 's' }} × {{ pricePerSeat }} DKK / month</span><Mono>{{ monthly.toLocaleString('da-DK') }} DKK / mo</Mono></div>
|
<div class="charge-row"><span>{{ seatsExtra }} new seat{{ seatsExtra === 1 ? '' : 's' }} × {{ moneyFmt.format(pricePerSeat) }} / month</span><Mono>{{ moneyFmt.format(monthly) }} / mo</Mono></div>
|
||||||
<div class="charge-row sep"><span class="muted">Prorated for current cycle ({{ daysUntilRenewal }} days until renewal)</span><Mono dim>{{ prorated.toLocaleString('da-DK') }} DKK</Mono></div>
|
<div class="charge-row sep"><span class="muted">Prorated for current cycle ({{ daysUntilRenewal }} days until renewal)</span><Mono dim>{{ moneyFmt.format(prorated) }}</Mono></div>
|
||||||
<div class="charge-row total"><span>Charged today</span><span class="big">{{ prorated.toLocaleString('da-DK') }} DKK</span></div>
|
<div class="charge-row total"><span>Charged today</span><span class="big">{{ moneyFmt.format(prorated) }}</span></div>
|
||||||
<div class="charge-row"><span class="muted">Next invoice on 01 Jun 2026</span><Mono dim>{{ (1940 + monthly).toLocaleString('da-DK') }} DKK</Mono></div>
|
<div class="charge-row"><span class="muted"><template v-if="renewsAt">Next invoice on {{ fmtDate(renewsAt) }}</template><template v-else>Next invoice</template></span><Mono dim>{{ moneyFmt.format(monthlySpend + monthly) }}</Mono></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="info-strip">
|
<div class="info-strip">
|
||||||
<UiIcon name="card" :size="14" stroke="var(--text-mute)" />
|
<UiIcon name="card" :size="14" stroke="var(--text-mute)" />
|
||||||
<span>Charged to <Mono>Visa •••• 4242</Mono>. Seats are added instantly — invitations can be sent right away.</span>
|
<span>Seats are added instantly — invitations can be sent right away.</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<UiButton variant="ghost" @click="seatsOpen = false">Cancel</UiButton>
|
<UiButton variant="ghost" @click="seatsOpen = false">Cancel</UiButton>
|
||||||
<UiButton variant="primary" @click="() => { seatsOpen = false; toast.ok(`${seatsExtra} seats added · charged ${prorated.toLocaleString('da-DK')} DKK`) }">
|
<UiButton variant="primary" @click="() => { seatsOpen = false; toast.ok(`${seatsExtra} seats added · charged ${moneyFmt.format(prorated)}`) }">
|
||||||
<template #leading><UiIcon name="plus" :size="13" /></template>
|
<template #leading><UiIcon name="plus" :size="13" /></template>
|
||||||
Add {{ seatsExtra }} seat{{ seatsExtra === 1 ? '' : 's' }}
|
Add {{ seatsExtra }} seat{{ seatsExtra === 1 ? '' : 's' }}
|
||||||
</UiButton>
|
</UiButton>
|
||||||
@@ -304,7 +312,6 @@ const prorated = computed(() => Math.round(monthly.value * (daysUntilRenewal / 3
|
|||||||
.content { padding: 24px 40px 64px 40px; }
|
.content { padding: 24px 40px 64px 40px; }
|
||||||
.row { display: grid; gap: 16px; margin-top: 16px; }
|
.row { display: grid; gap: 16px; margin-top: 16px; }
|
||||||
.two-col-14 { grid-template-columns: 1.4fr 1fr; }
|
.two-col-14 { grid-template-columns: 1.4fr 1fr; }
|
||||||
.two-col-11 { grid-template-columns: 1fr 1fr; }
|
|
||||||
|
|
||||||
.strip { margin-bottom: 16px; }
|
.strip { margin-bottom: 16px; }
|
||||||
.strip-grid { display: grid; grid-template-columns: repeat(4, 1fr); }
|
.strip-grid { display: grid; grid-template-columns: repeat(4, 1fr); }
|
||||||
@@ -370,28 +377,12 @@ const prorated = computed(() => Math.round(monthly.value * (daysUntilRenewal / 3
|
|||||||
.audit-content { flex: 1; min-width: 0; }
|
.audit-content { flex: 1; min-width: 0; }
|
||||||
.audit-line { display: flex; gap: 6px; align-items: baseline; flex-wrap: wrap; }
|
.audit-line { display: flex; gap: 6px; align-items: baseline; flex-wrap: wrap; }
|
||||||
.audit-actor { font-weight: 500; }
|
.audit-actor { font-weight: 500; }
|
||||||
|
.audit-empty { padding: 24px 16px; text-align: center; }
|
||||||
|
|
||||||
/* Issues — strict bg with left tone border */
|
/* Quick actions — grid of "tiles" */
|
||||||
.issues { display: flex; flex-direction: column; gap: 10px; }
|
|
||||||
.issue {
|
|
||||||
padding: 14px;
|
|
||||||
background: var(--bg);
|
|
||||||
border-radius: 6px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
border-left: 2px solid var(--border);
|
|
||||||
}
|
|
||||||
.issue[data-tone='ok'] { border-left-color: var(--ok); }
|
|
||||||
.issue[data-tone='warn'] { border-left-color: var(--warn); }
|
|
||||||
.issue[data-tone='bad'] { border-left-color: var(--bad); }
|
|
||||||
.issue[data-tone='info'] { border-left-color: var(--info); }
|
|
||||||
.issue-body { flex: 1; min-width: 0; }
|
|
||||||
.issue-title { font-size: 13px; font-weight: 500; }
|
|
||||||
.issue-sub { font-size: 12px; color: var(--text-mute); margin-top: 2px; }
|
|
||||||
|
|
||||||
/* Quick actions — 2-col grid of "tiles" */
|
|
||||||
.qa-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
|
.qa-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
|
||||||
|
/* Full-width card → 3 columns so the six actions sit in two tidy rows. */
|
||||||
|
.qa-grid-wide { grid-template-columns: repeat(3, 1fr); }
|
||||||
.qa {
|
.qa {
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
|
|||||||
@@ -1,41 +1,66 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
// Strict port of project/platform-screens.jsx `UsersScreen` (lines 625-768)
|
// Users & groups. The Users tab is real — workspace members come from
|
||||||
// with FilterChip (770), UserDetailPanel (816), DefList (948), InviteUserModal
|
// /api/tenants/:slug/users (platform-api UserDocument). The Groups,
|
||||||
// (961), plus GroupsTabRich from platform-admin.jsx (1022), InvitationsTab and
|
// Invitations and Service-accounts tabs have no backend yet (no Group /
|
||||||
// ServiceAccountsTab (platform-screens.jsx 1090, 1123).
|
// Invitation / ServiceAccount schema exists), so they render honest
|
||||||
|
// "coming soon" states rather than fabricated rows.
|
||||||
//
|
//
|
||||||
// User detail panel tabs follow the source order: Profile · Access · Mail ·
|
// Mutations (invite, suspend, role change, CSV import) still toast-stub: the
|
||||||
// Files · Activity · Audit (no Danger zone in the source).
|
// user-write endpoints are operator-only today, so a customer admin can't
|
||||||
|
// commit them yet. The data shown is real; the writes are not wired.
|
||||||
|
|
||||||
|
import type { TenantUserDoc } from '~/types/workspace'
|
||||||
import { sampleUsersFlat, groupsFull, sampleAudit } from '~/data/workspace'
|
|
||||||
|
|
||||||
type User = (typeof sampleUsersFlat)[number]
|
|
||||||
|
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
|
|
||||||
|
const { fetchMe } = useMe()
|
||||||
|
await fetchMe()
|
||||||
|
const { tenant } = useTenant()
|
||||||
|
const slug = computed(() => tenant.value?.slug ?? '')
|
||||||
|
|
||||||
|
const { data: users } = await useFetch<TenantUserDoc[]>(
|
||||||
|
() => `/api/tenants/${slug.value}/users`,
|
||||||
|
{ key: 'admin-users', default: () => [], immediate: !!slug.value, watch: [slug] },
|
||||||
|
)
|
||||||
|
|
||||||
const tab = ref<'users' | 'groups' | 'invitations' | 'service'>('users')
|
const tab = ref<'users' | 'groups' | 'invitations' | 'service'>('users')
|
||||||
const query = ref('')
|
const query = ref('')
|
||||||
const statusFilter = ref<'all' | 'active' | 'invited' | 'suspended'>('all')
|
const statusFilter = ref<'all' | 'active' | 'suspended'>('all')
|
||||||
const selected = ref<Set<string>>(new Set())
|
const selected = ref<Set<string>>(new Set())
|
||||||
const openUser = ref<User | null>(null)
|
const openUser = ref<TenantUserDoc | null>(null)
|
||||||
const userTab = ref<'profile' | 'access' | 'mail' | 'files' | 'activity' | 'audit'>('profile')
|
|
||||||
const inviteOpen = ref(false)
|
const inviteOpen = ref(false)
|
||||||
const inviteStep = ref(1)
|
const inviteStep = ref(1)
|
||||||
const importOpen = ref(false)
|
const importOpen = ref(false)
|
||||||
|
|
||||||
|
const userStatus = (u: TenantUserDoc): 'active' | 'suspended' => (u.active === false ? 'suspended' : 'active')
|
||||||
|
const roleLabel = (r: string) => r.charAt(0).toUpperCase() + r.slice(1)
|
||||||
|
|
||||||
const filteredUsers = computed(() =>
|
const filteredUsers = computed(() =>
|
||||||
sampleUsersFlat.filter((u) => {
|
(users.value ?? []).filter((u) => {
|
||||||
if (statusFilter.value !== 'all' && u.status !== statusFilter.value) return false
|
if (statusFilter.value !== 'all' && userStatus(u) !== statusFilter.value) return false
|
||||||
if (query.value && !`${u.name} ${u.email}`.toLowerCase().includes(query.value.toLowerCase())) return false
|
if (query.value && !`${u.name} ${u.email}`.toLowerCase().includes(query.value.toLowerCase())) return false
|
||||||
return true
|
return true
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
const invites = computed(() => sampleUsersFlat.filter((u) => u.status === 'invited'))
|
|
||||||
|
|
||||||
const statusTone = (s: string): 'ok' | 'warn' | 'bad' =>
|
const statusTone = (s: string): 'ok' | 'warn' | 'bad' =>
|
||||||
s === 'active' ? 'ok' : s === 'invited' ? 'warn' : 'bad'
|
s === 'active' ? 'ok' : s === 'suspended' ? 'bad' : 'warn'
|
||||||
|
|
||||||
|
function lastSeen(iso?: string): string {
|
||||||
|
if (!iso) return 'never'
|
||||||
|
const ms = Date.now() - new Date(iso).getTime()
|
||||||
|
const m = Math.floor(ms / 60_000)
|
||||||
|
if (m < 1) return 'just now'
|
||||||
|
if (m < 60) return `${m} min ago`
|
||||||
|
const h = Math.floor(m / 60)
|
||||||
|
if (h < 24) return `${h} h ago`
|
||||||
|
const d = Math.floor(h / 24)
|
||||||
|
if (d < 30) return `${d} day${d === 1 ? '' : 's'} ago`
|
||||||
|
return new Date(iso).toLocaleDateString('da-DK', { day: '2-digit', month: 'short', year: 'numeric' })
|
||||||
|
}
|
||||||
|
function joinedDate(iso?: string): string {
|
||||||
|
return iso ? new Date(iso).toLocaleDateString('da-DK', { day: '2-digit', month: 'long', year: 'numeric' }) : '—'
|
||||||
|
}
|
||||||
|
|
||||||
function toggleSelect(id: string) {
|
function toggleSelect(id: string) {
|
||||||
const s = new Set(selected.value)
|
const s = new Set(selected.value)
|
||||||
@@ -45,26 +70,17 @@ function toggleSelect(id: string) {
|
|||||||
}
|
}
|
||||||
function clearSelection() { selected.value = new Set() }
|
function clearSelection() { selected.value = new Set() }
|
||||||
|
|
||||||
watch(openUser, (u) => { if (u) userTab.value = 'profile' })
|
|
||||||
|
|
||||||
// Filter chip
|
// Filter chip
|
||||||
type ChipOption = { value: string; label: string }
|
type ChipOption = { value: string; label: string }
|
||||||
const statusOptions: ChipOption[] = [
|
const statusOptions: ChipOption[] = [
|
||||||
{ value: 'all', label: 'All' },
|
{ value: 'all', label: 'All' },
|
||||||
{ value: 'active', label: 'Active' },
|
{ value: 'active', label: 'Active' },
|
||||||
{ value: 'invited', label: 'Invited' },
|
|
||||||
{ value: 'suspended', label: 'Suspended' },
|
{ value: 'suspended', label: 'Suspended' },
|
||||||
]
|
]
|
||||||
|
|
||||||
// Groups tab
|
|
||||||
const openGroup = ref<typeof groupsFull[number] | null>(null)
|
|
||||||
const createGroupOpen = ref(false)
|
|
||||||
|
|
||||||
// Bulk-action modals + confirm
|
// Bulk-action modals + confirm
|
||||||
const assignGroupOpen = ref(false)
|
|
||||||
const changeRoleOpen = ref(false)
|
const changeRoleOpen = ref(false)
|
||||||
const suspendOpen = ref(false)
|
const suspendOpen = ref(false)
|
||||||
const groupChoice = ref<Set<string>>(new Set())
|
|
||||||
const roleChoice = ref<'member' | 'admin' | 'owner'>('member')
|
const roleChoice = ref<'member' | 'admin' | 'owner'>('member')
|
||||||
|
|
||||||
function sendInvite() {
|
function sendInvite() {
|
||||||
@@ -73,14 +89,6 @@ function sendInvite() {
|
|||||||
toast.ok('Invitation sent to magnus@dezky.com')
|
toast.ok('Invitation sent to magnus@dezky.com')
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyBulkGroup() {
|
|
||||||
const n = selected.value.size
|
|
||||||
const gs = [...groupChoice.value].join(', ') || '—'
|
|
||||||
assignGroupOpen.value = false
|
|
||||||
toast.ok(`${n} user${n === 1 ? '' : 's'} added to: ${gs}`)
|
|
||||||
groupChoice.value = new Set()
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyBulkRole() {
|
function applyBulkRole() {
|
||||||
const n = selected.value.size
|
const n = selected.value.size
|
||||||
changeRoleOpen.value = false
|
changeRoleOpen.value = false
|
||||||
@@ -99,15 +107,8 @@ function bulkExport() {
|
|||||||
toast.info(`Exporting ${n} user${n === 1 ? '' : 's'}…`, 'CSV with profile + access columns')
|
toast.info(`Exporting ${n} user${n === 1 ? '' : 's'}…`, 'CSV with profile + access columns')
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleGroup(g: string) {
|
|
||||||
const s = new Set(groupChoice.value)
|
|
||||||
if (s.has(g)) s.delete(g)
|
|
||||||
else s.add(g)
|
|
||||||
groupChoice.value = s
|
|
||||||
}
|
|
||||||
|
|
||||||
// Per-row kebab — open the user detail panel by default.
|
// Per-row kebab — open the user detail panel by default.
|
||||||
function rowAction(u: User, id: string) {
|
function rowAction(u: TenantUserDoc, id: string) {
|
||||||
if (id === 'open') openUser.value = u
|
if (id === 'open') openUser.value = u
|
||||||
else if (id === 'reset') toast.info(`Password reset link sent to ${u.email}`)
|
else if (id === 'reset') toast.info(`Password reset link sent to ${u.email}`)
|
||||||
else if (id === 'force') toast.info(`Forcing logout for ${u.name}`)
|
else if (id === 'force') toast.info(`Forcing logout for ${u.name}`)
|
||||||
@@ -115,13 +116,6 @@ function rowAction(u: User, id: string) {
|
|||||||
else if (id === 'delete') toast.bad(`${u.name} deletion scheduled`)
|
else if (id === 'delete') toast.bad(`${u.name} deletion scheduled`)
|
||||||
}
|
}
|
||||||
|
|
||||||
function groupAction(g: typeof groupsFull[number], id: string) {
|
|
||||||
if (id === 'open') openGroup.value = g
|
|
||||||
else if (id === 'rename') toast.info(`Rename ${g.name}`)
|
|
||||||
else if (id === 'duplicate') toast.info(`Duplicated ${g.name}`)
|
|
||||||
else if (id === 'delete') toast.bad(`${g.name} deletion scheduled`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const userRowItems = [
|
const userRowItems = [
|
||||||
{ id: 'open', label: 'Open profile', icon: 'external' as const },
|
{ id: 'open', label: 'Open profile', icon: 'external' as const },
|
||||||
{ id: 'reset', label: 'Send password reset', icon: 'key' as const },
|
{ id: 'reset', label: 'Send password reset', icon: 'key' as const },
|
||||||
@@ -130,14 +124,6 @@ const userRowItems = [
|
|||||||
{ id: 'suspend', label: 'Suspend user', icon: 'shield' as const, danger: true },
|
{ id: 'suspend', label: 'Suspend user', icon: 'shield' as const, danger: true },
|
||||||
{ id: 'delete', label: 'Delete user', icon: 'trash' as const, danger: true },
|
{ id: 'delete', label: 'Delete user', icon: 'trash' as const, danger: true },
|
||||||
]
|
]
|
||||||
|
|
||||||
const groupRowItems = [
|
|
||||||
{ id: 'open', label: 'Open group', icon: 'external' as const },
|
|
||||||
{ id: 'rename', label: 'Rename', icon: 'brush' as const },
|
|
||||||
{ id: 'duplicate', label: 'Duplicate', icon: 'copy' as const },
|
|
||||||
{ id: 'sep1', separator: true },
|
|
||||||
{ id: 'delete', label: 'Delete group',icon: 'trash' as const, danger: true },
|
|
||||||
]
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -167,10 +153,10 @@ const groupRowItems = [
|
|||||||
<Tabs
|
<Tabs
|
||||||
v-model="tab"
|
v-model="tab"
|
||||||
:items="[
|
:items="[
|
||||||
{ value: 'users', label: 'Users', count: sampleUsersFlat.length },
|
{ value: 'users', label: 'Users', count: users.length },
|
||||||
{ value: 'groups', label: 'Groups', count: 6 },
|
{ value: 'groups', label: 'Groups' },
|
||||||
{ value: 'invitations', label: 'Invitations', count: 2 },
|
{ value: 'invitations', label: 'Invitations' },
|
||||||
{ value: 'service', label: 'Service accounts', count: 3 },
|
{ value: 'service', label: 'Service accounts' },
|
||||||
]"
|
]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -183,16 +169,13 @@ const groupRowItems = [
|
|||||||
<input v-model="query" placeholder="Search by name or email…" />
|
<input v-model="query" placeholder="Search by name or email…" />
|
||||||
</div>
|
</div>
|
||||||
<AdminFilterChip label="Status" :options="statusOptions" v-model="statusFilter" />
|
<AdminFilterChip label="Status" :options="statusOptions" v-model="statusFilter" />
|
||||||
<button class="chip"><Eyebrow>Role:</Eyebrow><span>All</span><UiIcon name="chevDown" :size="12" stroke="var(--text-mute)" /></button>
|
|
||||||
<button class="chip"><Eyebrow>Group:</Eyebrow><span>All</span><UiIcon name="chevDown" :size="12" stroke="var(--text-mute)" /></button>
|
|
||||||
<div class="spacer" />
|
<div class="spacer" />
|
||||||
<Mono dim>{{ filteredUsers.length }} of {{ sampleUsersFlat.length }}</Mono>
|
<Mono dim>{{ filteredUsers.length }} of {{ users.length }}</Mono>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="selected.size > 0" class="bulk">
|
<div v-if="selected.size > 0" class="bulk">
|
||||||
<Mono style="color: inherit">{{ selected.size }} selected</Mono>
|
<Mono style="color: inherit">{{ selected.size }} selected</Mono>
|
||||||
<div class="spacer" />
|
<div class="spacer" />
|
||||||
<UiButton size="sm" variant="ghost" class="invert" @click="assignGroupOpen = true">Assign group</UiButton>
|
|
||||||
<UiButton size="sm" variant="ghost" class="invert" @click="changeRoleOpen = true">Change role</UiButton>
|
<UiButton size="sm" variant="ghost" class="invert" @click="changeRoleOpen = true">Change role</UiButton>
|
||||||
<UiButton size="sm" variant="ghost" class="invert" @click="bulkExport">Export selected</UiButton>
|
<UiButton size="sm" variant="ghost" class="invert" @click="bulkExport">Export selected</UiButton>
|
||||||
<UiButton size="sm" variant="ghost" class="invert" @click="suspendOpen = true">Suspend</UiButton>
|
<UiButton size="sm" variant="ghost" class="invert" @click="suspendOpen = true">Suspend</UiButton>
|
||||||
@@ -204,15 +187,15 @@ const groupRowItems = [
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th class="check">
|
<th class="check">
|
||||||
<input type="checkbox" :checked="selected.size === filteredUsers.length && filteredUsers.length > 0" @change="(e) => (e.target as HTMLInputElement).checked ? (selected = new Set(filteredUsers.map(u => u.id))) : clearSelection()" />
|
<input type="checkbox" :checked="selected.size === filteredUsers.length && filteredUsers.length > 0" @change="(e) => (e.target as HTMLInputElement).checked ? (selected = new Set(filteredUsers.map(u => u._id))) : clearSelection()" />
|
||||||
</th>
|
</th>
|
||||||
<th>Name</th><th>Role</th><th>Status</th><th>Group</th><th>Last seen</th><th class="right">Storage</th><th />
|
<th>Name</th><th>Role</th><th>Status</th><th>Last seen</th><th />
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="u in filteredUsers" :key="u.id" @click="openUser = u">
|
<tr v-for="u in filteredUsers" :key="u._id" @click="openUser = u">
|
||||||
<td class="check" @click.stop>
|
<td class="check" @click.stop>
|
||||||
<input type="checkbox" :checked="selected.has(u.id)" @change="toggleSelect(u.id)" />
|
<input type="checkbox" :checked="selected.has(u._id)" @change="toggleSelect(u._id)" />
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="name-cell">
|
<div class="name-cell">
|
||||||
@@ -223,137 +206,52 @@ const groupRowItems = [
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td><Badge :tone="u.role === 'Owner' ? 'invert' : 'neutral'">{{ u.role }}</Badge></td>
|
<td><Badge :tone="u.role === 'owner' ? 'invert' : 'neutral'">{{ roleLabel(u.role) }}</Badge></td>
|
||||||
<td><Badge :tone="statusTone(u.status)" dot>{{ u.status }}</Badge></td>
|
<td><Badge :tone="statusTone(userStatus(u))" dot>{{ userStatus(u) }}</Badge></td>
|
||||||
<td><span class="group-text">{{ u.group }}</span></td>
|
<td><Mono dim>{{ lastSeen(u.lastLoginAt) }}</Mono></td>
|
||||||
<td><Mono dim>{{ u.last }}</Mono></td>
|
|
||||||
<td class="right"><Mono>{{ u.storage > 0 ? `${u.storage} GB` : '—' }}</Mono></td>
|
|
||||||
<td class="right" @click.stop>
|
<td class="right" @click.stop>
|
||||||
<AdminKebabMenu :items="userRowItems" :icon-size="16" @select="(id) => rowAction(u, id)" />
|
<AdminKebabMenu :items="userRowItems" :icon-size="16" @select="(id) => rowAction(u, id)" />
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
<tr v-if="filteredUsers.length === 0" class="no-hover">
|
||||||
|
<td colspan="6" class="empty-row">
|
||||||
|
<Mono dim>{{ users.length === 0 ? 'No members in this workspace yet.' : 'No users match your filters.' }}</Mono>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<div class="pager">
|
|
||||||
<Mono dim>Showing 1–{{ filteredUsers.length }}</Mono>
|
|
||||||
<div class="pager-btns">
|
|
||||||
<UiButton size="sm" variant="secondary">
|
|
||||||
<template #leading><UiIcon name="chevLeft" :size="12" /></template>
|
|
||||||
Prev
|
|
||||||
</UiButton>
|
|
||||||
<UiButton size="sm" variant="secondary">
|
|
||||||
Next
|
|
||||||
<template #trailing><UiIcon name="chevRight" :size="12" /></template>
|
|
||||||
</UiButton>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- GROUPS TAB (GroupsTabRich) -->
|
<!-- GROUPS TAB — no backend yet -->
|
||||||
<div v-else-if="tab === 'groups'" class="content">
|
<div v-else-if="tab === 'groups'" class="content">
|
||||||
<div class="toolbar">
|
<div class="empty-card">
|
||||||
<div class="input-search">
|
<UiIcon name="users" :size="28" stroke="var(--text-mute)" />
|
||||||
<UiIcon name="search" :size="14" stroke="var(--text-mute)" />
|
<div class="empty-title">Group management coming soon</div>
|
||||||
<input placeholder="Search groups…" />
|
<div class="empty-body">Create teams, assign mail aliases and shared resources, and manage memberships in one place. We're wiring this up to your identity provider.</div>
|
||||||
</div>
|
</div>
|
||||||
<button class="chip"><Eyebrow>Sort:</Eyebrow><span>Name</span><UiIcon name="chevDown" :size="12" stroke="var(--text-mute)" /></button>
|
|
||||||
<div class="spacer" />
|
|
||||||
<Mono dim>{{ groupsFull.length }} groups</Mono>
|
|
||||||
<UiButton variant="primary" @click="createGroupOpen = true">
|
|
||||||
<template #leading><UiIcon name="plus" :size="14" /></template>
|
|
||||||
New group
|
|
||||||
</UiButton>
|
|
||||||
</div>
|
|
||||||
<Card :pad="0">
|
|
||||||
<table class="users-tbl">
|
|
||||||
<thead>
|
|
||||||
<tr><th>Group</th><th>Alias</th><th>Members</th><th>Owner</th><th>Created</th><th /></tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr v-for="g in groupsFull" :key="g.id" @click="openGroup = g">
|
|
||||||
<td>
|
|
||||||
<div class="name-cell">
|
|
||||||
<div class="g-icon"><UiIcon name="users" :size="14" /></div>
|
|
||||||
<div>
|
|
||||||
<div class="u-name">{{ g.name }}</div>
|
|
||||||
<Mono dim>{{ g.description }}</Mono>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td><Mono>{{ g.alias }}</Mono></td>
|
|
||||||
<td>
|
|
||||||
<div class="member-cell">
|
|
||||||
<UiIcon name="users" :size="12" stroke="var(--text-mute)" />
|
|
||||||
<Mono>{{ g.members }}</Mono>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<div class="name-cell small">
|
|
||||||
<Avatar :name="g.owner" :size="22" />
|
|
||||||
<span>{{ g.owner }}</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td><Mono dim>{{ g.created }}</Mono></td>
|
|
||||||
<td class="right" @click.stop>
|
|
||||||
<AdminKebabMenu :items="groupRowItems" @select="(id) => groupAction(g, id)" />
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- INVITATIONS TAB -->
|
<!-- INVITATIONS TAB — no backend yet -->
|
||||||
<div v-else-if="tab === 'invitations'" class="content">
|
<div v-else-if="tab === 'invitations'" class="content">
|
||||||
<Card :pad="0">
|
<div class="empty-card">
|
||||||
<table class="users-tbl">
|
<UiIcon name="mail" :size="28" stroke="var(--text-mute)" />
|
||||||
<thead><tr><th>Recipient</th><th>Sent</th><th>Expires</th><th /></tr></thead>
|
<div class="empty-title">Invitations coming soon</div>
|
||||||
<tbody>
|
<div class="empty-body">Track pending invites, copy activation links, and resend or revoke them here once user provisioning is wired to the workspace.</div>
|
||||||
<tr v-for="u in invites" :key="u.id">
|
|
||||||
<td>
|
|
||||||
<div class="name-cell">
|
|
||||||
<Avatar :name="u.name" :size="28" />
|
|
||||||
<div>
|
|
||||||
<div class="u-name">{{ u.name }}</div>
|
|
||||||
<Mono dim>{{ u.email }}</Mono>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
|
||||||
<td><Mono dim>14 May 2026</Mono></td>
|
|
||||||
<td><Mono dim>21 May 2026</Mono></td>
|
|
||||||
<td class="right">
|
|
||||||
<UiButton size="sm" variant="secondary">
|
|
||||||
<template #leading><UiIcon name="copy" :size="13" /></template>
|
|
||||||
Copy link
|
|
||||||
</UiButton>
|
|
||||||
<UiButton size="sm" variant="secondary">
|
|
||||||
<template #leading><UiIcon name="refresh" :size="13" /></template>
|
|
||||||
Resend
|
|
||||||
</UiButton>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- SERVICE ACCOUNTS TAB -->
|
<!-- SERVICE ACCOUNTS TAB — no backend yet -->
|
||||||
<div v-else class="content">
|
<div v-else class="content">
|
||||||
<div class="empty-card">
|
<div class="empty-card">
|
||||||
<UiIcon name="key" :size="28" stroke="var(--text-mute)" />
|
<UiIcon name="key" :size="28" stroke="var(--text-mute)" />
|
||||||
<div class="empty-title">3 service accounts</div>
|
<div class="empty-title">Service accounts coming soon</div>
|
||||||
<div class="empty-body">Service accounts let scripts and integrations authenticate to your workspace. Manage their API tokens here.</div>
|
<div class="empty-body">Service accounts will let scripts and integrations authenticate to your workspace with scoped API tokens.</div>
|
||||||
<UiButton variant="primary">
|
|
||||||
<template #leading><UiIcon name="plus" :size="14" /></template>
|
|
||||||
New service account
|
|
||||||
</UiButton>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- User detail side panel -->
|
<!-- User detail side panel — read-only profile from real data -->
|
||||||
<SidePanel :open="!!openUser" :eyebrow="openUser?.id || ''" :title="openUser?.name || ''" width="lg" @close="openUser = null">
|
<SidePanel :open="!!openUser" :eyebrow="openUser?.email || ''" :title="openUser?.name || ''" width="lg" @close="openUser = null">
|
||||||
<div v-if="openUser" class="user-detail">
|
<div v-if="openUser" class="user-detail">
|
||||||
<div class="ud-head">
|
<div class="ud-head">
|
||||||
<Avatar :name="openUser.name" :size="56" />
|
<Avatar :name="openUser.name" :size="56" />
|
||||||
@@ -361,138 +259,28 @@ const groupRowItems = [
|
|||||||
<div class="ud-name">{{ openUser.name }}</div>
|
<div class="ud-name">{{ openUser.name }}</div>
|
||||||
<Mono dim>{{ openUser.email }}</Mono>
|
<Mono dim>{{ openUser.email }}</Mono>
|
||||||
<div class="ud-badges">
|
<div class="ud-badges">
|
||||||
<Badge :tone="statusTone(openUser.status)" dot>{{ openUser.status }}</Badge>
|
<Badge :tone="statusTone(userStatus(openUser))" dot>{{ userStatus(openUser) }}</Badge>
|
||||||
<Badge tone="neutral">{{ openUser.role }}</Badge>
|
<Badge tone="neutral">{{ roleLabel(openUser.role) }}</Badge>
|
||||||
<Badge tone="neutral">{{ openUser.group }}</Badge>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Tabs
|
|
||||||
v-model="userTab"
|
|
||||||
:items="[
|
|
||||||
{ value: 'profile', label: 'Profile' },
|
|
||||||
{ value: 'access', label: 'Access' },
|
|
||||||
{ value: 'mail', label: 'Mail' },
|
|
||||||
{ value: 'files', label: 'Files' },
|
|
||||||
{ value: 'activity', label: 'Activity' },
|
|
||||||
{ value: 'audit', label: 'Audit' },
|
|
||||||
]"
|
|
||||||
/>
|
|
||||||
<div class="ud-body">
|
<div class="ud-body">
|
||||||
<template v-if="userTab === 'profile'">
|
|
||||||
<dl class="def">
|
<dl class="def">
|
||||||
<div><dt>Full name</dt><dd>{{ openUser.name }}</dd></div>
|
<div><dt>Full name</dt><dd>{{ openUser.name }}</dd></div>
|
||||||
<div><dt>Email</dt><dd>{{ openUser.email }}</dd></div>
|
<div><dt>Email</dt><dd>{{ openUser.email }}</dd></div>
|
||||||
<div><dt>Role</dt><dd>{{ openUser.role }}</dd></div>
|
<div><dt>Role</dt><dd>{{ roleLabel(openUser.role) }}</dd></div>
|
||||||
<div><dt>Group</dt><dd>{{ openUser.group }}</dd></div>
|
<div><dt>Status</dt><dd>{{ userStatus(openUser) }}</dd></div>
|
||||||
<div><dt>License</dt><dd>Business · seat 11</dd></div>
|
<div><dt>Joined</dt><dd>{{ joinedDate(openUser.createdAt) }}</dd></div>
|
||||||
<div><dt>Joined</dt><dd>14 January 2026</dd></div>
|
<div><dt>Last sign-in</dt><dd>{{ lastSeen(openUser.lastLoginAt) }}</dd></div>
|
||||||
<div><dt>Locale</dt><dd>da-DK · Europe/Copenhagen</dd></div>
|
|
||||||
<div><dt>Phone</dt><dd>+45 21 47 88 02</dd></div>
|
|
||||||
</dl>
|
</dl>
|
||||||
</template>
|
|
||||||
|
|
||||||
<template v-else-if="userTab === 'access'">
|
|
||||||
<dl class="def">
|
|
||||||
<div><dt>MFA</dt><dd><Badge tone="ok" dot>enabled · TOTP</Badge></dd></div>
|
|
||||||
<div><dt>SSO sessions</dt><dd>2 active</dd></div>
|
|
||||||
<div><dt>Last sign-in</dt><dd>{{ openUser.last }} · 92.43.118.4 · Copenhagen</dd></div>
|
|
||||||
<div><dt>Recovery codes</dt><dd>8 of 10 unused</dd></div>
|
|
||||||
</dl>
|
|
||||||
<div class="sub-head">Active devices</div>
|
|
||||||
<div v-for="d in [
|
|
||||||
{ d: 'MacBook Pro · macOS 14', w: 'Chrome 132', loc: 'Copenhagen', active: '2 min ago' },
|
|
||||||
{ d: 'iPhone 15 Pro · iOS 18', w: 'dezky Mail', loc: 'Copenhagen', active: '1 h ago' },
|
|
||||||
]" :key="d.d" class="dev-row">
|
|
||||||
<UiIcon name="device" :size="18" stroke="var(--text-mute)" />
|
|
||||||
<div class="dev-meta">
|
|
||||||
<div class="dev-d">{{ d.d }}</div>
|
|
||||||
<Mono dim>{{ d.w }} · {{ d.loc }} · {{ d.active }}</Mono>
|
|
||||||
</div>
|
|
||||||
<UiButton size="sm" variant="ghost">Revoke</UiButton>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template v-else-if="userTab === 'mail'">
|
|
||||||
<dl class="def">
|
|
||||||
<div><dt>Primary address</dt><dd>{{ openUser.email }}</dd></div>
|
|
||||||
<div><dt>Quota</dt><dd>12.4 GB of 50 GB · 25%</dd></div>
|
|
||||||
<div><dt>Forwarding</dt><dd>Off</dd></div>
|
|
||||||
<div><dt>Vacation reply</dt><dd>Off</dd></div>
|
|
||||||
</dl>
|
|
||||||
<div class="sub-head">Aliases</div>
|
|
||||||
<div v-for="a in ['anne.b@dezky.com', 'founder@dezky.com']" :key="a" class="alias-row">
|
|
||||||
<Mono>{{ a }}</Mono>
|
|
||||||
<UiButton size="sm" variant="ghost">
|
|
||||||
<template #leading><UiIcon name="trash" :size="12" /></template>
|
|
||||||
Remove
|
|
||||||
</UiButton>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template v-else-if="userTab === 'files'">
|
|
||||||
<dl class="def">
|
|
||||||
<div><dt>Quota</dt><dd>12.4 GB of 100 GB · 12%</dd></div>
|
|
||||||
<div><dt>Shared by user</dt><dd>14 items</dd></div>
|
|
||||||
<div><dt>Shared with user</dt><dd>23 items</dd></div>
|
|
||||||
</dl>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template v-else-if="userTab === 'activity'">
|
|
||||||
<div class="activity-list">
|
|
||||||
<div v-for="a in sampleAudit.slice(0, 6)" :key="a.id" class="activity-row">
|
|
||||||
<Mono dim>{{ a.when }}</Mono>
|
|
||||||
<span class="activity-action">{{ a.action }}</span>
|
|
||||||
<Mono dim>{{ a.ip }}</Mono>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template v-else>
|
|
||||||
<div class="empty-tab">
|
|
||||||
<UiIcon name="shield" :size="28" stroke="var(--text-mute)" />
|
|
||||||
<div class="empty-title">No changes recorded yet</div>
|
|
||||||
<div class="empty-body">Edits to this user's settings will appear here.</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<UiButton variant="danger">
|
<UiButton variant="danger" @click="openUser && rowAction(openUser, 'force')">
|
||||||
<template #leading><UiIcon name="logout" :size="13" /></template>
|
<template #leading><UiIcon name="logout" :size="13" /></template>
|
||||||
Force logout
|
Force logout
|
||||||
</UiButton>
|
</UiButton>
|
||||||
<UiButton variant="secondary">Reset password</UiButton>
|
<UiButton variant="secondary" @click="openUser && rowAction(openUser, 'reset')">Reset password</UiButton>
|
||||||
<UiButton variant="primary">Save changes</UiButton>
|
|
||||||
</template>
|
|
||||||
</SidePanel>
|
|
||||||
|
|
||||||
<!-- Group detail side panel -->
|
|
||||||
<SidePanel :open="!!openGroup" eyebrow="Group" :title="openGroup?.name || ''" width="lg" @close="openGroup = null">
|
|
||||||
<div v-if="openGroup" class="user-detail">
|
|
||||||
<div class="ud-head">
|
|
||||||
<div class="g-icon big"><UiIcon name="users" :size="22" /></div>
|
|
||||||
<div class="ud-meta">
|
|
||||||
<div class="ud-name">{{ openGroup.name }}</div>
|
|
||||||
<Mono dim>{{ openGroup.alias }}</Mono>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="ud-body">
|
|
||||||
<dl class="def">
|
|
||||||
<div><dt>Members</dt><dd>{{ openGroup.members }}</dd></div>
|
|
||||||
<div><dt>Owner</dt><dd>{{ openGroup.owner }}</dd></div>
|
|
||||||
<div><dt>Created</dt><dd>{{ openGroup.created }}</dd></div>
|
|
||||||
<div><dt>Description</dt><dd>{{ openGroup.description }}</dd></div>
|
|
||||||
</dl>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<template #footer>
|
|
||||||
<UiButton variant="danger">
|
|
||||||
<template #leading><UiIcon name="trash" :size="13" /></template>
|
|
||||||
Delete group
|
|
||||||
</UiButton>
|
|
||||||
<div style="flex: 1" />
|
|
||||||
<UiButton variant="primary" @click="openGroup = null">Save changes</UiButton>
|
|
||||||
</template>
|
</template>
|
||||||
</SidePanel>
|
</SidePanel>
|
||||||
|
|
||||||
@@ -578,26 +366,6 @@ Mikkel Sørensen,mikkel@baslund.dk,admin,Engineering,business</pre>
|
|||||||
</template>
|
</template>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
<!-- Bulk · assign group -->
|
|
||||||
<Modal :open="assignGroupOpen" :eyebrow="`${selected.size} selected`" title="Add to groups" size="md" @close="assignGroupOpen = false">
|
|
||||||
<div class="form-stack">
|
|
||||||
<Eyebrow>Pick one or more groups</Eyebrow>
|
|
||||||
<div class="check-stack">
|
|
||||||
<label v-for="g in ['Engineering', 'Design', 'Operations', 'Finance', 'Sales', 'Leadership']" :key="g">
|
|
||||||
<input type="checkbox" :checked="groupChoice.has(g)" @change="toggleGroup(g)" />
|
|
||||||
{{ g }}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<template #footer>
|
|
||||||
<UiButton variant="ghost" @click="assignGroupOpen = false">Cancel</UiButton>
|
|
||||||
<UiButton variant="primary" :disabled="groupChoice.size === 0" @click="applyBulkGroup">
|
|
||||||
<template #leading><UiIcon name="check" :size="13" /></template>
|
|
||||||
Add to groups
|
|
||||||
</UiButton>
|
|
||||||
</template>
|
|
||||||
</Modal>
|
|
||||||
|
|
||||||
<!-- Bulk · change role -->
|
<!-- Bulk · change role -->
|
||||||
<Modal :open="changeRoleOpen" :eyebrow="`${selected.size} selected`" title="Change role" size="md" @close="changeRoleOpen = false">
|
<Modal :open="changeRoleOpen" :eyebrow="`${selected.size} selected`" title="Change role" size="md" @close="changeRoleOpen = false">
|
||||||
<div class="form-stack">
|
<div class="form-stack">
|
||||||
@@ -632,19 +400,6 @@ Mikkel Sørensen,mikkel@baslund.dk,admin,Engineering,business</pre>
|
|||||||
>
|
>
|
||||||
Sign-in will be blocked across mail, files, chat, and meetings. Data is preserved; you can re-enable any time.
|
Sign-in will be blocked across mail, files, chat, and meetings. Data is preserved; you can re-enable any time.
|
||||||
</ConfirmDialog>
|
</ConfirmDialog>
|
||||||
|
|
||||||
<!-- Create group modal -->
|
|
||||||
<Modal :open="createGroupOpen" eyebrow="Groups" title="New group" size="md" @close="createGroupOpen = false">
|
|
||||||
<div class="form-stack">
|
|
||||||
<label class="field"><Eyebrow>Group name</Eyebrow><input class="input" placeholder="Engineering" /></label>
|
|
||||||
<label class="field"><Eyebrow>Mail alias</Eyebrow><input class="input" placeholder="eng@dezky.com" /></label>
|
|
||||||
<label class="field"><Eyebrow>Description</Eyebrow><input class="input" placeholder="Product engineering team" /></label>
|
|
||||||
</div>
|
|
||||||
<template #footer>
|
|
||||||
<UiButton variant="ghost" @click="createGroupOpen = false">Cancel</UiButton>
|
|
||||||
<UiButton variant="primary" @click="createGroupOpen = false; toast.ok('Group created')">Create group</UiButton>
|
|
||||||
</template>
|
|
||||||
</Modal>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -665,21 +420,6 @@ Mikkel Sørensen,mikkel@baslund.dk,admin,Engineering,business</pre>
|
|||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
}
|
}
|
||||||
.input-search input { flex: 1; border: none; outline: none; background: transparent; font-family: inherit; font-size: 13px; color: var(--text); }
|
.input-search input { flex: 1; border: none; outline: none; background: transparent; font-family: inherit; font-size: 13px; color: var(--text); }
|
||||||
.chip {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
height: 36px;
|
|
||||||
padding: 0 12px;
|
|
||||||
background: var(--surface);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 13px;
|
|
||||||
font-family: inherit;
|
|
||||||
color: var(--text);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.chip span { font-weight: 500; }
|
|
||||||
.spacer { flex: 1; }
|
.spacer { flex: 1; }
|
||||||
|
|
||||||
.bulk {
|
.bulk {
|
||||||
@@ -719,26 +459,12 @@ Mikkel Sørensen,mikkel@baslund.dk,admin,Engineering,business</pre>
|
|||||||
.users-tbl tr:last-child td { border-bottom: none; }
|
.users-tbl tr:last-child td { border-bottom: none; }
|
||||||
.users-tbl .right { text-align: right; }
|
.users-tbl .right { text-align: right; }
|
||||||
.users-tbl .check { width: 36px; }
|
.users-tbl .check { width: 36px; }
|
||||||
|
.users-tbl tr.no-hover { cursor: default; }
|
||||||
|
.users-tbl tr.no-hover:hover { background: transparent; }
|
||||||
|
.empty-row { padding: 40px 16px; text-align: center; }
|
||||||
|
|
||||||
.name-cell { display: flex; align-items: center; gap: 12px; }
|
.name-cell { display: flex; align-items: center; gap: 12px; }
|
||||||
.name-cell.small { gap: 8px; }
|
|
||||||
.u-name { font-weight: 500; font-size: 13px; }
|
.u-name { font-weight: 500; font-size: 13px; }
|
||||||
.group-text { font-family: var(--font-mono); font-size: 12px; color: var(--text-dim); }
|
|
||||||
.member-cell { display: flex; align-items: center; gap: 6px; }
|
|
||||||
.g-icon {
|
|
||||||
width: 28px;
|
|
||||||
height: 28px;
|
|
||||||
border-radius: 6px;
|
|
||||||
background: var(--bg);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
color: var(--text-mute);
|
|
||||||
}
|
|
||||||
.g-icon.big { width: 44px; height: 44px; border-radius: 10px; color: var(--text-dim); border: 1px solid var(--border); }
|
|
||||||
|
|
||||||
.pager { display: flex; justify-content: space-between; align-items: center; margin-top: 16px; font-size: 12px; color: var(--text-mute); }
|
|
||||||
.pager-btns { display: flex; gap: 4px; }
|
|
||||||
|
|
||||||
.empty-card {
|
.empty-card {
|
||||||
padding: 60px 24px;
|
padding: 60px 24px;
|
||||||
@@ -766,40 +492,6 @@ Mikkel Sørensen,mikkel@baslund.dk,admin,Engineering,business</pre>
|
|||||||
.def dt { font-family: var(--font-mono); font-size: 10px; letter-spacing: 0.12em; text-transform: uppercase; color: var(--text-mute); }
|
.def dt { font-family: var(--font-mono); font-size: 10px; letter-spacing: 0.12em; text-transform: uppercase; color: var(--text-mute); }
|
||||||
.def dd { margin: 0; font-size: 13px; color: var(--text); }
|
.def dd { margin: 0; font-size: 13px; color: var(--text); }
|
||||||
|
|
||||||
.sub-head { font-size: 13px; font-weight: 600; margin: 24px 0 8px; }
|
|
||||||
.dev-row {
|
|
||||||
padding: 12px 14px;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 6px;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
.dev-meta { flex: 1; }
|
|
||||||
.dev-d { font-size: 13px; font-weight: 500; }
|
|
||||||
|
|
||||||
.alias-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 8px 0;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.activity-list { font-family: var(--font-mono); font-size: 12px; }
|
|
||||||
.activity-row {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 80px 1fr auto;
|
|
||||||
gap: 12px;
|
|
||||||
padding: 10px 0;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
.activity-action { color: var(--text); }
|
|
||||||
|
|
||||||
.empty-tab { text-align: center; padding: 60px 20px; }
|
|
||||||
|
|
||||||
/* Invite modal */
|
/* Invite modal */
|
||||||
.form-stack { display: flex; flex-direction: column; gap: 14px; }
|
.form-stack { display: flex; flex-direction: column; gap: 14px; }
|
||||||
.field { display: flex; flex-direction: column; gap: 6px; }
|
.field { display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import type { EmailTemplate } from '~/components/partner/EmailTemplateEditor.vue
|
|||||||
import type { BrandIdentity } from '~/components/partner/EditIdentityModal.vue'
|
import type { BrandIdentity } from '~/components/partner/EditIdentityModal.vue'
|
||||||
|
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
|
const { request } = useApiFetch()
|
||||||
|
|
||||||
const identityOpen = ref(false)
|
const identityOpen = ref(false)
|
||||||
const editing = ref<EmailTemplate | null>(null)
|
const editing = ref<EmailTemplate | null>(null)
|
||||||
@@ -74,7 +75,7 @@ watch(branding, syncBranding)
|
|||||||
|
|
||||||
async function putBranding(): Promise<boolean> {
|
async function putBranding(): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
await $fetch('/api/partner/branding', {
|
await request('/api/partner/branding', {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: {
|
body: {
|
||||||
identity: identity.value,
|
identity: identity.value,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import type { CustomerOrg, CustomerStatus, PartnerTenantDoc } from '~/types/part
|
|||||||
|
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const { request } = useApiFetch()
|
||||||
const partnerMode = usePartnerMode()
|
const partnerMode = usePartnerMode()
|
||||||
|
|
||||||
const view = ref<'table' | 'cards'>('table')
|
const view = ref<'table' | 'cards'>('table')
|
||||||
@@ -181,7 +182,7 @@ async function saveEdit() {
|
|||||||
}
|
}
|
||||||
savingEdit.value = true
|
savingEdit.value = true
|
||||||
try {
|
try {
|
||||||
await $fetch(`/api/partner/tenants/${editCustomer.value.slug}`, {
|
await request(`/api/partner/tenants/${editCustomer.value.slug}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
body: {
|
body: {
|
||||||
name: editForm.name,
|
name: editForm.name,
|
||||||
@@ -204,7 +205,7 @@ async function saveEdit() {
|
|||||||
async function toggleSuspend(c: CustomerRow) {
|
async function toggleSuspend(c: CustomerRow) {
|
||||||
const action = c.status === 'suspended' ? 'resume' : 'suspend'
|
const action = c.status === 'suspended' ? 'resume' : 'suspend'
|
||||||
try {
|
try {
|
||||||
await $fetch(`/api/partner/tenants/${c.slug}/${action}`, { method: 'POST' })
|
await request(`/api/partner/tenants/${c.slug}/${action}`, { method: 'POST' })
|
||||||
toast.ok(action === 'suspend' ? 'Suspended' : 'Resumed', c.name)
|
toast.ok(action === 'suspend' ? 'Suspended' : 'Resumed', c.name)
|
||||||
editCustomer.value = null
|
editCustomer.value = null
|
||||||
await refreshAll()
|
await refreshAll()
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import type { CustomerOrg, CustomerStatus } from '~/types/partner'
|
|||||||
import type { TaskContext } from '~/components/partner/CustomerTaskPanel.vue'
|
import type { TaskContext } from '~/components/partner/CustomerTaskPanel.vue'
|
||||||
|
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
|
const { request } = useApiFetch()
|
||||||
|
|
||||||
// Decorative MRR sparkline shape only — historical MRR isn't stored yet (a
|
// Decorative MRR sparkline shape only — historical MRR isn't stored yet (a
|
||||||
// daily-snapshot job lands later; see useMrrTrendline). The live numbers
|
// daily-snapshot job lands later; see useMrrTrendline). The live numbers
|
||||||
@@ -262,7 +263,7 @@ async function deleteReport() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await $fetch(`/api/partner/reports/saved/${r.id}`, { method: 'DELETE' })
|
await request(`/api/partner/reports/saved/${r.id}`, { method: 'DELETE' })
|
||||||
toast.bad('Report deleted', r.name)
|
toast.bad('Report deleted', r.name)
|
||||||
confirmDeleteId.value = null
|
confirmDeleteId.value = null
|
||||||
await Promise.all([refreshSaved(), refreshNuxtData('partner-reports-saved')])
|
await Promise.all([refreshSaved(), refreshNuxtData('partner-reports-saved')])
|
||||||
@@ -284,7 +285,7 @@ async function onCreated(payload: {
|
|||||||
format: string
|
format: string
|
||||||
}) {
|
}) {
|
||||||
try {
|
try {
|
||||||
await $fetch('/api/partner/reports/saved', {
|
await request('/api/partner/reports/saved', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: {
|
body: {
|
||||||
name: payload.name,
|
name: payload.name,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
|
|
||||||
|
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
|
const { request } = useApiFetch()
|
||||||
|
|
||||||
const tab = ref<'agreement' | 'contact' | 'tax' | 'notifications'>('agreement')
|
const tab = ref<'agreement' | 'contact' | 'tax' | 'notifications'>('agreement')
|
||||||
const tabs = [
|
const tabs = [
|
||||||
@@ -93,7 +94,7 @@ watch(settings, syncContact)
|
|||||||
async function saveContact() {
|
async function saveContact() {
|
||||||
savingContact.value = true
|
savingContact.value = true
|
||||||
try {
|
try {
|
||||||
await $fetch('/api/partner/settings', { method: 'PATCH', body: { profile: { ...contact } } })
|
await request('/api/partner/settings', { method: 'PATCH', body: { profile: { ...contact } } })
|
||||||
toast.ok('Saved', 'Contact info updated')
|
toast.ok('Saved', 'Contact info updated')
|
||||||
await Promise.all([refresh(), refreshNuxtData('partner-settings')])
|
await Promise.all([refresh(), refreshNuxtData('partner-settings')])
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
import type { TeamMember } from '~/components/partner/TeammatePanel.vue'
|
import type { TeamMember } from '~/components/partner/TeammatePanel.vue'
|
||||||
|
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
|
const { request } = useApiFetch()
|
||||||
|
|
||||||
const inviteOpen = ref(false)
|
const inviteOpen = ref(false)
|
||||||
const openMember = ref<TeamMember | null>(null)
|
const openMember = ref<TeamMember | null>(null)
|
||||||
@@ -72,7 +73,7 @@ function accessLabel(m: TeamMember) {
|
|||||||
|
|
||||||
async function onSent(payload: { name: string; email: string; role: string }) {
|
async function onSent(payload: { name: string; email: string; role: string }) {
|
||||||
try {
|
try {
|
||||||
await $fetch('/api/partner/users', {
|
await request('/api/partner/users', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: { name: payload.name, email: payload.email },
|
body: { name: payload.name, email: payload.email },
|
||||||
})
|
})
|
||||||
@@ -86,7 +87,7 @@ async function onSent(payload: { name: string; email: string; role: string }) {
|
|||||||
|
|
||||||
async function removeMember(m: TeamMember) {
|
async function removeMember(m: TeamMember) {
|
||||||
try {
|
try {
|
||||||
await $fetch(`/api/partner/users/${m.id}`, { method: 'DELETE' })
|
await request(`/api/partner/users/${m.id}`, { method: 'DELETE' })
|
||||||
toast.ok('Removed', `${m.name} removed from the team`)
|
toast.ok('Removed', `${m.name} removed from the team`)
|
||||||
openMember.value = null
|
openMember.value = null
|
||||||
await Promise.all([refresh(), refreshNuxtData('partner-users')])
|
await Promise.all([refresh(), refreshNuxtData('partner-users')])
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
// Tenant-scoped audit slice for the customer-admin dashboard. Proxies
|
||||||
|
// GET /tenants/:slug/audit with the signed-in user's access token. The
|
||||||
|
// platform-api enforces tenant membership and filters strictly by tenantSlug.
|
||||||
|
|
||||||
|
import { getUserSession } from 'nuxt-oidc-auth/runtime/server/utils/session.js'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const session = await getUserSession(event).catch(() => null)
|
||||||
|
const accessToken = (session as { accessToken?: string } | null)?.accessToken
|
||||||
|
if (!accessToken) {
|
||||||
|
throw createError({ statusCode: 401, statusMessage: 'Not signed in' })
|
||||||
|
}
|
||||||
|
const slug = getRouterParam(event, 'slug')
|
||||||
|
const { limit } = getQuery(event)
|
||||||
|
const base = process.env.PLATFORM_API_INTERNAL_URL ?? 'http://platform-api:3001'
|
||||||
|
return $fetch(`${base}/tenants/${slug}/audit`, {
|
||||||
|
headers: { Authorization: `Bearer ${accessToken}` },
|
||||||
|
query: limit ? { limit } : undefined,
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
// Update the tenant's company/tax details. Proxies PATCH
|
||||||
|
// /tenants/:slug/billing-info (narrow — billingInfo only); platform-api
|
||||||
|
// enforces tenant membership.
|
||||||
|
|
||||||
|
import { getUserSession } from 'nuxt-oidc-auth/runtime/server/utils/session.js'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const session = await getUserSession(event).catch(() => null)
|
||||||
|
const accessToken = (session as { accessToken?: string } | null)?.accessToken
|
||||||
|
if (!accessToken) {
|
||||||
|
throw createError({ statusCode: 401, statusMessage: 'Not signed in' })
|
||||||
|
}
|
||||||
|
const slug = getRouterParam(event, 'slug')
|
||||||
|
const body = await readBody(event)
|
||||||
|
const base = process.env.PLATFORM_API_INTERNAL_URL ?? 'http://platform-api:3001'
|
||||||
|
return $fetch(`${base}/tenants/${slug}/billing-info`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { Authorization: `Bearer ${accessToken}` },
|
||||||
|
body,
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
// Whitelabel branding (name, accent, email-template overrides) for the
|
||||||
|
// customer-admin branding page. Proxies GET /tenants/:slug/branding;
|
||||||
|
// platform-api enforces tenant membership.
|
||||||
|
|
||||||
|
import { getUserSession } from 'nuxt-oidc-auth/runtime/server/utils/session.js'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const session = await getUserSession(event).catch(() => null)
|
||||||
|
const accessToken = (session as { accessToken?: string } | null)?.accessToken
|
||||||
|
if (!accessToken) {
|
||||||
|
throw createError({ statusCode: 401, statusMessage: 'Not signed in' })
|
||||||
|
}
|
||||||
|
const slug = getRouterParam(event, 'slug')
|
||||||
|
const base = process.env.PLATFORM_API_INTERNAL_URL ?? 'http://platform-api:3001'
|
||||||
|
return $fetch(`${base}/tenants/${slug}/branding`, {
|
||||||
|
headers: { Authorization: `Bearer ${accessToken}` },
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
// Save whitelabel branding. Proxies PUT /tenants/:slug/branding with
|
||||||
|
// { name?, brandColor?, emailTemplates? }; platform-api enforces membership.
|
||||||
|
|
||||||
|
import { getUserSession } from 'nuxt-oidc-auth/runtime/server/utils/session.js'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const session = await getUserSession(event).catch(() => null)
|
||||||
|
const accessToken = (session as { accessToken?: string } | null)?.accessToken
|
||||||
|
if (!accessToken) {
|
||||||
|
throw createError({ statusCode: 401, statusMessage: 'Not signed in' })
|
||||||
|
}
|
||||||
|
const slug = getRouterParam(event, 'slug')
|
||||||
|
const body = await readBody(event)
|
||||||
|
const base = process.env.PLATFORM_API_INTERNAL_URL ?? 'http://platform-api:3001'
|
||||||
|
return $fetch(`${base}/tenants/${slug}/branding`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { Authorization: `Bearer ${accessToken}` },
|
||||||
|
body,
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
// Tenant-scoped invoice history for the customer-admin billing page. Proxies
|
||||||
|
// GET /tenants/:slug/invoices with the signed-in user's access token;
|
||||||
|
// platform-api enforces tenant membership.
|
||||||
|
|
||||||
|
import { getUserSession } from 'nuxt-oidc-auth/runtime/server/utils/session.js'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const session = await getUserSession(event).catch(() => null)
|
||||||
|
const accessToken = (session as { accessToken?: string } | null)?.accessToken
|
||||||
|
if (!accessToken) {
|
||||||
|
throw createError({ statusCode: 401, statusMessage: 'Not signed in' })
|
||||||
|
}
|
||||||
|
const slug = getRouterParam(event, 'slug')
|
||||||
|
const base = process.env.PLATFORM_API_INTERNAL_URL ?? 'http://platform-api:3001'
|
||||||
|
return $fetch(`${base}/tenants/${slug}/invoices`, {
|
||||||
|
headers: { Authorization: `Bearer ${accessToken}` },
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
// Finish a card update: set the confirmed payment method as default. Proxies
|
||||||
|
// POST /tenants/:slug/payment-method/default with { paymentMethodId }.
|
||||||
|
|
||||||
|
import { getUserSession } from 'nuxt-oidc-auth/runtime/server/utils/session.js'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const session = await getUserSession(event).catch(() => null)
|
||||||
|
const accessToken = (session as { accessToken?: string } | null)?.accessToken
|
||||||
|
if (!accessToken) {
|
||||||
|
throw createError({ statusCode: 401, statusMessage: 'Not signed in' })
|
||||||
|
}
|
||||||
|
const slug = getRouterParam(event, 'slug')
|
||||||
|
const body = await readBody(event)
|
||||||
|
const base = process.env.PLATFORM_API_INTERNAL_URL ?? 'http://platform-api:3001'
|
||||||
|
return $fetch(`${base}/tenants/${slug}/payment-method/default`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: `Bearer ${accessToken}` },
|
||||||
|
body,
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
// The card on file for a tenant (or null). Proxies GET
|
||||||
|
// /tenants/:slug/payment-method; platform-api enforces tenant membership.
|
||||||
|
|
||||||
|
import { getUserSession } from 'nuxt-oidc-auth/runtime/server/utils/session.js'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const session = await getUserSession(event).catch(() => null)
|
||||||
|
const accessToken = (session as { accessToken?: string } | null)?.accessToken
|
||||||
|
if (!accessToken) {
|
||||||
|
throw createError({ statusCode: 401, statusMessage: 'Not signed in' })
|
||||||
|
}
|
||||||
|
const slug = getRouterParam(event, 'slug')
|
||||||
|
const base = process.env.PLATFORM_API_INTERNAL_URL ?? 'http://platform-api:3001'
|
||||||
|
return $fetch(`${base}/tenants/${slug}/payment-method`, {
|
||||||
|
headers: { Authorization: `Bearer ${accessToken}` },
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
// Start a card update: returns { clientSecret, publishableKey } for the portal
|
||||||
|
// to mount Stripe Elements. Proxies POST /tenants/:slug/payment-method/setup-intent.
|
||||||
|
|
||||||
|
import { getUserSession } from 'nuxt-oidc-auth/runtime/server/utils/session.js'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const session = await getUserSession(event).catch(() => null)
|
||||||
|
const accessToken = (session as { accessToken?: string } | null)?.accessToken
|
||||||
|
if (!accessToken) {
|
||||||
|
throw createError({ statusCode: 401, statusMessage: 'Not signed in' })
|
||||||
|
}
|
||||||
|
const slug = getRouterParam(event, 'slug')
|
||||||
|
const base = process.env.PLATFORM_API_INTERNAL_URL ?? 'http://platform-api:3001'
|
||||||
|
return $fetch(`${base}/tenants/${slug}/payment-method/setup-intent`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: `Bearer ${accessToken}` },
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
// Workspace user list for the customer-admin surface (seat-usage count on the
|
||||||
|
// dashboard, the Users & groups page). Proxies GET /tenants/:slug/users with
|
||||||
|
// the signed-in user's access token; platform-api enforces tenant membership.
|
||||||
|
|
||||||
|
import { getUserSession } from 'nuxt-oidc-auth/runtime/server/utils/session.js'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const session = await getUserSession(event).catch(() => null)
|
||||||
|
const accessToken = (session as { accessToken?: string } | null)?.accessToken
|
||||||
|
if (!accessToken) {
|
||||||
|
throw createError({ statusCode: 401, statusMessage: 'Not signed in' })
|
||||||
|
}
|
||||||
|
const slug = getRouterParam(event, 'slug')
|
||||||
|
const base = process.env.PLATFORM_API_INTERNAL_URL ?? 'http://platform-api:3001'
|
||||||
|
return $fetch(`${base}/tenants/${slug}/users`, {
|
||||||
|
headers: { Authorization: `Bearer ${accessToken}` },
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
// Shared customer-workspace (tenant) domain types for the /admin surface.
|
||||||
|
// These mirror the platform-api schemas (Tenant, Subscription, AuditEvent) as
|
||||||
|
// they serialize over /api/me and /api/tenants/:slug/*. View-models the admin
|
||||||
|
// pages bind to are derived from these in the page/composable layer.
|
||||||
|
|
||||||
|
export type TenantStatus = 'pending' | 'active' | 'suspended' | 'deleted'
|
||||||
|
export type PlanKey = 'mvp' | 'pro' | 'enterprise'
|
||||||
|
|
||||||
|
// Company/tax info stored on the tenant for invoicing.
|
||||||
|
export interface TenantBillingInfo {
|
||||||
|
companyName?: string
|
||||||
|
vatId?: string
|
||||||
|
country?: string
|
||||||
|
contactEmail?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// A tenant as returned by GET /tenants (findByIds for the signed-in user).
|
||||||
|
export interface TenantDoc {
|
||||||
|
_id: string
|
||||||
|
slug: string
|
||||||
|
name: string
|
||||||
|
status: TenantStatus
|
||||||
|
plan?: PlanKey
|
||||||
|
// Seat count carried on the tenant (portfolio displays). The billed seat
|
||||||
|
// limit lives on the Subscription; prefer that for license math.
|
||||||
|
seats?: number
|
||||||
|
domains?: string[]
|
||||||
|
industry?: string
|
||||||
|
brandColor?: string
|
||||||
|
billingInfo?: TenantBillingInfo
|
||||||
|
createdAt?: string
|
||||||
|
updatedAt?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SubscriptionStatus =
|
||||||
|
| 'trialing'
|
||||||
|
| 'active'
|
||||||
|
| 'past_due'
|
||||||
|
| 'canceled'
|
||||||
|
| 'incomplete'
|
||||||
|
| 'incomplete_expired'
|
||||||
|
|
||||||
|
// A subscription as returned by GET /subscriptions (one per tenant).
|
||||||
|
export interface SubscriptionDoc {
|
||||||
|
_id: string
|
||||||
|
tenantId: string
|
||||||
|
plan: PlanKey
|
||||||
|
cycle: 'monthly' | 'quarterly' | 'yearly'
|
||||||
|
currency: 'DKK' | 'EUR' | 'USD'
|
||||||
|
// Per-seat amount in `currency`, snapshotted at provision time. Plain number.
|
||||||
|
perSeatAmount: number
|
||||||
|
// Billed seat count (the license limit).
|
||||||
|
seats: number
|
||||||
|
status: SubscriptionStatus
|
||||||
|
currentPeriodEnd?: string
|
||||||
|
trialEndsAt?: string
|
||||||
|
canceledAt?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// A user row as returned by GET /tenants/:slug/users (UserDocument). Only the
|
||||||
|
// fields the customer-admin surface actually renders are typed here.
|
||||||
|
export interface TenantUserDoc {
|
||||||
|
_id: string
|
||||||
|
email: string
|
||||||
|
name: string
|
||||||
|
role: 'owner' | 'admin' | 'member'
|
||||||
|
active: boolean
|
||||||
|
lastLoginAt?: string
|
||||||
|
createdAt?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// The card on file, as returned by GET /tenants/:slug/payment-method (null when
|
||||||
|
// none). Card data lives in Stripe — we only ever surface these display bits.
|
||||||
|
export interface PaymentMethodCard {
|
||||||
|
brand: string
|
||||||
|
last4: string
|
||||||
|
expMonth: number
|
||||||
|
expYear: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// An invoice as returned by GET /tenants/:slug/invoices. Amounts in MINOR units.
|
||||||
|
export interface InvoiceDoc {
|
||||||
|
_id: string
|
||||||
|
number?: string
|
||||||
|
currency: 'DKK' | 'EUR' | 'USD'
|
||||||
|
amountDue: number
|
||||||
|
amountPaid: number
|
||||||
|
status: 'draft' | 'open' | 'paid' | 'void' | 'uncollectible' | 'past_due'
|
||||||
|
periodStart?: string
|
||||||
|
periodEnd?: string
|
||||||
|
hostedInvoiceUrl?: string
|
||||||
|
pdfUrl?: string
|
||||||
|
createdAt?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// An email-template override (customer's edited copy of a template).
|
||||||
|
export interface EmailTemplateOverride {
|
||||||
|
key: string
|
||||||
|
subject: string
|
||||||
|
body: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Whitelabel branding as returned by GET /tenants/:slug/branding. name +
|
||||||
|
// brandColor come from the Tenant doc; emailTemplates are the saved overrides.
|
||||||
|
export interface TenantBrandingView {
|
||||||
|
name: string
|
||||||
|
brandColor?: string
|
||||||
|
primaryDomain?: string
|
||||||
|
emailTemplates: EmailTemplateOverride[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// A raw audit event as returned by GET /tenants/:slug/audit.
|
||||||
|
export interface AuditEventDoc {
|
||||||
|
_id: string
|
||||||
|
at: string
|
||||||
|
actorType: 'user' | 'system'
|
||||||
|
actorEmail?: string
|
||||||
|
action: string
|
||||||
|
outcome: 'success' | 'failure'
|
||||||
|
resourceType?: string
|
||||||
|
resourceId?: string
|
||||||
|
resourceName?: string
|
||||||
|
}
|
||||||
@@ -590,6 +590,9 @@ services:
|
|||||||
# marginPct). Values come from the gitignored root .env. Webhook secret is
|
# marginPct). Values come from the gitignored root .env. Webhook secret is
|
||||||
# only needed once the signature-verified /stripe/webhook path goes live.
|
# only needed once the signature-verified /stripe/webhook path goes live.
|
||||||
STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY:-}
|
STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY:-}
|
||||||
|
# Publishable key is safe to expose to the browser — the portal fetches it
|
||||||
|
# from platform-api (via the setup-intent response) to mount Stripe Elements.
|
||||||
|
STRIPE_PUBLISHABLE_KEY: ${STRIPE_PUBLISHABLE_KEY:-}
|
||||||
STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET:-}
|
STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET:-}
|
||||||
BILLING_STRIPE_ENABLED: ${BILLING_STRIPE_ENABLED:-false}
|
BILLING_STRIPE_ENABLED: ${BILLING_STRIPE_ENABLED:-false}
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { Subscription, SubscriptionSchema } from '../schemas/subscription.schema
|
|||||||
import { Tenant, TenantSchema } from '../schemas/tenant.schema.js'
|
import { Tenant, TenantSchema } from '../schemas/tenant.schema.js'
|
||||||
import { UsersModule } from '../users/users.module.js'
|
import { UsersModule } from '../users/users.module.js'
|
||||||
import { BillingService } from './billing.service.js'
|
import { BillingService } from './billing.service.js'
|
||||||
|
import { CustomerBillingController } from './customer-billing.controller.js'
|
||||||
import { OperatorBillingController } from './operator-billing.controller.js'
|
import { OperatorBillingController } from './operator-billing.controller.js'
|
||||||
import { PartnerBillingController } from './partner-billing.controller.js'
|
import { PartnerBillingController } from './partner-billing.controller.js'
|
||||||
import { PayoutWorker } from './payout.worker.js'
|
import { PayoutWorker } from './payout.worker.js'
|
||||||
@@ -29,7 +30,12 @@ import { StripeWebhookController } from './stripe-webhook.controller.js'
|
|||||||
IntegrationsModule,
|
IntegrationsModule,
|
||||||
UsersModule,
|
UsersModule,
|
||||||
],
|
],
|
||||||
controllers: [PartnerBillingController, OperatorBillingController, StripeWebhookController],
|
controllers: [
|
||||||
|
PartnerBillingController,
|
||||||
|
OperatorBillingController,
|
||||||
|
CustomerBillingController,
|
||||||
|
StripeWebhookController,
|
||||||
|
],
|
||||||
providers: [BillingService, PayoutWorker],
|
providers: [BillingService, PayoutWorker],
|
||||||
})
|
})
|
||||||
export class BillingModule {}
|
export class BillingModule {}
|
||||||
|
|||||||
@@ -1,4 +1,10 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common'
|
import {
|
||||||
|
ForbiddenException,
|
||||||
|
Injectable,
|
||||||
|
Logger,
|
||||||
|
NotFoundException,
|
||||||
|
ServiceUnavailableException,
|
||||||
|
} from '@nestjs/common'
|
||||||
import { InjectModel } from '@nestjs/mongoose'
|
import { InjectModel } from '@nestjs/mongoose'
|
||||||
import type Stripe from 'stripe'
|
import type Stripe from 'stripe'
|
||||||
import { Model, Types } from 'mongoose'
|
import { Model, Types } from 'mongoose'
|
||||||
@@ -186,6 +192,102 @@ export class BillingService {
|
|||||||
return this.tenantInvoices(tenant._id)
|
return this.tenantInvoices(tenant._id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Membership-gated variant for the customer-admin portal: only a member of
|
||||||
|
// the tenant (or a platform admin) may read its invoices. Mirrors the
|
||||||
|
// membership check used across the tenant-scoped portal endpoints.
|
||||||
|
async tenantInvoicesForActor(
|
||||||
|
slug: string,
|
||||||
|
actor: { platformAdmin: boolean; tenantIds: Types.ObjectId[] },
|
||||||
|
): Promise<InvoiceDocument[]> {
|
||||||
|
const tenant = await this.tenantModel.findOne({ slug }, { _id: 1 }).exec()
|
||||||
|
if (!tenant) return []
|
||||||
|
if (!actor.platformAdmin && !actor.tenantIds.some((id) => id.equals(tenant._id))) {
|
||||||
|
throw new ForbiddenException(`No access to tenant "${slug}"`)
|
||||||
|
}
|
||||||
|
return this.tenantInvoices(tenant._id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Customer payment method (card on file) ────────────────────────────────
|
||||||
|
|
||||||
|
// Load the tenant + its subscription, enforcing membership. Throws 404 if the
|
||||||
|
// tenant is unknown, 403 if the caller isn't a member.
|
||||||
|
private async resolveTenantSub(
|
||||||
|
slug: string,
|
||||||
|
actor: { platformAdmin: boolean; tenantIds: Types.ObjectId[] },
|
||||||
|
): Promise<{ tenant: TenantDocument; sub: SubscriptionDocument | null }> {
|
||||||
|
const tenant = await this.tenantModel.findOne({ slug }).exec()
|
||||||
|
if (!tenant) throw new NotFoundException(`Tenant "${slug}" not found`)
|
||||||
|
if (!actor.platformAdmin && !actor.tenantIds.some((id) => id.equals(tenant._id))) {
|
||||||
|
throw new ForbiddenException(`No access to tenant "${slug}"`)
|
||||||
|
}
|
||||||
|
const sub = await this.subModel.findOne({ tenantId: tenant._id }).exec()
|
||||||
|
return { tenant, sub }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure the tenant's subscription has a Stripe customer, creating one on
|
||||||
|
// demand (older tenants provisioned before Stripe was enabled lack it).
|
||||||
|
private async ensureStripeCustomer(tenant: TenantDocument, sub: SubscriptionDocument | null): Promise<string> {
|
||||||
|
if (!this.stripe.enabled) throw new ServiceUnavailableException('Billing is not enabled')
|
||||||
|
if (!sub) throw new NotFoundException(`No subscription for tenant "${tenant.slug}"`)
|
||||||
|
if (sub.stripeCustomerId) return sub.stripeCustomerId
|
||||||
|
const customerId = await this.stripe.createCustomer({
|
||||||
|
name: tenant.name,
|
||||||
|
email: tenant.billingInfo?.contactEmail,
|
||||||
|
metadata: { tenantId: String(tenant._id), slug: tenant.slug },
|
||||||
|
})
|
||||||
|
sub.stripeCustomerId = customerId
|
||||||
|
await sub.save()
|
||||||
|
return customerId
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read-only: the card on file, or null. Doesn't create a customer just to read.
|
||||||
|
async getPaymentMethod(
|
||||||
|
slug: string,
|
||||||
|
actor: { platformAdmin: boolean; tenantIds: Types.ObjectId[] },
|
||||||
|
): Promise<{ brand: string; last4: string; expMonth: number; expYear: number } | null> {
|
||||||
|
if (!this.stripe.enabled) return null
|
||||||
|
const { sub } = await this.resolveTenantSub(slug, actor)
|
||||||
|
if (!sub?.stripeCustomerId) return null
|
||||||
|
return this.stripe.getDefaultCard(sub.stripeCustomerId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Begin a card-update: returns the SetupIntent secret + publishable key for
|
||||||
|
// the portal to mount Stripe Elements. Creates a Stripe customer if missing.
|
||||||
|
async createPaymentSetupIntent(
|
||||||
|
slug: string,
|
||||||
|
actor: { platformAdmin: boolean; tenantIds: Types.ObjectId[] },
|
||||||
|
): Promise<{ clientSecret: string; publishableKey: string }> {
|
||||||
|
const { tenant, sub } = await this.resolveTenantSub(slug, actor)
|
||||||
|
const customerId = await this.ensureStripeCustomer(tenant, sub)
|
||||||
|
const clientSecret = await this.stripe.createSetupIntent(customerId)
|
||||||
|
return { clientSecret, publishableKey: this.stripe.publishableKey ?? '' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Finish a card-update: make the newly-attached PM the default and return the
|
||||||
|
// resulting card so the UI can refresh without a second round-trip.
|
||||||
|
async setDefaultPaymentMethod(
|
||||||
|
slug: string,
|
||||||
|
actor: { platformAdmin: boolean; tenantIds: Types.ObjectId[] },
|
||||||
|
paymentMethodId: string,
|
||||||
|
auditActor?: AuditActor,
|
||||||
|
): Promise<{ brand: string; last4: string; expMonth: number; expYear: number } | null> {
|
||||||
|
const { tenant, sub } = await this.resolveTenantSub(slug, actor)
|
||||||
|
const customerId = await this.ensureStripeCustomer(tenant, sub)
|
||||||
|
await this.stripe.setDefaultCard(customerId, paymentMethodId, sub?.stripeSubscriptionId)
|
||||||
|
void this.audit.record(
|
||||||
|
{
|
||||||
|
action: 'billing.payment_method_updated',
|
||||||
|
resourceType: 'subscription',
|
||||||
|
resourceId: tenant.slug,
|
||||||
|
resourceName: tenant.name,
|
||||||
|
tenantSlug: tenant.slug,
|
||||||
|
source: 'portal',
|
||||||
|
},
|
||||||
|
auditActor,
|
||||||
|
)
|
||||||
|
return this.stripe.getDefaultCard(customerId)
|
||||||
|
}
|
||||||
|
|
||||||
// ── Stripe webhook ────────────────────────────────────────────────────────
|
// ── Stripe webhook ────────────────────────────────────────────────────────
|
||||||
// Idempotent upsert by stripeInvoiceId / subscription id. Only meaningful
|
// Idempotent upsert by stripeInvoiceId / subscription id. Only meaningful
|
||||||
// when Stripe is enabled; no-op acknowledgement otherwise.
|
// when Stripe is enabled; no-op acknowledgement otherwise.
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { BadRequestException, Body, Controller, Get, Param, Post, Req, UseGuards } from '@nestjs/common'
|
||||||
|
import { ActorService } from '../auth/actor.service.js'
|
||||||
|
import { clientIp } from '../auth/client-ip.js'
|
||||||
|
import { CurrentUser } from '../auth/current-user.decorator.js'
|
||||||
|
import { JwtAuthGuard } from '../auth/jwt-auth.guard.js'
|
||||||
|
import type { AuthentikJwtPayload } from '../auth/jwt-payload.interface.js'
|
||||||
|
import { BillingService } from './billing.service.js'
|
||||||
|
|
||||||
|
// Customer-admin billing reads, scoped to a single tenant the caller belongs
|
||||||
|
// to. Lives in BillingModule (not TenantsModule) to avoid a module cycle:
|
||||||
|
// TenantsModule → BillingModule → UsersModule → TenantsModule. Only
|
||||||
|
// JwtAuthGuard here (any portal user); per-tenant membership is enforced in
|
||||||
|
// BillingService.tenantInvoicesForActor.
|
||||||
|
@Controller('tenants')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
export class CustomerBillingController {
|
||||||
|
constructor(
|
||||||
|
private readonly billing: BillingService,
|
||||||
|
private readonly actor: ActorService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Get(':slug/invoices')
|
||||||
|
async invoices(@Param('slug') slug: string, @CurrentUser() jwt: AuthentikJwtPayload) {
|
||||||
|
const user = await this.actor.resolve(jwt)
|
||||||
|
return this.billing.tenantInvoicesForActor(slug, user)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The card currently on file (or null). Read-only.
|
||||||
|
@Get(':slug/payment-method')
|
||||||
|
async paymentMethod(@Param('slug') slug: string, @CurrentUser() jwt: AuthentikJwtPayload) {
|
||||||
|
const user = await this.actor.resolve(jwt)
|
||||||
|
return this.billing.getPaymentMethod(slug, user)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start a card update — returns the SetupIntent client secret + publishable
|
||||||
|
// key for the portal to confirm with Stripe Elements.
|
||||||
|
@Post(':slug/payment-method/setup-intent')
|
||||||
|
async setupIntent(@Param('slug') slug: string, @CurrentUser() jwt: AuthentikJwtPayload) {
|
||||||
|
const user = await this.actor.resolve(jwt)
|
||||||
|
return this.billing.createPaymentSetupIntent(slug, user)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Finish the update: set the confirmed payment method as the default.
|
||||||
|
@Post(':slug/payment-method/default')
|
||||||
|
async setDefault(
|
||||||
|
@Param('slug') slug: string,
|
||||||
|
@Body('paymentMethodId') paymentMethodId: string,
|
||||||
|
@CurrentUser() jwt: AuthentikJwtPayload,
|
||||||
|
@Req() req: Parameters<typeof clientIp>[0],
|
||||||
|
) {
|
||||||
|
if (!paymentMethodId) throw new BadRequestException('paymentMethodId is required')
|
||||||
|
const user = await this.actor.resolve(jwt)
|
||||||
|
return this.billing.setDefaultPaymentMethod(slug, user, paymentMethodId, {
|
||||||
|
userId: String(user._id),
|
||||||
|
email: user.email,
|
||||||
|
ip: clientIp(req),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,11 +12,15 @@ export class StripeClient {
|
|||||||
private readonly logger = new Logger(StripeClient.name)
|
private readonly logger = new Logger(StripeClient.name)
|
||||||
private readonly secretKey?: string
|
private readonly secretKey?: string
|
||||||
private readonly webhookSecret?: string
|
private readonly webhookSecret?: string
|
||||||
|
// Publishable key is safe to hand to the browser — it's how the portal mounts
|
||||||
|
// Stripe Elements. Exposed via getter so the billing endpoints can return it.
|
||||||
|
readonly publishableKey?: string
|
||||||
private _stripe?: Stripe
|
private _stripe?: Stripe
|
||||||
readonly enabled: boolean
|
readonly enabled: boolean
|
||||||
|
|
||||||
constructor(config: ConfigService) {
|
constructor(config: ConfigService) {
|
||||||
this.secretKey = config.get<string>('STRIPE_SECRET_KEY')
|
this.secretKey = config.get<string>('STRIPE_SECRET_KEY')
|
||||||
|
this.publishableKey = config.get<string>('STRIPE_PUBLISHABLE_KEY')
|
||||||
this.webhookSecret = config.get<string>('STRIPE_WEBHOOK_SECRET')
|
this.webhookSecret = config.get<string>('STRIPE_WEBHOOK_SECRET')
|
||||||
this.enabled = config.get<string>('BILLING_STRIPE_ENABLED') === 'true' && !!this.secretKey
|
this.enabled = config.get<string>('BILLING_STRIPE_ENABLED') === 'true' && !!this.secretKey
|
||||||
if (!this.enabled) {
|
if (!this.enabled) {
|
||||||
@@ -138,6 +142,59 @@ export class StripeClient {
|
|||||||
return res.data
|
return res.data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Payment methods (customer-admin "card on file" flow) ──────────────────
|
||||||
|
|
||||||
|
// The customer's default card, if any. Prefers the invoice-settings default;
|
||||||
|
// falls back to the first attached card. Returns null when none is on file.
|
||||||
|
async getDefaultCard(customerId: string): Promise<{
|
||||||
|
brand: string
|
||||||
|
last4: string
|
||||||
|
expMonth: number
|
||||||
|
expYear: number
|
||||||
|
} | null> {
|
||||||
|
const customer = await this.stripe.customers.retrieve(customerId, {
|
||||||
|
expand: ['invoice_settings.default_payment_method'],
|
||||||
|
})
|
||||||
|
if (customer.deleted) return null
|
||||||
|
|
||||||
|
let pm = customer.invoice_settings?.default_payment_method as Stripe.PaymentMethod | null
|
||||||
|
if (!pm || typeof pm === 'string') {
|
||||||
|
const list = await this.stripe.paymentMethods.list({ customer: customerId, type: 'card', limit: 1 })
|
||||||
|
pm = list.data[0] ?? null
|
||||||
|
}
|
||||||
|
if (!pm?.card) return null
|
||||||
|
return {
|
||||||
|
brand: pm.card.brand,
|
||||||
|
last4: pm.card.last4,
|
||||||
|
expMonth: pm.card.exp_month,
|
||||||
|
expYear: pm.card.exp_year,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetupIntent to collect a card off-session for future invoices. The portal
|
||||||
|
// confirms it client-side with Stripe Elements using the returned secret.
|
||||||
|
async createSetupIntent(customerId: string): Promise<string> {
|
||||||
|
const si = await this.stripe.setupIntents.create({
|
||||||
|
customer: customerId,
|
||||||
|
payment_method_types: ['card'],
|
||||||
|
usage: 'off_session',
|
||||||
|
})
|
||||||
|
if (!si.client_secret) throw new Error('SetupIntent has no client_secret')
|
||||||
|
return si.client_secret
|
||||||
|
}
|
||||||
|
|
||||||
|
// After a SetupIntent succeeds, make the new card the customer's default for
|
||||||
|
// invoices (and for the active subscription, if any). The PM is already
|
||||||
|
// attached to the customer by the SetupIntent.
|
||||||
|
async setDefaultCard(customerId: string, paymentMethodId: string, subscriptionId?: string): Promise<void> {
|
||||||
|
await this.stripe.customers.update(customerId, {
|
||||||
|
invoice_settings: { default_payment_method: paymentMethodId },
|
||||||
|
})
|
||||||
|
if (subscriptionId) {
|
||||||
|
await this.stripe.subscriptions.update(subscriptionId, { default_payment_method: paymentMethodId })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
constructWebhookEvent(rawBody: Buffer | string, signature: string): Stripe.Event {
|
constructWebhookEvent(rawBody: Buffer | string, signature: string): Stripe.Event {
|
||||||
if (!this.webhookSecret) throw new Error('Stripe webhook secret not configured')
|
if (!this.webhookSecret) throw new Error('Stripe webhook secret not configured')
|
||||||
return this.stripe.webhooks.constructEvent(rawBody, signature, this.webhookSecret)
|
return this.stripe.webhooks.constructEvent(rawBody, signature, this.webhookSecret)
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'
|
||||||
|
import { HydratedDocument, Types } from 'mongoose'
|
||||||
|
|
||||||
|
export type TenantBrandingDocument = HydratedDocument<TenantBranding>
|
||||||
|
|
||||||
|
// A single email-template override. `key` identifies the template
|
||||||
|
// ('invitation', 'reset', 'digest', 'trial'); subject/body are the customer's
|
||||||
|
// edited copy. Absence of a row for a key means "use the platform default",
|
||||||
|
// which lives in the portal (canonical defaults) — we only persist overrides.
|
||||||
|
@Schema({ _id: false })
|
||||||
|
export class EmailTemplateOverride {
|
||||||
|
@Prop({ required: true, trim: true })
|
||||||
|
key!: string
|
||||||
|
|
||||||
|
@Prop({ default: '' })
|
||||||
|
subject!: string
|
||||||
|
|
||||||
|
@Prop({ default: '' })
|
||||||
|
body!: string
|
||||||
|
}
|
||||||
|
const EmailTemplateOverrideSchema = SchemaFactory.createForClass(EmailTemplateOverride)
|
||||||
|
|
||||||
|
// Per-tenant whitelabel branding that doesn't fit on the Tenant doc itself.
|
||||||
|
// Name + brandColor live on Tenant (source of truth); this holds the email
|
||||||
|
// template overrides. Logos / custom-domain verification will join here once
|
||||||
|
// their pipelines exist. One doc per tenant, keyed by tenantId.
|
||||||
|
@Schema({ collection: 'tenant_branding', timestamps: true })
|
||||||
|
export class TenantBranding {
|
||||||
|
@Prop({ type: Types.ObjectId, ref: 'Tenant', required: true, unique: true, index: true })
|
||||||
|
tenantId!: Types.ObjectId
|
||||||
|
|
||||||
|
@Prop({ type: [EmailTemplateOverrideSchema], default: [] })
|
||||||
|
emailTemplates!: EmailTemplateOverride[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TenantBrandingSchema = SchemaFactory.createForClass(TenantBranding)
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { IsOptional, IsString, MaxLength } from 'class-validator'
|
||||||
|
|
||||||
|
// Customer-editable company/tax fields for invoicing. Deliberately narrow —
|
||||||
|
// this is the only tenant mutation a customer admin can perform from the
|
||||||
|
// portal, so it must not carry plan/status/partner fields. contactEmail is a
|
||||||
|
// plain string (not @IsEmail) so it can be cleared to '' without a 400.
|
||||||
|
export class UpdateBillingInfoDto {
|
||||||
|
@IsOptional() @IsString() @MaxLength(160)
|
||||||
|
companyName?: string
|
||||||
|
|
||||||
|
@IsOptional() @IsString() @MaxLength(64)
|
||||||
|
vatId?: string
|
||||||
|
|
||||||
|
@IsOptional() @IsString() @MaxLength(80)
|
||||||
|
country?: string
|
||||||
|
|
||||||
|
@IsOptional() @IsString() @MaxLength(160)
|
||||||
|
contactEmail?: string
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { Type } from 'class-transformer'
|
||||||
|
import {
|
||||||
|
IsArray,
|
||||||
|
IsHexColor,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
MaxLength,
|
||||||
|
MinLength,
|
||||||
|
ValidateNested,
|
||||||
|
} from 'class-validator'
|
||||||
|
|
||||||
|
export class EmailTemplateDto {
|
||||||
|
@IsString() @MaxLength(40)
|
||||||
|
key!: string
|
||||||
|
|
||||||
|
@IsString() @MaxLength(200)
|
||||||
|
subject!: string
|
||||||
|
|
||||||
|
@IsString() @MaxLength(20000)
|
||||||
|
body!: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Customer-editable whitelabel branding. Narrow on purpose: name + accent
|
||||||
|
// color (written to the Tenant doc) and email-template overrides (written to
|
||||||
|
// TenantBranding). No plan/status/partner — and no logo/domain yet (no
|
||||||
|
// backend). brandColor accepts '#rrggbb'.
|
||||||
|
export class UpdateTenantBrandingDto {
|
||||||
|
@IsOptional() @IsString() @MinLength(1) @MaxLength(120)
|
||||||
|
name?: string
|
||||||
|
|
||||||
|
@IsOptional() @IsHexColor()
|
||||||
|
brandColor?: string
|
||||||
|
|
||||||
|
@IsOptional() @IsArray() @ValidateNested({ each: true }) @Type(() => EmailTemplateDto)
|
||||||
|
emailTemplates?: EmailTemplateDto[]
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { Injectable } from '@nestjs/common'
|
||||||
|
import { InjectModel } from '@nestjs/mongoose'
|
||||||
|
import { Model } from 'mongoose'
|
||||||
|
import { AuditService, type AuditActor } from '../audit/audit.service.js'
|
||||||
|
import { TenantBranding, type TenantBrandingDocument } from '../schemas/tenant-branding.schema.js'
|
||||||
|
import { Tenant, type TenantDocument } from '../schemas/tenant.schema.js'
|
||||||
|
import type { UpdateTenantBrandingDto } from './dto/update-tenant-branding.dto.js'
|
||||||
|
|
||||||
|
export interface TenantBrandingView {
|
||||||
|
name: string
|
||||||
|
brandColor?: string
|
||||||
|
primaryDomain?: string
|
||||||
|
emailTemplates: Array<{ key: string; subject: string; body: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
// Customer whitelabel branding. Name + brandColor are read/written on the
|
||||||
|
// Tenant doc (source of truth); email-template overrides live in the
|
||||||
|
// TenantBranding doc. The portal page reads the combined view and saves the
|
||||||
|
// whole thing back in one PUT (same full-replace shape as partner branding).
|
||||||
|
@Injectable()
|
||||||
|
export class TenantBrandingService {
|
||||||
|
constructor(
|
||||||
|
@InjectModel(Tenant.name) private readonly tenantModel: Model<TenantDocument>,
|
||||||
|
@InjectModel(TenantBranding.name) private readonly brandingModel: Model<TenantBrandingDocument>,
|
||||||
|
private readonly audit: AuditService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async get(tenant: TenantDocument): Promise<TenantBrandingView> {
|
||||||
|
const branding = await this.brandingModel.findOne({ tenantId: tenant._id }).exec()
|
||||||
|
return {
|
||||||
|
name: tenant.name,
|
||||||
|
brandColor: tenant.brandColor,
|
||||||
|
primaryDomain: tenant.domains?.[0],
|
||||||
|
emailTemplates: (branding?.emailTemplates ?? []).map((t) => ({
|
||||||
|
key: t.key,
|
||||||
|
subject: t.subject,
|
||||||
|
body: t.body,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async put(
|
||||||
|
tenant: TenantDocument,
|
||||||
|
dto: UpdateTenantBrandingDto,
|
||||||
|
actor?: AuditActor,
|
||||||
|
): Promise<TenantBrandingView> {
|
||||||
|
// Name + accent live on the Tenant doc.
|
||||||
|
const set: Record<string, unknown> = {}
|
||||||
|
if (dto.name !== undefined) set.name = dto.name
|
||||||
|
if (dto.brandColor !== undefined) set.brandColor = dto.brandColor
|
||||||
|
if (Object.keys(set).length) {
|
||||||
|
await this.tenantModel.updateOne({ _id: tenant._id }, { $set: set }).exec()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Email-template overrides are a full replace (the page edits the whole set).
|
||||||
|
if (dto.emailTemplates !== undefined) {
|
||||||
|
await this.brandingModel
|
||||||
|
.findOneAndUpdate(
|
||||||
|
{ tenantId: tenant._id },
|
||||||
|
{ $set: { emailTemplates: dto.emailTemplates } },
|
||||||
|
{ upsert: true, new: true, runValidators: true, setDefaultsOnInsert: true },
|
||||||
|
)
|
||||||
|
.exec()
|
||||||
|
}
|
||||||
|
|
||||||
|
void this.audit.record(
|
||||||
|
{
|
||||||
|
action: 'tenant.branding_updated',
|
||||||
|
resourceType: 'tenant',
|
||||||
|
resourceId: String(tenant._id),
|
||||||
|
resourceName: dto.name ?? tenant.name,
|
||||||
|
tenantSlug: tenant.slug,
|
||||||
|
metadata: {
|
||||||
|
fields: Object.keys(set),
|
||||||
|
templates: dto.emailTemplates?.length ?? 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
actor,
|
||||||
|
)
|
||||||
|
|
||||||
|
const fresh = await this.tenantModel.findById(tenant._id).exec()
|
||||||
|
return this.get(fresh ?? tenant)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,8 @@ import {
|
|||||||
Param,
|
Param,
|
||||||
Patch,
|
Patch,
|
||||||
Post,
|
Post,
|
||||||
|
Put,
|
||||||
|
Query,
|
||||||
Req,
|
Req,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
} from '@nestjs/common'
|
} from '@nestjs/common'
|
||||||
@@ -17,9 +19,12 @@ import { CurrentUser } from '../auth/current-user.decorator.js'
|
|||||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard.js'
|
import { JwtAuthGuard } from '../auth/jwt-auth.guard.js'
|
||||||
import { OperatorGuard } from '../auth/operator.guard.js'
|
import { OperatorGuard } from '../auth/operator.guard.js'
|
||||||
import type { AuthentikJwtPayload } from '../auth/jwt-payload.interface.js'
|
import type { AuthentikJwtPayload } from '../auth/jwt-payload.interface.js'
|
||||||
import type { AuditActor } from '../audit/audit.service.js'
|
import { AuditService, type AuditActor } from '../audit/audit.service.js'
|
||||||
import { CreateTenantDto } from './dto/create-tenant.dto.js'
|
import { CreateTenantDto } from './dto/create-tenant.dto.js'
|
||||||
|
import { UpdateBillingInfoDto } from './dto/update-billing-info.dto.js'
|
||||||
|
import { UpdateTenantBrandingDto } from './dto/update-tenant-branding.dto.js'
|
||||||
import { UpdateTenantDto } from './dto/update-tenant.dto.js'
|
import { UpdateTenantDto } from './dto/update-tenant.dto.js'
|
||||||
|
import { TenantBrandingService } from './tenant-branding.service.js'
|
||||||
import { TenantsService } from './tenants.service.js'
|
import { TenantsService } from './tenants.service.js'
|
||||||
|
|
||||||
// Build the audit actor from a resolved User doc + the originating request.
|
// Build the audit actor from a resolved User doc + the originating request.
|
||||||
@@ -41,6 +46,8 @@ export class TenantsController {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly tenants: TenantsService,
|
private readonly tenants: TenantsService,
|
||||||
private readonly actor: ActorService,
|
private readonly actor: ActorService,
|
||||||
|
private readonly audit: AuditService,
|
||||||
|
private readonly branding: TenantBrandingService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@@ -79,6 +86,29 @@ export class TenantsController {
|
|||||||
return this.tenants.listUsersForTenant(slug)
|
return this.tenants.listUsersForTenant(slug)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Tenant-scoped audit slice for the customer-admin dashboard. Same membership
|
||||||
|
// gate as GET :slug — any member of the tenant can read their own workspace's
|
||||||
|
// activity. This is the portal-accessible counterpart to the operator-only
|
||||||
|
// GET /audit (which requires dezky-operator audience). Filtered strictly by
|
||||||
|
// tenantSlug so a caller only ever sees their own tenant's events.
|
||||||
|
@Get(':slug/audit')
|
||||||
|
async listAudit(
|
||||||
|
@Param('slug') slug: string,
|
||||||
|
@CurrentUser() jwt: AuthentikJwtPayload,
|
||||||
|
@Query('limit') limit?: string,
|
||||||
|
) {
|
||||||
|
const actor = await this.actor.resolve(jwt)
|
||||||
|
const tenant = await this.tenants.findOneBySlug(slug)
|
||||||
|
if (!actor.platformAdmin && !actor.tenantIds.some((id) => id.equals(tenant._id))) {
|
||||||
|
throw new ForbiddenException(`No access to tenant "${slug}"`)
|
||||||
|
}
|
||||||
|
const parsed = limit ? Number.parseInt(limit, 10) : undefined
|
||||||
|
return this.audit.list({
|
||||||
|
tenantSlug: slug,
|
||||||
|
limit: Number.isFinite(parsed) ? parsed : undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
@Patch(':slug')
|
@Patch(':slug')
|
||||||
async update(
|
async update(
|
||||||
@Param('slug') slug: string,
|
@Param('slug') slug: string,
|
||||||
@@ -94,6 +124,51 @@ export class TenantsController {
|
|||||||
return this.tenants.update(slug, dto, auditActor(user, req))
|
return this.tenants.update(slug, dto, auditActor(user, req))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Customer admins can fix their own company/tax details. Narrow on purpose —
|
||||||
|
// only billingInfo, never plan/status/partner (unlike the broad PATCH above).
|
||||||
|
// Membership-gated like the other tenant-scoped portal reads.
|
||||||
|
@Patch(':slug/billing-info')
|
||||||
|
async updateBillingInfo(
|
||||||
|
@Param('slug') slug: string,
|
||||||
|
@Body() dto: UpdateBillingInfoDto,
|
||||||
|
@CurrentUser() jwt: AuthentikJwtPayload,
|
||||||
|
@Req() req: Parameters<typeof clientIp>[0],
|
||||||
|
) {
|
||||||
|
const user = await this.actor.resolve(jwt)
|
||||||
|
const tenant = await this.tenants.findOneBySlug(slug)
|
||||||
|
if (!user.platformAdmin && !user.tenantIds.some((id) => id.equals(tenant._id))) {
|
||||||
|
throw new ForbiddenException(`No access to tenant "${slug}"`)
|
||||||
|
}
|
||||||
|
return this.tenants.updateBillingInfo(slug, dto, auditActor(user, req))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Whitelabel branding (name + accent + email-template overrides). Narrow,
|
||||||
|
// membership-gated — never touches plan/status/partner.
|
||||||
|
@Get(':slug/branding')
|
||||||
|
async getBranding(@Param('slug') slug: string, @CurrentUser() jwt: AuthentikJwtPayload) {
|
||||||
|
const user = await this.actor.resolve(jwt)
|
||||||
|
const tenant = await this.tenants.findOneBySlug(slug)
|
||||||
|
if (!user.platformAdmin && !user.tenantIds.some((id) => id.equals(tenant._id))) {
|
||||||
|
throw new ForbiddenException(`No access to tenant "${slug}"`)
|
||||||
|
}
|
||||||
|
return this.branding.get(tenant)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put(':slug/branding')
|
||||||
|
async putBranding(
|
||||||
|
@Param('slug') slug: string,
|
||||||
|
@Body() dto: UpdateTenantBrandingDto,
|
||||||
|
@CurrentUser() jwt: AuthentikJwtPayload,
|
||||||
|
@Req() req: Parameters<typeof clientIp>[0],
|
||||||
|
) {
|
||||||
|
const user = await this.actor.resolve(jwt)
|
||||||
|
const tenant = await this.tenants.findOneBySlug(slug)
|
||||||
|
if (!user.platformAdmin && !user.tenantIds.some((id) => id.equals(tenant._id))) {
|
||||||
|
throw new ForbiddenException(`No access to tenant "${slug}"`)
|
||||||
|
}
|
||||||
|
return this.branding.put(tenant, dto, auditActor(user, req))
|
||||||
|
}
|
||||||
|
|
||||||
@Delete(':slug')
|
@Delete(':slug')
|
||||||
@HttpCode(204)
|
@HttpCode(204)
|
||||||
async remove(@Param('slug') slug: string, @CurrentUser() jwt: AuthentikJwtPayload, @Req() req: Parameters<typeof clientIp>[0]) {
|
async remove(@Param('slug') slug: string, @CurrentUser() jwt: AuthentikJwtPayload, @Req() req: Parameters<typeof clientIp>[0]) {
|
||||||
|
|||||||
@@ -5,9 +5,11 @@ import { AuthModule } from '../auth/auth.module.js'
|
|||||||
import { IntegrationsModule } from '../integrations/integrations.module.js'
|
import { IntegrationsModule } from '../integrations/integrations.module.js'
|
||||||
import { PricesModule } from '../prices/prices.module.js'
|
import { PricesModule } from '../prices/prices.module.js'
|
||||||
import { Subscription, SubscriptionSchema } from '../schemas/subscription.schema.js'
|
import { Subscription, SubscriptionSchema } from '../schemas/subscription.schema.js'
|
||||||
|
import { TenantBranding, TenantBrandingSchema } from '../schemas/tenant-branding.schema.js'
|
||||||
import { Tenant, TenantSchema } from '../schemas/tenant.schema.js'
|
import { Tenant, TenantSchema } from '../schemas/tenant.schema.js'
|
||||||
import { User, UserSchema } from '../schemas/user.schema.js'
|
import { User, UserSchema } from '../schemas/user.schema.js'
|
||||||
import { ProvisioningService } from './provisioning.service.js'
|
import { ProvisioningService } from './provisioning.service.js'
|
||||||
|
import { TenantBrandingService } from './tenant-branding.service.js'
|
||||||
import { TenantsController } from './tenants.controller.js'
|
import { TenantsController } from './tenants.controller.js'
|
||||||
import { TenantsService } from './tenants.service.js'
|
import { TenantsService } from './tenants.service.js'
|
||||||
|
|
||||||
@@ -20,6 +22,7 @@ import { TenantsService } from './tenants.service.js'
|
|||||||
// provisioned tenant gets its Subscription doc in the same call. Price
|
// provisioned tenant gets its Subscription doc in the same call. Price
|
||||||
// lookup goes through PricesService for the soft-active filter.
|
// lookup goes through PricesService for the soft-active filter.
|
||||||
{ name: Subscription.name, schema: SubscriptionSchema },
|
{ name: Subscription.name, schema: SubscriptionSchema },
|
||||||
|
{ name: TenantBranding.name, schema: TenantBrandingSchema },
|
||||||
]),
|
]),
|
||||||
AuthModule,
|
AuthModule,
|
||||||
AuditModule,
|
AuditModule,
|
||||||
@@ -27,7 +30,7 @@ import { TenantsService } from './tenants.service.js'
|
|||||||
PricesModule,
|
PricesModule,
|
||||||
],
|
],
|
||||||
controllers: [TenantsController],
|
controllers: [TenantsController],
|
||||||
providers: [TenantsService, ProvisioningService],
|
providers: [TenantsService, ProvisioningService, TenantBrandingService],
|
||||||
exports: [TenantsService],
|
exports: [TenantsService],
|
||||||
})
|
})
|
||||||
export class TenantsModule {}
|
export class TenantsModule {}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { Tenant, TenantDocument } from '../schemas/tenant.schema.js'
|
|||||||
import { User, UserDocument } from '../schemas/user.schema.js'
|
import { User, UserDocument } from '../schemas/user.schema.js'
|
||||||
import type { PartnerUpdateTenantDto } from '../me/dto/partner-update-tenant.dto.js'
|
import type { PartnerUpdateTenantDto } from '../me/dto/partner-update-tenant.dto.js'
|
||||||
import type { CreateTenantDto } from './dto/create-tenant.dto.js'
|
import type { CreateTenantDto } from './dto/create-tenant.dto.js'
|
||||||
|
import type { UpdateBillingInfoDto } from './dto/update-billing-info.dto.js'
|
||||||
import type { UpdateTenantDto } from './dto/update-tenant.dto.js'
|
import type { UpdateTenantDto } from './dto/update-tenant.dto.js'
|
||||||
import { ProvisioningService } from './provisioning.service.js'
|
import { ProvisioningService } from './provisioning.service.js'
|
||||||
|
|
||||||
@@ -244,6 +245,39 @@ export class TenantsService {
|
|||||||
return tenant
|
return tenant
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Narrow update of just the company/tax fields, for the customer-admin billing
|
||||||
|
// page. Uses dotted $set so only the provided keys change (and '' clears one)
|
||||||
|
// without disturbing the rest of billingInfo.
|
||||||
|
async updateBillingInfo(
|
||||||
|
slug: string,
|
||||||
|
dto: UpdateBillingInfoDto,
|
||||||
|
actor?: AuditActor,
|
||||||
|
): Promise<TenantDocument> {
|
||||||
|
const set: Record<string, unknown> = {}
|
||||||
|
if (dto.companyName !== undefined) set['billingInfo.companyName'] = dto.companyName
|
||||||
|
if (dto.vatId !== undefined) set['billingInfo.vatId'] = dto.vatId
|
||||||
|
if (dto.country !== undefined) set['billingInfo.country'] = dto.country
|
||||||
|
if (dto.contactEmail !== undefined) set['billingInfo.contactEmail'] = dto.contactEmail
|
||||||
|
|
||||||
|
const tenant = await this.tenantModel
|
||||||
|
.findOneAndUpdate({ slug }, { $set: set }, { new: true, runValidators: true })
|
||||||
|
.exec()
|
||||||
|
if (!tenant) throw new NotFoundException(`Tenant "${slug}" not found`)
|
||||||
|
|
||||||
|
void this.audit.record(
|
||||||
|
{
|
||||||
|
action: 'tenant.billing_info_updated',
|
||||||
|
resourceType: 'tenant',
|
||||||
|
resourceId: String(tenant._id),
|
||||||
|
resourceName: tenant.name,
|
||||||
|
tenantSlug: tenant.slug,
|
||||||
|
metadata: { fields: Object.keys(set) },
|
||||||
|
},
|
||||||
|
actor,
|
||||||
|
)
|
||||||
|
return tenant
|
||||||
|
}
|
||||||
|
|
||||||
async softDelete(slug: string, actor?: AuditActor): Promise<void> {
|
async softDelete(slug: string, actor?: AuditActor): Promise<void> {
|
||||||
const result = await this.tenantModel
|
const result = await this.tenantModel
|
||||||
.findOneAndUpdate({ slug }, { status: 'deleted' }, { new: true })
|
.findOneAndUpdate({ slug }, { status: 'deleted' }, { new: true })
|
||||||
|
|||||||
Reference in New Issue
Block a user