Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c9911cc262 | |||
| 47eb9502f8 | |||
| 2a43a7bbf3 | |||
| f094158334 | |||
| f8618b2bbc | |||
| 559348f6bc | |||
| 3288fde693 | |||
| db26dafc64 | |||
| 0b269e7ea7 | |||
| da1b77ba5d |
@@ -30,15 +30,48 @@ AUTHENTIK_BOOTSTRAP_PASSWORD=admin_change_this_after_first_login
|
||||
# AUTHENTIK_BOOTSTRAP_TOKEN is used by the provisioning service to call Authentik API
|
||||
AUTHENTIK_BOOTSTRAP_TOKEN=changeme_use_openssl_rand_hex_32
|
||||
|
||||
# ────────────────────────────────────────
|
||||
# Operator OIDC (dezky-operator)
|
||||
# ────────────────────────────────────────
|
||||
# The operator app differs from the portal: its OAuth provider is provisioned
|
||||
# declaratively by the operator-application blueprint, which CONSUMES the secret
|
||||
# below (rather than Authentik generating one for you to copy out). You must set
|
||||
# a value BEFORE first boot — on a fresh environment the blueprint creates the
|
||||
# provider with exactly this secret, and the operator container authenticates
|
||||
# with the same value, so the two only agree if it's set here first.
|
||||
# Generate with: openssl rand -hex 64
|
||||
OPERATOR_OIDC_CLIENT_ID=dezky-operator
|
||||
OPERATOR_OIDC_CLIENT_SECRET=changeme_run_openssl_rand_hex_64
|
||||
|
||||
# ────────────────────────────────────────
|
||||
# Stalwart Mail
|
||||
# ────────────────────────────────────────
|
||||
# Fallback admin login (config.toml authentication.fallback-admin). platform-api
|
||||
# uses admin + this password for Basic auth on the JMAP management API.
|
||||
STALWART_ADMIN_USER=admin
|
||||
STALWART_ADMIN_PASSWORD=changeme_use_openssl_rand
|
||||
# HMAC secret Stalwart signs its audit webhook POSTs with (verified by
|
||||
# platform-api at /ingest/stalwart/webhook). openssl rand -hex 32
|
||||
STALWART_WEBHOOK_SECRET=changeme_use_openssl_rand_hex_32
|
||||
# Set true to let platform-api create/delete domains + DKIM in Stalwart from the
|
||||
# customer-admin Domains page. Off by default (domain steps record 'skipped').
|
||||
STALWART_PROVISIONING_ENABLED=false
|
||||
|
||||
# ────────────────────────────────────────
|
||||
# OCIS
|
||||
# ────────────────────────────────────────
|
||||
OCIS_ADMIN_PASSWORD=changeme_use_openssl_rand
|
||||
# Dedicated OCIS service user (Authentik) used by platform-api to read drive
|
||||
# quotas for the Storage page via an OIDC password grant. Must exist in
|
||||
# Authentik, have access to the OCIS application, and hold the OCIS admin role
|
||||
# (required to list all drives). See docs/NEXT-STEPS.md.
|
||||
OCIS_SVC_USERNAME=svc-platform-api
|
||||
OCIS_SVC_PASSWORD=changeme_use_openssl_rand
|
||||
# OCIS account UUID of the service user, used to grant it the OCIS admin role at
|
||||
# startup (required for libregraph ListAllDrives). Populate after the OCIS
|
||||
# bootstrap autoprovisions the account (see docs/NEXT-STEPS.md). Leave empty
|
||||
# until then.
|
||||
OCIS_ADMIN_USER_ID=
|
||||
|
||||
# ────────────────────────────────────────
|
||||
# Collabora
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
// Authorization gate for the operator portal.
|
||||
//
|
||||
// nuxt-oidc-auth's global middleware only proves *authentication* — "does
|
||||
// this browser hold a valid dezky-operator session?". It says nothing about
|
||||
// whether the person is actually an operator. Without this middleware, anyone
|
||||
// who completes the dezky-operator OIDC flow (a partner, a tenant admin, any
|
||||
// Authentik user) lands on the full operator shell. The platform-api still
|
||||
// 403s their data calls via OperatorGuard, but *being on the operator app at
|
||||
// all* is the violation — and it leaves only the Authentik application policy
|
||||
// standing between a non-admin and the UI. This is the second, in-app layer.
|
||||
//
|
||||
// Runs after 00.auth.global (nuxt-oidc-auth), so by the time we get here the
|
||||
// session exists. We resolve the operator's own profile and require
|
||||
// platformAdmin=true. A signed-in non-admin is fully signed out (local +
|
||||
// Authentik IdP) — never silently left with a live operator session on what
|
||||
// may be a shared workstation — and shown /not-authorized.
|
||||
//
|
||||
// /api/me is SSR-safe via useRequestFetch (it forwards the session cookie),
|
||||
// so there's no flash of operator chrome before the redirect.
|
||||
|
||||
export default defineNuxtRouteMiddleware(async (to) => {
|
||||
// Public surfaces: the login bounce, the sign-out landing, and the
|
||||
// not-authorized page itself must stay reachable without the check.
|
||||
if (
|
||||
to.path.startsWith('/auth/') ||
|
||||
to.path === '/signed-out' ||
|
||||
to.path === '/not-authorized'
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
const { fetchMe, isPlatformAdmin } = useMe()
|
||||
const me = await fetchMe()
|
||||
|
||||
// fetchMe() collapses every failure to null — both "not signed in" and
|
||||
// "signed in, but /api/me (→ platform-api) errored transiently". We let BOTH
|
||||
// through here. The not-signed-in case is handled by the OIDC middleware's
|
||||
// bounce to login. The API-error case is a DELIBERATE fail-OPEN: failing
|
||||
// closed would bounce every operator to /not-authorized — and thus fully
|
||||
// sign them out — on any platform-api restart, a self-inflicted mass-signout
|
||||
// on routine deploys. The exposure from failing open is contained by the
|
||||
// other two layers: Authentik's application policy stops a non-admin from
|
||||
// ever obtaining a session (layer 1), and OperatorGuard 403s every data call
|
||||
// regardless of what the UI renders (layer 3). See docs/AUTHENTIK-SETUP.md →
|
||||
// "Operator portal isolation". (The partner middleware fails CLOSED instead
|
||||
// because the partner surface has no layer-1 IdP gate.)
|
||||
if (!me) return
|
||||
|
||||
if (!isPlatformAdmin.value) {
|
||||
return navigateTo('/not-authorized')
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,130 @@
|
||||
<script setup lang="ts">
|
||||
// Shown when an authenticated-but-non-operator session reaches the operator
|
||||
// portal (see middleware/require-platform-admin.global.ts). The account is
|
||||
// valid in Authentik but lacks platformAdmin — e.g. a partner or tenant user
|
||||
// who completed the dezky-operator OIDC flow.
|
||||
//
|
||||
// We do NOT leave them parked here with a live session: that's the
|
||||
// shared-workstation risk the full-sign-out rule guards against. We trigger the
|
||||
// same full sign-out the UserMenu uses — clearing both the local
|
||||
// nuxt-oidc-auth session and the Authentik IdP session (so this also ends their
|
||||
// SSO session in any other tab — intended for an elevated context).
|
||||
//
|
||||
// A short delay lets them actually read why before the redirect through
|
||||
// Authentik fires; the button is an immediate-out fallback. The timer is
|
||||
// cleared on unmount so a manual click can't double-fire it.
|
||||
|
||||
definePageMeta({ layout: 'blank', auth: false, oidcAuth: { enabled: false } })
|
||||
|
||||
const SIGN_OUT_DELAY_MS = 2200
|
||||
|
||||
function signOut() {
|
||||
return navigateTo('/api/auth/sign-out', { external: true })
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const timer = setTimeout(signOut, SIGN_OUT_DELAY_MS)
|
||||
onBeforeUnmount(() => clearTimeout(timer))
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="shell">
|
||||
<div class="card">
|
||||
<div class="badge">
|
||||
<UiIcon name="shield" :size="22" />
|
||||
</div>
|
||||
<p class="eyebrow">dezky · ops</p>
|
||||
<h1>Not an operator account</h1>
|
||||
<p class="lead">
|
||||
This account doesn't have operator access. For your security we're
|
||||
signing you out completely — sign in with an operator account to
|
||||
continue.
|
||||
</p>
|
||||
<button class="primary" type="button" @click="signOut">Sign out now</button>
|
||||
<p class="hint">operator.dezky.local</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.shell {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 32px;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 36px 36px 32px;
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
text-align: center;
|
||||
box-shadow: 0 24px 60px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.badge {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 12px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(240, 88, 88, 0.12);
|
||||
color: var(--bad);
|
||||
margin: 0 auto 16px;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-mute);
|
||||
margin: 0 0 10px 0;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 600;
|
||||
font-size: 24px;
|
||||
letter-spacing: -0.02em;
|
||||
line-height: 1.15;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.lead {
|
||||
font-size: 13px;
|
||||
color: var(--text-dim);
|
||||
line-height: 1.55;
|
||||
margin: 14px 0 26px;
|
||||
}
|
||||
|
||||
.primary {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 42px;
|
||||
background: var(--accent);
|
||||
color: var(--accent-fg);
|
||||
border: none;
|
||||
border-radius: 7px;
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
.primary:hover { filter: brightness(0.96); }
|
||||
|
||||
.hint {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--text-mute);
|
||||
margin: 22px 0 0 0;
|
||||
}
|
||||
</style>
|
||||
@@ -68,6 +68,17 @@ function cancelEdit(id: string) {
|
||||
delete drafts[id]
|
||||
}
|
||||
|
||||
// A pending price change held back for operator confirmation, because it
|
||||
// re-prices live customers. Resolved by confirmReprice() or dismissed.
|
||||
interface PendingChange {
|
||||
row: PriceRow
|
||||
next: Partial<Record<Currency, number>>
|
||||
changes: { currency: Currency; from?: number; to: number; affected: number }[]
|
||||
totalAffected: number
|
||||
}
|
||||
const pending = ref<PendingChange | null>(null)
|
||||
const confirmBusy = ref(false)
|
||||
|
||||
async function saveEdit(row: PriceRow) {
|
||||
const draft = drafts[row._id]
|
||||
if (!draft) return
|
||||
@@ -81,6 +92,45 @@ async function saveEdit(row: PriceRow) {
|
||||
}
|
||||
next[c] = parsed
|
||||
}
|
||||
|
||||
// Which currencies actually changed value? Only those re-price customers.
|
||||
const changedCurrencies = CURRENCIES.filter((c) => next[c] !== undefined && next[c] !== row.amounts[c])
|
||||
if (changedCurrencies.length === 0) {
|
||||
cancelEdit(row._id) // nothing changed — just close the editor
|
||||
return
|
||||
}
|
||||
|
||||
// Ask the backend how many live customers each changed currency touches.
|
||||
saving.value = row._id
|
||||
let impact: Record<Currency, number> = { DKK: 0, EUR: 0, USD: 0 }
|
||||
try {
|
||||
impact = await $fetch<Record<Currency, number>>(`/api/prices/${row._id}/impact`)
|
||||
} catch {
|
||||
// Impact lookup failed — fall through with zeros; the save still works,
|
||||
// we just can't show the affected-customer count.
|
||||
} finally {
|
||||
saving.value = null
|
||||
}
|
||||
|
||||
const changes = changedCurrencies.map((c) => ({
|
||||
currency: c,
|
||||
from: row.amounts[c],
|
||||
to: next[c]!,
|
||||
affected: impact[c] ?? 0,
|
||||
}))
|
||||
const totalAffected = changes.reduce((sum, ch) => sum + ch.affected, 0)
|
||||
|
||||
// No live customers on the changed currencies → commit straight away.
|
||||
if (totalAffected === 0) {
|
||||
await commitSave(row, next)
|
||||
return
|
||||
}
|
||||
pending.value = { row, next, changes, totalAffected }
|
||||
}
|
||||
|
||||
// The actual PATCH. Backend mints new Stripe Prices for changed currencies and
|
||||
// moves live subs onto them (effective next cycle).
|
||||
async function commitSave(row: PriceRow, next: Partial<Record<Currency, number>>) {
|
||||
saving.value = row._id
|
||||
try {
|
||||
await $fetch(`/api/prices/${row._id}`, { method: 'PATCH', body: { amounts: next } })
|
||||
@@ -95,6 +145,18 @@ async function saveEdit(row: PriceRow) {
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmReprice() {
|
||||
if (!pending.value) return
|
||||
const { row, next } = pending.value
|
||||
confirmBusy.value = true
|
||||
try {
|
||||
await commitSave(row, next)
|
||||
} finally {
|
||||
confirmBusy.value = false
|
||||
pending.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleActive(row: PriceRow) {
|
||||
saving.value = row._id
|
||||
try {
|
||||
@@ -169,11 +231,11 @@ const sortedPrices = computed<PriceRow[]>(() =>
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="stage">
|
||||
<div>
|
||||
<PageHeader
|
||||
eyebrow="Operator · operator.dezky.local"
|
||||
title="Pricing catalog"
|
||||
subtitle="One row per plan + cycle, with independent prices per currency. Changes affect subscriptions provisioned from now on — existing customers keep the price snapshot taken at provisioning."
|
||||
subtitle="One row per plan + cycle, with independent prices per currency. Editing an amount re-prices live customers on that currency at their next billing cycle (no mid-cycle charge) and applies to all new subscriptions."
|
||||
>
|
||||
<template #actions>
|
||||
<label class="toggle">
|
||||
@@ -183,6 +245,7 @@ const sortedPrices = computed<PriceRow[]>(() =>
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<div class="stage">
|
||||
<Card :pad="0">
|
||||
<table>
|
||||
<thead>
|
||||
@@ -291,6 +354,38 @@ const sortedPrices = computed<PriceRow[]>(() =>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
:open="pending !== null"
|
||||
eyebrow="Pricing catalog"
|
||||
:title="pending ? `Change ${PLAN_LABEL[pending.row.plan]} · ${CYCLE_LABEL[pending.row.cycle]} pricing?` : ''"
|
||||
confirm-label="Apply change"
|
||||
:busy="confirmBusy"
|
||||
@close="pending = null"
|
||||
@confirm="confirmReprice"
|
||||
>
|
||||
<template v-if="pending">
|
||||
<p>
|
||||
<strong>{{ pending.totalAffected }}</strong>
|
||||
active {{ pending.totalAffected === 1 ? 'customer' : 'customers' }} will move to the
|
||||
new price at their <strong>next billing cycle</strong>. The current period and past
|
||||
invoices are unaffected — no mid-cycle charge or credit.
|
||||
</p>
|
||||
<ul class="reprice-list">
|
||||
<li v-for="ch in pending.changes" :key="ch.currency">
|
||||
<Mono>{{ ch.currency }}</Mono>
|
||||
<span class="reprice-amounts">
|
||||
{{ ch.from !== undefined ? toMajor(ch.from) : '—' }} →
|
||||
<strong>{{ toMajor(ch.to) }}</strong>
|
||||
</span>
|
||||
<span class="reprice-count">
|
||||
{{ ch.affected }} {{ ch.affected === 1 ? 'customer' : 'customers' }}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
</ConfirmDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@@ -343,4 +438,18 @@ tr.inactive { opacity: 0.55; }
|
||||
outline: 0;
|
||||
}
|
||||
.hint { font-size: 13px; color: var(--text-dim); margin: 0 0 12px; }
|
||||
|
||||
.reprice-list { list-style: none; margin: 14px 0 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }
|
||||
.reprice-list li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.reprice-amounts { font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
|
||||
.reprice-count { margin-left: auto; color: var(--text-mute); font-size: 12px; }
|
||||
</style>
|
||||
|
||||
@@ -203,7 +203,7 @@ async function reconcile() {
|
||||
</td>
|
||||
<td>
|
||||
<Badge :tone="u.platformAdmin ? 'accent' : 'neutral'">
|
||||
{{ u.platformAdmin ? 'platform-admin' : u.role }}
|
||||
{{ u.platformAdmin ? 'platform-admin' : u.tenantRole }}
|
||||
</Badge>
|
||||
</td>
|
||||
<td>
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { platformApi } from '~~/server/utils/platform-api'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const id = getRouterParam(event, 'id')
|
||||
return platformApi(event, `/prices/${id}/impact`)
|
||||
})
|
||||
@@ -41,7 +41,12 @@ export interface TenantUser {
|
||||
authentikSubjectId: string
|
||||
email: string
|
||||
name: string
|
||||
// Global/legacy role + fallback. Prefer `tenantRole` for display in a tenant
|
||||
// context — `role` can differ from the user's actual role in this tenant.
|
||||
role: 'owner' | 'admin' | 'member'
|
||||
// Role resolved for THIS tenant (per-tenant override, else global `role`).
|
||||
// Set by GET /tenants/:slug/users.
|
||||
tenantRole: 'owner' | 'admin' | 'member'
|
||||
active: boolean
|
||||
platformAdmin: boolean
|
||||
tenantIds: string[]
|
||||
|
||||
@@ -9,6 +9,7 @@ import type { IconName } from './UiIcon.vue'
|
||||
const launcher = useAppLauncher()
|
||||
const route = useRoute()
|
||||
const partnerMode = usePartnerMode()
|
||||
const { isTenantAdmin } = useMe()
|
||||
|
||||
interface Tile {
|
||||
key: string
|
||||
@@ -38,8 +39,15 @@ const tiles = computed<Tile[]>(() => {
|
||||
{ key: 'cal', name: 'Kalender', icon: 'calendar', ext: 'cal.dezky.com' },
|
||||
{ key: 'contacts', name: 'Kontakter', icon: 'users', ext: 'contacts.dezky.com' },
|
||||
]
|
||||
if (isAdmin) {
|
||||
base.push({ key: 'admin', name: 'Admin', icon: 'shield', ext: 'admin.dezky.com', current: !isPartner })
|
||||
// Admin tile is the entry point to the workspace-admin surface. Show it to any
|
||||
// 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) {
|
||||
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()
|
||||
function open(t: Tile) {
|
||||
launcher.hide()
|
||||
if (t.key === 'home') return navigateTo('/')
|
||||
if (t.key === 'admin') return navigateTo('/admin')
|
||||
if (t.key === 'partner') return navigateTo('/partner')
|
||||
toast.info(`Opening ${t.name}…`, t.ext)
|
||||
@@ -73,7 +82,7 @@ onMounted(() => {
|
||||
<header>
|
||||
<div class="head-meta">
|
||||
<Eyebrow>Apps</Eyebrow>
|
||||
<div class="head-title">Open in new tab</div>
|
||||
<div class="head-title">Jump to</div>
|
||||
</div>
|
||||
<button class="x" @click="launcher.hide" aria-label="Close">
|
||||
<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'])
|
||||
|
||||
// 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(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && props.open) emit('close')
|
||||
@@ -28,8 +40,8 @@ onMounted(() => {
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="modal">
|
||||
<div v-if="open" class="backdrop" @click="emit('close')">
|
||||
<div class="modal" :style="{ maxWidth: maxWidth + 'px' }" @click.stop>
|
||||
<div v-if="open" class="backdrop" @mousedown="onBackdropMousedown" @click.self="onBackdropClick">
|
||||
<div class="modal" :style="{ maxWidth: maxWidth + 'px' }">
|
||||
<header v-if="title || eyebrow || $slots.header">
|
||||
<div class="lhs">
|
||||
<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 { PartnerTenantDoc } from '~/types/partner'
|
||||
import type { TenantUserDoc } from '~/types/workspace'
|
||||
|
||||
interface NavItem {
|
||||
id: string
|
||||
@@ -62,7 +63,7 @@ const ADMIN_NAV: NavRow[] = [
|
||||
{ id: 'mail', label: 'Mail settings', icon: 'mail', href: '/admin/mail' },
|
||||
{ id: 'meetings', label: 'Meetings', icon: 'video', href: '/admin/meetings' },
|
||||
{ id: 'chat', label: 'Chat', icon: 'chat', href: '/admin/chat' },
|
||||
{ id: 'domains', label: 'Domains', icon: 'globe', href: '/admin/domains', badge: 1 },
|
||||
{ id: 'domains', label: 'Domains', icon: 'globe', href: '/admin/domains' },
|
||||
{ id: 'storage', label: 'Storage', icon: 'database', href: '/admin/storage' },
|
||||
{ id: 'security', label: 'Security & audit', icon: 'shield', href: '/admin/security' },
|
||||
{ sec: 'Commercial' },
|
||||
@@ -96,7 +97,15 @@ const navItems = computed<NavRow[]>(() => {
|
||||
: row,
|
||||
)
|
||||
}
|
||||
if (section.value === 'admin') return ADMIN_NAV
|
||||
if (section.value === 'admin') {
|
||||
// Inject the count of domains needing attention onto the Domains row.
|
||||
// Undefined when 0 so the badge hides rather than rendering "0".
|
||||
return ADMIN_NAV.map((row) =>
|
||||
'id' in row && row.id === 'domains'
|
||||
? { ...row, badge: domainsNeedingAttention.value || undefined }
|
||||
: row,
|
||||
)
|
||||
}
|
||||
return END_USER_NAV
|
||||
})
|
||||
|
||||
@@ -156,6 +165,48 @@ const { data: partnerTenants } = await useFetch<PartnerTenantDoc[]>('/api/partne
|
||||
})
|
||||
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)
|
||||
|
||||
// Domains needing attention (anything not fully verified) drive the Domains nav
|
||||
// badge. Shares the 'admin-domains' fetch key with the Domains page, so adding
|
||||
// or fixing a domain updates the badge live. Gated like the seat usage fetch.
|
||||
const { data: sidebarDomains } = await useFetch<{ status: string }[]>(
|
||||
() => `/api/tenants/${ownSlug.value}/domains`,
|
||||
{
|
||||
key: 'admin-domains',
|
||||
default: () => [],
|
||||
immediate: !isPartnerStaff.value && !!ownSlug.value,
|
||||
watch: [ownSlug],
|
||||
},
|
||||
)
|
||||
const domainsNeedingAttention = computed(
|
||||
() => (sidebarDomains.value ?? []).filter((d) => d.status !== 'active').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
|
||||
// the real tenant list by the _id stored in partner mode.
|
||||
const activeCustomer = computed(() =>
|
||||
@@ -167,14 +218,15 @@ const activeCustomer = computed(() =>
|
||||
<aside class="sidebar" :class="{ collapsed }">
|
||||
<!-- Workspace switcher -->
|
||||
<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'">
|
||||
<span class="ws-tile bone">
|
||||
<NodeMark :size="28" fg="#0A0A0A" accent="var(--signal)" />
|
||||
<span class="ws-tile brand" :style="{ background: brandBg, color: brandFg }">
|
||||
{{ (ownTenant?.name?.[0] || 'a').toLowerCase() }}
|
||||
</span>
|
||||
<div v-if="!collapsed" class="ws-text">
|
||||
<div class="ws-name">baslund</div>
|
||||
<div class="ws-sub">Business · 11/25</div>
|
||||
<div class="ws-name">{{ ownTenant?.name || 'Workspace' }}</div>
|
||||
<div class="ws-sub">{{ planLabel }}{{ seatLimit ? ` · ${seatsUsed}/${seatLimit}` : '' }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -225,18 +277,9 @@ const activeCustomer = computed(() =>
|
||||
</template>
|
||||
</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">
|
||||
<button class="user" :title="collapsed ? 'Anne Hansen' : undefined">
|
||||
<Avatar name="Anne Hansen" :size="26" />
|
||||
<div v-if="!collapsed" class="user-text">
|
||||
<div class="user-name">Anne Hansen</div>
|
||||
<div class="user-role">
|
||||
{{ section === 'partner' ? 'partner admin' : section === 'admin' ? 'admin' : 'user' }}
|
||||
</div>
|
||||
</div>
|
||||
<UiIcon v-if="!collapsed" name="chevUpDown" :size="12" stroke="var(--side-mute)" />
|
||||
</button>
|
||||
<button class="collapse" @click="toggle" :title="collapsed ? 'Expand · ⌘[' : 'Collapse · ⌘['">
|
||||
<UiIcon :name="collapsed ? 'chevRight' : 'chevLeft'" :size="11" />
|
||||
<span v-if="!collapsed">collapse · ⌘[</span>
|
||||
@@ -298,6 +341,13 @@ const activeCustomer = computed(() =>
|
||||
font-weight: 700;
|
||||
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-name {
|
||||
@@ -397,31 +447,6 @@ nav {
|
||||
border-top: 1px solid var(--side-border);
|
||||
padding: 8px;
|
||||
}
|
||||
.user {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
color: var(--side-dim);
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
.user:hover { background: var(--side-hover); }
|
||||
.user-text { flex: 1; min-width: 0; }
|
||||
.user-name { font-size: 12px; color: var(--side-text); font-weight: 500; }
|
||||
.user-role {
|
||||
font-size: 10px;
|
||||
color: var(--side-mute);
|
||||
font-family: var(--font-mono);
|
||||
margin-top: 1px;
|
||||
}
|
||||
.sidebar.collapsed .user { justify-content: center; padding: 8px 0; }
|
||||
|
||||
/* Collapse toggle */
|
||||
.collapse {
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
<script setup lang="ts">
|
||||
// One DNS record row used by the add-domain wizard: TYPE / HOST / VALUE with a
|
||||
// live status badge and copy buttons. Values come straight from the server's
|
||||
// expected records (Stalwart's authoritative zone), so the DKIM key etc. is real.
|
||||
import type { DomainRecordView, RecordStatus } from '~/composables/useDomains'
|
||||
|
||||
const props = defineProps<{ rec: DomainRecordView }>()
|
||||
const toast = useToast()
|
||||
|
||||
function badgeTone(status: RecordStatus): 'ok' | 'warn' | 'bad' {
|
||||
return status === 'ok' ? 'ok' : status === 'bad' ? 'bad' : 'warn'
|
||||
}
|
||||
function copy(text: string) {
|
||||
if (typeof navigator !== 'undefined' && navigator.clipboard) navigator.clipboard.writeText(text)
|
||||
toast.ok('Copied to clipboard')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="dns-row">
|
||||
<div><Mono dim>TYPE</Mono><div class="dns-val">{{ rec.type }}</div></div>
|
||||
<div>
|
||||
<Mono dim>HOST</Mono>
|
||||
<button class="dns-val link" @click="copy(rec.fqdn)" :title="rec.fqdn">{{ rec.fqdn }}</button>
|
||||
</div>
|
||||
<div>
|
||||
<Mono dim>VALUE</Mono>
|
||||
<button class="dns-val dim link" @click="copy(rec.expected)" :title="rec.expected">{{ rec.expected }}</button>
|
||||
</div>
|
||||
<div class="dns-right">
|
||||
<Badge :tone="badgeTone(rec.status)" dot>{{ rec.status }}</Badge>
|
||||
<span v-if="rec.priority !== undefined" class="prio"><Mono dim>prio {{ rec.priority }}</Mono></span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.dns-row {
|
||||
display: grid;
|
||||
grid-template-columns: 80px 220px 1fr 90px;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
padding: 12px 14px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
}
|
||||
.dns-val { font-family: var(--font-mono); font-size: 13px; font-weight: 600; margin-top: 2px; }
|
||||
.dns-val.dim { color: var(--text-dim); font-weight: 400; font-size: 12px; }
|
||||
.dns-val.link {
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 0;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
max-width: 100%;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: block;
|
||||
}
|
||||
.dns-val.link:hover { color: var(--text); }
|
||||
.dns-right { display: flex; flex-direction: column; align-items: flex-end; gap: 6px; }
|
||||
.prio { font-size: 11px; }
|
||||
</style>
|
||||
@@ -7,6 +7,8 @@
|
||||
defineProps<{ open: boolean }>()
|
||||
const emit = defineEmits<{ close: []; done: [] }>()
|
||||
|
||||
const { request } = useApiFetch()
|
||||
|
||||
// 5 steps. Branding was dropped because nothing on the backend persists it
|
||||
// yet (no Tenant.branding field, no logo upload pipeline) — partners
|
||||
// configure branding post-provisioning via /partner/branding once that
|
||||
@@ -214,7 +216,7 @@ async function submit() {
|
||||
// client so the wizard never sends half-filled admin payloads.
|
||||
...(adminName && adminEmail && { adminName, adminEmail }),
|
||||
}
|
||||
const res = await $fetch<{
|
||||
const res = await request<{
|
||||
tenant: { name: string }
|
||||
adminInvite?: AdminCredentials | { error: string }
|
||||
}>('/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 }
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
// Customer-admin email-domain data + mutations, backed by platform-api's
|
||||
// /api/tenants/:slug/domains endpoints. Reads use useFetch (SSR-friendly list);
|
||||
// writes go through useApiFetch so a lapsed session refreshes silently instead
|
||||
// of redirecting away mid-action. Mirrors the read/write split in
|
||||
// pages/admin/security.vue.
|
||||
|
||||
export type RecordStatus = 'ok' | 'warn' | 'bad' | 'pending'
|
||||
export type DomainStatus = 'pending' | 'verifying' | 'active' | 'error'
|
||||
export type RecordKind = 'ownership' | 'mx' | 'spf' | 'dkim' | 'dmarc'
|
||||
export type DmarcPolicy = 'none' | 'quarantine' | 'reject'
|
||||
|
||||
export interface DomainRecordView {
|
||||
kind: RecordKind
|
||||
type: string
|
||||
host: string
|
||||
fqdn: string
|
||||
expected: string
|
||||
priority?: number
|
||||
observed?: string
|
||||
status: RecordStatus
|
||||
}
|
||||
|
||||
export interface DomainView {
|
||||
id: string
|
||||
domain: string
|
||||
isPrimary: boolean
|
||||
status: DomainStatus
|
||||
ownershipVerified: boolean
|
||||
verificationToken: string
|
||||
dmarcPolicy: DmarcPolicy
|
||||
stalwartProvisioned: boolean
|
||||
stalwartError?: string
|
||||
mailboxes: number
|
||||
checks: Record<'ownership' | 'mx' | 'spf' | 'dkim' | 'dmarc', RecordStatus>
|
||||
records: DomainRecordView[]
|
||||
lastCheckedAt?: string
|
||||
}
|
||||
|
||||
export function useDomains() {
|
||||
const { tenant } = useTenant()
|
||||
const slug = computed(() => tenant.value?.slug ?? '')
|
||||
const { request } = useApiFetch()
|
||||
|
||||
const base = () => `/api/tenants/${slug.value}/domains`
|
||||
const one = (domain: string) => `${base()}/${encodeURIComponent(domain)}`
|
||||
|
||||
const { data: domains, refresh, pending } = useFetch<DomainView[]>(base, {
|
||||
key: 'admin-domains',
|
||||
default: () => [],
|
||||
immediate: !!slug.value,
|
||||
watch: [slug],
|
||||
})
|
||||
|
||||
const add = (domain: string) =>
|
||||
request<DomainView>(base(), { method: 'POST', body: { domain } })
|
||||
|
||||
const getOne = (domain: string) => request<DomainView>(one(domain))
|
||||
|
||||
const recheck = (domain: string) =>
|
||||
request<DomainView>(`${one(domain)}/recheck`, { method: 'POST' })
|
||||
|
||||
const setDmarcPolicy = (domain: string, dmarcPolicy: DmarcPolicy) =>
|
||||
request<DomainView>(`${one(domain)}/dmarc`, { method: 'PATCH', body: { dmarcPolicy } })
|
||||
|
||||
const remove = (domain: string) => request(one(domain), { method: 'DELETE' })
|
||||
|
||||
return { domains, pending, refresh, slug, add, getOne, recheck, setDmarcPolicy, remove }
|
||||
}
|
||||
@@ -9,6 +9,9 @@ interface MeProfile {
|
||||
email: string
|
||||
name: string
|
||||
role: string
|
||||
// Per-tenant role overrides keyed by tenantId; absent keys fall back to
|
||||
// `role`. Serialized from platform-api's User.tenantRoles Map.
|
||||
tenantRoles?: Record<string, 'owner' | 'admin' | 'member'>
|
||||
active: boolean
|
||||
platformAdmin: boolean
|
||||
tenantIds: string[]
|
||||
@@ -17,10 +20,12 @@ interface MeProfile {
|
||||
lastLoginAt?: string
|
||||
}
|
||||
|
||||
import type { TenantDoc, SubscriptionDoc } from '~/types/workspace'
|
||||
|
||||
interface MeResponse {
|
||||
profile: MeProfile
|
||||
tenants: unknown[]
|
||||
subscriptions: unknown[]
|
||||
tenants: TenantDoc[]
|
||||
subscriptions: SubscriptionDoc[]
|
||||
}
|
||||
|
||||
export function useMe() {
|
||||
@@ -28,17 +33,34 @@ export function useMe() {
|
||||
|
||||
async function fetchMe(force = false): Promise<MeResponse | null> {
|
||||
if (state.value && !force) return state.value
|
||||
try {
|
||||
// useRequestFetch on SSR forwards the incoming request's headers
|
||||
// (including the nuxt-oidc-auth session cookie) when calling the
|
||||
// Nitro route. Bare $fetch on SSR has no cookie context, so /api/me
|
||||
// would 401, the middleware would skip the redirect, and the end-user
|
||||
// page would flash before client-side rehydration finally redirects.
|
||||
const fetcher = useRequestFetch()
|
||||
try {
|
||||
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 {
|
||||
state.value = null
|
||||
}
|
||||
} else {
|
||||
state.value = null
|
||||
}
|
||||
}
|
||||
return state.value
|
||||
}
|
||||
|
||||
@@ -47,5 +69,38 @@ export function useMe() {
|
||||
const isPartnerStaff = computed(() => !!profile.value?.partnerId)
|
||||
const isPlatformAdmin = computed(() => !!profile.value?.platformAdmin)
|
||||
|
||||
return { state, profile, partner, isPartnerStaff, isPlatformAdmin, fetchMe }
|
||||
const isAdminRole = (r: string | undefined) => r === 'owner' || r === 'admin'
|
||||
|
||||
// Effective role for a specific tenant — mirrors platform-api roleForTenant():
|
||||
// a per-tenant entry wins, else the legacy global `role`, else 'member'.
|
||||
function roleForTenant(tenantId: string): 'owner' | 'admin' | 'member' {
|
||||
const p = profile.value
|
||||
return p?.tenantRoles?.[tenantId] ?? (p?.role as 'owner' | 'admin' | 'member') ?? 'member'
|
||||
}
|
||||
function isTenantAdminOf(tenantId: string): boolean {
|
||||
return isAdminRole(roleForTenant(tenantId))
|
||||
}
|
||||
|
||||
// Gates the /admin surface: true if the user administers AT LEAST ONE of
|
||||
// their tenants. Per-tenant enforcement of *which* workspace they may admin
|
||||
// happens once a tenant is in context (backend membership + roleForTenant).
|
||||
// For existing single-role data this is identical to the old global check.
|
||||
const isTenantAdmin = computed(() => {
|
||||
const p = profile.value
|
||||
if (!p) return false
|
||||
if (p.tenantIds.length) return p.tenantIds.some((t) => isTenantAdminOf(t))
|
||||
return isAdminRole(p.role)
|
||||
})
|
||||
|
||||
return {
|
||||
state,
|
||||
profile,
|
||||
partner,
|
||||
isPartnerStaff,
|
||||
isPlatformAdmin,
|
||||
isTenantAdmin,
|
||||
roleForTenant,
|
||||
isTenantAdminOf,
|
||||
fetchMe,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,13 @@
|
||||
const activeCustomerId = ref<string | null>(null)
|
||||
|
||||
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) {
|
||||
activeCustomerId.value = customerId
|
||||
if (import.meta.client) {
|
||||
@@ -23,13 +30,23 @@ export const usePartnerMode = () => {
|
||||
}
|
||||
}
|
||||
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')
|
||||
if (stored) activeCustomerId.value = stored
|
||||
}
|
||||
return {
|
||||
activeCustomerId,
|
||||
isActive: computed(() => activeCustomerId.value !== null),
|
||||
isActive: computed(
|
||||
() => isPartnerStaff.value && activeCustomerId.value !== null,
|
||||
),
|
||||
enter,
|
||||
exit,
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -50,45 +50,6 @@ export const sampleGroups = [
|
||||
{ id: 'g-5', name: 'Sales', description: 'Outbound + customer success', members: 3, owner: 'Bo Christensen', resources: ['sales@', 'Drev/Sales', '#sales'] },
|
||||
]
|
||||
|
||||
export const sampleDomains = [
|
||||
{
|
||||
id: 'd-1',
|
||||
domain: 'baslund.dk',
|
||||
primary: true,
|
||||
status: 'partial',
|
||||
records: [
|
||||
{ type: 'MX', status: 'ok', value: '10 mail.dezky.com' },
|
||||
{ type: 'SPF', status: 'warn', value: 'v=spf1 ~all', expected: 'v=spf1 include:_spf.dezky.com ~all' },
|
||||
{ type: 'DKIM', status: 'ok', value: 'k=rsa; p=MIGfMA0GCSq...' },
|
||||
{ type: 'DMARC', status: 'bad', value: '— not found —', expected: 'v=DMARC1; p=quarantine; rua=mailto:dmarc@baslund.dk' },
|
||||
],
|
||||
addedOn: '2025-12-04',
|
||||
},
|
||||
{
|
||||
id: 'd-2',
|
||||
domain: 'baslund.shop',
|
||||
primary: false,
|
||||
status: 'healthy',
|
||||
records: [
|
||||
{ type: 'MX', status: 'ok', value: '10 mail.dezky.com' },
|
||||
{ type: 'SPF', status: 'ok', value: 'v=spf1 include:_spf.dezky.com ~all' },
|
||||
{ type: 'DKIM', status: 'ok', value: 'k=rsa; p=MIGfMA0G...' },
|
||||
{ type: 'DMARC', status: 'ok', value: 'v=DMARC1; p=quarantine' },
|
||||
],
|
||||
addedOn: '2026-02-11',
|
||||
},
|
||||
{
|
||||
id: 'd-3',
|
||||
domain: 'baslund.io',
|
||||
primary: false,
|
||||
status: 'verifying',
|
||||
records: [
|
||||
{ type: 'TXT', status: 'warn', value: 'dezky-site-verification=…', hint: 'Awaiting propagation · ~10 min remaining' },
|
||||
],
|
||||
addedOn: '2026-05-22',
|
||||
},
|
||||
]
|
||||
|
||||
export const sampleInvoices = [
|
||||
{ id: 'inv-2026-001247', date: '2026-05-01', period: '2026-05', amount: 1940, status: 'paid', method: 'MobilePay' },
|
||||
{ id: 'inv-2026-001112', date: '2026-04-01', period: '2026-04', amount: 1940, status: 'paid', method: 'MobilePay' },
|
||||
@@ -173,14 +134,6 @@ export const sampleUsersFlat = [
|
||||
{ id: 'u_tt55', name: 'Clara Bjerre', email: 'clara@dezky.com', role: 'Member', status: 'active', last: '5 d ago', group: 'Sales', storage: 2.0 },
|
||||
]
|
||||
|
||||
// Source-fidelity domains (platform-screens.jsx SAMPLE_DOMAINS line 23) — flat
|
||||
// shape with per-record-type status used by DomainsScreen / DomainCard.
|
||||
export const sampleDomainsFlat = [
|
||||
{ domain: 'dezky.com', status: 'ok' as const, mx: 'ok' as const, spf: 'ok' as const, dkim: 'ok' as const, dmarc: 'ok' as const, users: 11 },
|
||||
{ domain: 'dezky.io', status: 'ok' as const, mx: 'ok' as const, spf: 'ok' as const, dkim: 'ok' as const, dmarc: 'warn' as const, users: 0 },
|
||||
{ domain: 'baslund.dk', status: 'warn' as const, mx: 'ok' as const, spf: 'warn' as const, dkim: 'ok' as const, dmarc: 'bad' as const, users: 2 },
|
||||
]
|
||||
|
||||
// Meeting rooms — strict port of platform-collab.jsx MEETING_ROOMS (line 8)
|
||||
export const meetingRooms = [
|
||||
{ id: 'r_eng', name: 'Engineering standup', alias: 'eng-standup', type: 'recurring' as const, when: 'Daily · 09:30', owner: 'Mikkel Nørgaard', members: 4, recording: 'auto' as const, protected: false },
|
||||
|
||||
@@ -1,25 +1,52 @@
|
||||
// Routes signed-in users to the surface that matches their role:
|
||||
// - partner staff (User.partnerId set) on '/' → /partner
|
||||
// - non-partner-staff hitting /partner/* → /
|
||||
// - non-admins hitting /admin/* → /
|
||||
//
|
||||
// Runs after the OIDC global middleware (00.auth.global from nuxt-oidc-auth)
|
||||
// so we know the user is authenticated by the time we get here. /me is
|
||||
// fetched lazily via useMe() and cached in useState — first nav after sign-in
|
||||
// pays one round-trip, subsequent navs read from cache.
|
||||
//
|
||||
// The partner + admin surfaces share the dezky-portal OAuth client with ordinary
|
||||
// tenant users (a tenant admin authenticates here legitimately), so there is
|
||||
// no IdP-level gate the way the operator app has — these redirects plus the
|
||||
// platform-api's per-endpoint role/partnerId checks are the whole defense. Because
|
||||
// of that, /partner/* and /admin/* must fail CLOSED: if we can't positively
|
||||
// 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
|
||||
// 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.
|
||||
|
||||
export default defineNuxtRouteMiddleware(async (to) => {
|
||||
if (to.path.startsWith('/auth/') || to.path === '/signed-out') return
|
||||
|
||||
const { fetchMe, isPartnerStaff } = useMe()
|
||||
const onPartnerSurface = to.path.startsWith('/partner')
|
||||
const onAdminSurface = to.path.startsWith('/admin')
|
||||
|
||||
const { fetchMe, isPartnerStaff, isTenantAdmin } = useMe()
|
||||
const me = await fetchMe()
|
||||
if (!me) return // Not signed in yet — OIDC middleware handles the bounce
|
||||
|
||||
// Couldn't resolve identity. For non-gated routes, defer to the OIDC
|
||||
// middleware's bounce. For partner/admin routes, fail closed — unconfirmed
|
||||
// is not-authorized.
|
||||
if (!me) {
|
||||
return onPartnerSurface || onAdminSurface ? navigateTo('/') : undefined
|
||||
}
|
||||
|
||||
if (to.path === '/' && isPartnerStaff.value) {
|
||||
return navigateTo('/partner')
|
||||
}
|
||||
if (to.path.startsWith('/partner') && !isPartnerStaff.value) {
|
||||
if (onPartnerSurface && !isPartnerStaff.value) {
|
||||
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
|
||||
openIdConfiguration:
|
||||
'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',
|
||||
responseType: 'code',
|
||||
grantType: 'authorization_code',
|
||||
|
||||
+211
-237
@@ -1,76 +1,130 @@
|
||||
<script setup lang="ts">
|
||||
// Strict port of project/platform-screens.jsx `BillingScreen` (lines 1134-1257)
|
||||
// with UpdatePaymentMethodModal (1262), EditBillingDetailsModal (1357) and
|
||||
// AddSeatsModal (1415). Hero plan card on a 1.4fr/1fr split with the payment
|
||||
// + business sub-cards on the right.
|
||||
// Subscription & invoices. Real data: the plan hero (plan, seats, spend,
|
||||
// renewal), billing details (tenant.billingInfo) and the invoice history
|
||||
// (/api/tenants/:slug/invoices) all come from platform-api.
|
||||
//
|
||||
// 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 { 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 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 pauseOpen = ref(false)
|
||||
const planOpen = ref(false)
|
||||
|
||||
// AddSeats math
|
||||
const used = 11
|
||||
const current = 25
|
||||
const pricePerSeat = 78
|
||||
const daysUntilRenewal = 96
|
||||
// Add-seats math, fed by the real subscription. perSeatMonthly is already
|
||||
// cycle-normalized + in major units.
|
||||
const extra = ref(5)
|
||||
const totalSeats = computed(() => current + extra.value)
|
||||
const monthly = computed(() => extra.value * pricePerSeat)
|
||||
const prorated = computed(() => Math.round(monthly.value * (daysUntilRenewal / 30)))
|
||||
|
||||
// UpdatePaymentMethod modal state
|
||||
type Method = 'card' | 'invoice' | 'sepa'
|
||||
const method = ref<Method>('card')
|
||||
const card = reactive({ number: '', name: 'Anne Baslund', exp: '', cvc: '', country: 'DK', zip: '1620' })
|
||||
|
||||
// Edit billing details state
|
||||
const det = reactive({
|
||||
company: 'Baslund ApS',
|
||||
cvr: '42 18 09 33',
|
||||
contact: 'Anne Baslund',
|
||||
email: 'billing@dezky.com',
|
||||
addr1: 'Vesterbrogade 14',
|
||||
addr2: '',
|
||||
zip: '1620',
|
||||
city: 'København V',
|
||||
country: 'DK',
|
||||
vat: 'DK 42 18 09 33',
|
||||
currency: 'DKK',
|
||||
const pricePerSeat = computed(() => perSeatMonthly.value)
|
||||
const daysUntilRenewal = computed(() => {
|
||||
if (!renewsAt.value) return 30
|
||||
return Math.max(0, Math.round((renewsAt.value.getTime() - Date.now()) / 86_400_000))
|
||||
})
|
||||
|
||||
const invoices = [
|
||||
{ id: 'INV-2026-005', date: '01 May 2026', amount: '1.940,00 DKK', status: 'Paid' },
|
||||
{ id: 'INV-2026-004', date: '01 Apr 2026', amount: '1.940,00 DKK', status: 'Paid' },
|
||||
{ id: 'INV-2026-003', date: '01 Mar 2026', amount: '1.560,00 DKK', status: 'Paid' },
|
||||
{ id: 'INV-2026-002', date: '01 Feb 2026', amount: '1.560,00 DKK', status: 'Paid' },
|
||||
{ id: 'INV-2026-001', date: '01 Jan 2026', amount: '1.560,00 DKK', status: 'Paid' },
|
||||
]
|
||||
|
||||
const payMethods = [
|
||||
{ v: 'card' as const, l: 'Card', d: 'Visa · MC · Amex' },
|
||||
{ v: 'invoice' as const, l: 'Invoice (EAN)', d: 'Net 14 · DK B2B' },
|
||||
{ v: 'sepa' as const, l: 'SEPA · MobilePay', d: 'Direct debit · DK' },
|
||||
]
|
||||
|
||||
const totalSeats = computed(() => seatLimit.value + extra.value)
|
||||
const monthly = computed(() => extra.value * pricePerSeat.value)
|
||||
const prorated = computed(() => Math.round(monthly.value * (daysUntilRenewal.value / 30)))
|
||||
function quickSet(n: number) { extra.value = n }
|
||||
|
||||
function exportInvoices(format: 'OIOUBL' | 'CSV') {
|
||||
toast.info(`Exporting invoices as ${format}…`, format === 'OIOUBL' ? 'B2B · Nemhandel' : 'comma-separated · UTF-8')
|
||||
}
|
||||
function downloadInvoice(id: string) {
|
||||
toast.info('Downloading invoice…', id)
|
||||
function openInvoice(inv: InvoiceDoc) {
|
||||
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() {
|
||||
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>
|
||||
|
||||
@@ -80,14 +134,7 @@ function confirmPause() {
|
||||
eyebrow="Billing"
|
||||
title="Subscription & invoices"
|
||||
subtitle="Manage your plan, payment method, and tax-compliant invoices."
|
||||
>
|
||||
<template #actions>
|
||||
<UiButton variant="secondary" @click="toast.info('Bundling invoice ZIP…')">
|
||||
<template #leading><UiIcon name="download" :size="14" /></template>
|
||||
Download all (.zip)
|
||||
</UiButton>
|
||||
</template>
|
||||
</PageHeader>
|
||||
/>
|
||||
|
||||
<div class="content">
|
||||
<div class="top-row">
|
||||
@@ -97,15 +144,15 @@ function confirmPause() {
|
||||
<div class="hero-head">
|
||||
<div>
|
||||
<div class="kicker">// current plan</div>
|
||||
<div class="hero-title">Business</div>
|
||||
<div class="hero-sub">25 seats · invoiced monthly</div>
|
||||
<div class="hero-title">{{ planLabel }}</div>
|
||||
<div class="hero-sub">{{ seatLimit }} seats · invoiced {{ cycleLabel }}</div>
|
||||
</div>
|
||||
<Badge tone="accent">Renews 28 Aug 2026</Badge>
|
||||
<Badge tone="accent">{{ renewsAt ? `Renews ${fmtDate(renewsAt)}` : (subscription?.status ?? '—') }}</Badge>
|
||||
</div>
|
||||
<div class="hero-stats">
|
||||
<div><div class="hero-label">Seats used</div><div class="hero-num">11 / 25</div></div>
|
||||
<div><div class="hero-label">This month</div><div class="hero-num">1.940 DKK</div></div>
|
||||
<div><div class="hero-label">Next invoice</div><div class="hero-num">01 Jun</div></div>
|
||||
<div><div class="hero-label">Seats used</div><div class="hero-num">{{ seatsUsed }} / {{ seatLimit }}</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">{{ fmtDate(renewsAt, { day: '2-digit', month: 'short' }) }}</div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hero-actions">
|
||||
@@ -116,36 +163,40 @@ function confirmPause() {
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<!-- Payment + business details -->
|
||||
<!-- Payment (coming soon) + business details (real) -->
|
||||
<Card>
|
||||
<div class="card-head">
|
||||
<div>
|
||||
<Eyebrow>Payment</Eyebrow>
|
||||
<div class="card-title">Payment method</div>
|
||||
</div>
|
||||
<UiButton size="sm" variant="ghost" @click="paymentOpen = true">Update</UiButton>
|
||||
<UiButton size="sm" variant="ghost" @click="pmOpen = true">{{ paymentMethod ? 'Update' : 'Add card' }}</UiButton>
|
||||
</div>
|
||||
<div class="visa-row">
|
||||
<div class="visa">VISA</div>
|
||||
<div class="visa-meta">
|
||||
<div class="visa-num">•••• •••• •••• 4242</div>
|
||||
<div class="visa-sub">Expires 11/2028 · Anne Baslund</div>
|
||||
<div v-if="paymentMethod" class="pm-row">
|
||||
<div class="pm-chip">{{ paymentMethod.brand.toUpperCase() }}</div>
|
||||
<div class="pm-meta">
|
||||
<div class="pm-num">•••• •••• •••• {{ paymentMethod.last4 }}</div>
|
||||
<div class="pm-sub">Expires {{ pmExpiry(paymentMethod) }}</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>
|
||||
<Eyebrow>Business</Eyebrow>
|
||||
<div class="card-title">Billing details</div>
|
||||
</div>
|
||||
<UiButton size="sm" variant="ghost" @click="detailsOpen = true">Edit</UiButton>
|
||||
<UiButton size="sm" variant="ghost" @click="openDetails">Edit</UiButton>
|
||||
</div>
|
||||
<dl class="def">
|
||||
<div><dt>Company</dt><dd>Baslund ApS</dd></div>
|
||||
<div><dt>CVR</dt><dd>42 18 09 33</dd></div>
|
||||
<div><dt>Address</dt><dd>Vesterbrogade 14, 1620 København V</dd></div>
|
||||
<div><dt>VAT</dt><dd>DK 42 18 09 33</dd></div>
|
||||
<div><dt>Currency</dt><dd>DKK · EUR available</dd></div>
|
||||
<div><dt>Company</dt><dd>{{ billingInfo.companyName || tenant?.name || '—' }}</dd></div>
|
||||
<div><dt>VAT</dt><dd>{{ billingInfo.vatId || '—' }}</dd></div>
|
||||
<div><dt>Country</dt><dd>{{ billingInfo.country || '—' }}</dd></div>
|
||||
<div><dt>Invoice email</dt><dd>{{ billingInfo.contactEmail || '—' }}</dd></div>
|
||||
<div><dt>Currency</dt><dd>{{ currency }}</dd></div>
|
||||
</dl>
|
||||
</Card>
|
||||
</div>
|
||||
@@ -158,8 +209,8 @@ function confirmPause() {
|
||||
<div class="card-title">Invoices</div>
|
||||
</div>
|
||||
<div class="invoices-actions">
|
||||
<UiButton size="sm" variant="secondary" @click="exportInvoices('OIOUBL')">OIOUBL (B2B)</UiButton>
|
||||
<UiButton size="sm" variant="secondary" @click="exportInvoices('CSV')">CSV</UiButton>
|
||||
<UiButton size="sm" variant="secondary" :disabled="invoices.length === 0" @click="exportInvoices('OIOUBL')">OIOUBL (B2B)</UiButton>
|
||||
<UiButton size="sm" variant="secondary" :disabled="invoices.length === 0" @click="exportInvoices('CSV')">CSV</UiButton>
|
||||
</div>
|
||||
</div>
|
||||
<table class="inv-table">
|
||||
@@ -169,14 +220,18 @@ function confirmPause() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="inv in invoices" :key="inv.id">
|
||||
<td><Mono>{{ inv.id }}</Mono></td>
|
||||
<td>{{ inv.date }}</td>
|
||||
<td><span class="amount">{{ inv.amount }}</span></td>
|
||||
<td><Badge tone="ok" dot>{{ inv.status.toLowerCase() }}</Badge></td>
|
||||
<tr v-for="inv in invoices" :key="inv._id">
|
||||
<td><Mono>{{ inv.number || inv._id.slice(-8) }}</Mono></td>
|
||||
<td>{{ invDate(inv) }}</td>
|
||||
<td><span class="amount">{{ fmtMinor(inv.amountDue, inv.currency) }}</span></td>
|
||||
<td><Badge :tone="invStatusTone(inv.status)" dot>{{ inv.status }}</Badge></td>
|
||||
<td class="right">
|
||||
<UiButton size="sm" variant="ghost" @click="downloadInvoice(inv.id)"><template #leading><UiIcon name="download" :size="13" /></template>PDF</UiButton>
|
||||
<UiButton size="sm" variant="ghost" @click="viewInvoice(inv.id)"><template #leading><UiIcon name="external" :size="13" /></template>View</UiButton>
|
||||
<UiButton size="sm" variant="ghost" @click="openInvoice(inv)"><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>
|
||||
</tr>
|
||||
</tbody>
|
||||
@@ -184,103 +239,6 @@ function confirmPause() {
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- Update payment method modal -->
|
||||
<Modal :open="paymentOpen" eyebrow="Billing · payment method" title="Update payment method" size="md" @close="paymentOpen = false">
|
||||
<div class="pay">
|
||||
<div>
|
||||
<Eyebrow>Pay by</Eyebrow>
|
||||
<div class="pay-options">
|
||||
<button v-for="o in payMethods" :key="o.v" :class="{ active: method === o.v }" @click="method = o.v">
|
||||
<div class="po-label">{{ o.l }}</div>
|
||||
<Mono dim>{{ o.d }}</Mono>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-if="method === 'card'">
|
||||
<label class="field"><Eyebrow>Card number</Eyebrow>
|
||||
<div class="input-row">
|
||||
<UiIcon name="card" :size="15" stroke="var(--text-mute)" />
|
||||
<input v-model="card.number" placeholder="4242 4242 4242 4242" />
|
||||
<Mono dim>VISA · MC · AMEX</Mono>
|
||||
</div>
|
||||
</label>
|
||||
<label class="field"><Eyebrow>Name on card</Eyebrow><input class="input" v-model="card.name" /></label>
|
||||
<div class="grid-2">
|
||||
<label class="field"><Eyebrow>Expiry</Eyebrow><input class="input" v-model="card.exp" placeholder="MM / YY" /></label>
|
||||
<label class="field"><Eyebrow>CVC</Eyebrow><input class="input" v-model="card.cvc" placeholder="3 digits" /></label>
|
||||
</div>
|
||||
<div class="grid-14-1">
|
||||
<label class="field"><Eyebrow>Country</Eyebrow><CountrySelect v-model="card.country" /></label>
|
||||
<label class="field"><Eyebrow>Postal code</Eyebrow><input class="input" v-model="card.zip" /></label>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="method === 'invoice'">
|
||||
<label class="field"><Eyebrow>EAN number</Eyebrow><input class="input" placeholder="5790000000000" /></label>
|
||||
<label class="field"><Eyebrow>Purchase order reference (optional)</Eyebrow><input class="input" placeholder="Internal PO # to include on invoice" /></label>
|
||||
<div class="note">
|
||||
<Mono dim>// OIOUBL · Nemhandel</Mono>
|
||||
<div class="note-body">Invoices are delivered to your EAN via the Nemhandel network. Payment terms are <b>net 14 days</b> from invoice date. The first invoice arrives on the next billing cycle.</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<label class="field"><Eyebrow>IBAN</Eyebrow><input class="input" placeholder="DK00 0000 0000 0000 00" /></label>
|
||||
<label class="field"><Eyebrow>Account holder name</Eyebrow><input class="input" value="Baslund ApS" /></label>
|
||||
<label class="check"><input type="checkbox" checked /> I authorise Dezky to debit this account by SEPA Direct Debit</label>
|
||||
</template>
|
||||
|
||||
<div class="trust">
|
||||
<UiIcon name="shield" :size="14" stroke="var(--ok)" />
|
||||
<div>Payment details are tokenised by our processor. Dezky never sees raw card numbers, IBANs, or CVC codes. PCI DSS Level 1.</div>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<UiButton variant="ghost" @click="paymentOpen = false">Cancel</UiButton>
|
||||
<UiButton variant="primary" @click="paymentOpen = false; toast.ok('Payment method saved')">
|
||||
<template #leading><UiIcon name="check" :size="13" /></template>
|
||||
Save payment method
|
||||
</UiButton>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
<!-- Edit billing details modal -->
|
||||
<Modal :open="detailsOpen" eyebrow="Billing · business details" title="Edit billing details" size="lg" @close="detailsOpen = false">
|
||||
<div class="details">
|
||||
<div class="grid-co">
|
||||
<label class="field"><Eyebrow>Company name</Eyebrow><input class="input" v-model="det.company" /></label>
|
||||
<label class="field"><Eyebrow>CVR / org. number</Eyebrow><input class="input" v-model="det.cvr" /></label>
|
||||
</div>
|
||||
<div class="grid-2">
|
||||
<label class="field"><Eyebrow>Billing contact</Eyebrow><input class="input" v-model="det.contact" /></label>
|
||||
<label class="field"><Eyebrow>Invoice email</Eyebrow><input class="input" v-model="det.email" /></label>
|
||||
</div>
|
||||
<label class="field"><Eyebrow>Address line 1</Eyebrow><input class="input" v-model="det.addr1" /></label>
|
||||
<label class="field"><Eyebrow>Address line 2 (optional)</Eyebrow><input class="input" v-model="det.addr2" placeholder="Floor, suite, c/o…" /></label>
|
||||
<div class="grid-zip">
|
||||
<label class="field"><Eyebrow>Postal code</Eyebrow><input class="input" v-model="det.zip" /></label>
|
||||
<label class="field"><Eyebrow>City</Eyebrow><input class="input" v-model="det.city" /></label>
|
||||
<label class="field"><Eyebrow>Country</Eyebrow><CountrySelect v-model="det.country" /></label>
|
||||
</div>
|
||||
<div class="grid-co">
|
||||
<label class="field"><Eyebrow>VAT number</Eyebrow><input class="input" v-model="det.vat" /></label>
|
||||
<label class="field"><Eyebrow>Currency</Eyebrow><input class="input" v-model="det.currency" /></label>
|
||||
</div>
|
||||
<div class="note">
|
||||
<Mono dim>// VAT</Mono>
|
||||
<div class="note-body">For Danish customers, the CVR + VAT must match. Reverse-charge applies for EU B2B customers outside Denmark (we won't charge VAT, you self-account).</div>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<UiButton variant="ghost" @click="detailsOpen = false">Cancel</UiButton>
|
||||
<UiButton variant="primary" @click="detailsOpen = false; toast.ok('Billing details saved')">
|
||||
<template #leading><UiIcon name="check" :size="13" /></template>
|
||||
Save details
|
||||
</UiButton>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
<!-- Pause subscription confirmation -->
|
||||
<ConfirmDialog
|
||||
:open="pauseOpen"
|
||||
@@ -291,7 +249,7 @@ function confirmPause() {
|
||||
@close="pauseOpen = false"
|
||||
@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
|
||||
to restore full access.
|
||||
</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="plan-options">
|
||||
<button v-for="p in [
|
||||
{ id: 'basic', name: 'Basic', price: '49 DKK / seat / mo', d: 'Mail · Drev · 50 GB', current: false },
|
||||
{ id: 'business', name: 'Business · current', price: '78 DKK / seat / mo', d: 'Everything in Basic + Møder + Chat · 200 GB', current: true },
|
||||
{ id: 'enterprise', name: 'Enterprise', price: 'from 140 DKK / seat / mo', d: 'SSO contracts · audit log retention · 1 TB', current: false },
|
||||
{ id: 'mvp', name: 'Starter', d: 'Mail · Drev · 50 GB', current: planLabel === 'Starter' },
|
||||
{ id: 'pro', name: 'Business', d: 'Everything in Starter + Møder + Chat · 200 GB', current: planLabel === 'Business' },
|
||||
{ id: 'enterprise', name: 'Enterprise', d: 'SSO contracts · audit log retention · 1 TB', current: planLabel === 'Enterprise' },
|
||||
]" :key="p.id" :class="['plan-card', { active: p.current }]">
|
||||
<div class="plan-name">{{ p.name }}</div>
|
||||
<Mono dim>{{ p.price }}</Mono>
|
||||
<div class="plan-name">{{ p.name }}{{ p.current ? ' · current' : '' }}</div>
|
||||
<div class="plan-d">{{ p.d }}</div>
|
||||
</button>
|
||||
</div>
|
||||
@@ -325,8 +282,8 @@ function confirmPause() {
|
||||
<Modal :open="seatsOpen" eyebrow="Billing · seats" title="Add seats" size="md" @close="seatsOpen = false">
|
||||
<div class="seats">
|
||||
<div class="seats-3col">
|
||||
<div><Eyebrow>Active users</Eyebrow><div class="big">{{ used }}</div></div>
|
||||
<div><Eyebrow>Current seats</Eyebrow><div class="big">{{ current }}</div></div>
|
||||
<div><Eyebrow>Active users</Eyebrow><div class="big">{{ seatsUsed }}</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>
|
||||
|
||||
@@ -344,25 +301,49 @@ function confirmPause() {
|
||||
|
||||
<div class="bill-box">
|
||||
<Eyebrow>What you'll pay</Eyebrow>
|
||||
<div class="bb-row"><span>{{ extra }} new seat{{ extra === 1 ? '' : 's' }} × {{ pricePerSeat }} DKK / month</span><Mono>{{ monthly.toLocaleString('da-DK') }} DKK / mo</Mono></div>
|
||||
<div class="bb-row sep"><span class="dim">Prorated for current cycle ({{ daysUntilRenewal }} days until renewal)</span><Mono dim>{{ prorated.toLocaleString('da-DK') }} DKK</Mono></div>
|
||||
<div class="bb-row total"><span>Charged today</span><span class="hero-amount">{{ prorated.toLocaleString('da-DK') }} DKK</span></div>
|
||||
<div class="bb-row"><span class="dim">Next invoice on 01 Jun 2026</span><Mono dim>{{ (1940 + monthly).toLocaleString('da-DK') }} DKK</Mono></div>
|
||||
<div 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>{{ moneyFmt.format(prorated) }}</Mono></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"><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 class="trust">
|
||||
<UiIcon name="card" :size="14" stroke="var(--text-mute)" />
|
||||
<div>Charged to <Mono>Visa •••• 4242</Mono>. Seats are added instantly — invitations can be sent right away.</div>
|
||||
<div>Seats are added instantly — invitations can be sent right away.</div>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<UiButton variant="ghost" @click="seatsOpen = false">Cancel</UiButton>
|
||||
<UiButton variant="primary" @click="seatsOpen = false; toast.ok(`${extra} seats added · charged ${prorated.toLocaleString('da-DK')} DKK`)">
|
||||
<UiButton variant="primary" @click="seatsOpen = false; toast.ok(`${extra} seats added · charged ${moneyFmt.format(prorated)}`)">
|
||||
<template #leading><UiIcon name="plus" :size="13" /></template>
|
||||
Add {{ extra }} seat{{ extra === 1 ? '' : 's' }}
|
||||
</UiButton>
|
||||
</template>
|
||||
</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>
|
||||
</template>
|
||||
|
||||
@@ -404,7 +385,21 @@ function confirmPause() {
|
||||
letter-spacing: -0.01em;
|
||||
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;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
@@ -413,9 +408,10 @@ function confirmPause() {
|
||||
border-radius: 6px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.visa {
|
||||
width: 40px;
|
||||
.pm-chip {
|
||||
height: 28px;
|
||||
min-width: 44px;
|
||||
padding: 0 8px;
|
||||
border-radius: 4px;
|
||||
background: var(--text);
|
||||
color: var(--bg);
|
||||
@@ -427,9 +423,9 @@ function confirmPause() {
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
.visa-meta { flex: 1; }
|
||||
.visa-num { font-family: var(--font-mono); font-size: 13px; }
|
||||
.visa-sub { font-size: 11px; color: var(--text-mute); margin-top: 2px; }
|
||||
.pm-meta { flex: 1; }
|
||||
.pm-num { font-family: var(--font-mono); font-size: 13px; }
|
||||
.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 > 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 tr:last-child td { border-bottom: none; }
|
||||
.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; }
|
||||
|
||||
/* 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; }
|
||||
.empty-row { text-align: center; padding: 40px 16px; }
|
||||
|
||||
.trust {
|
||||
padding: 12px;
|
||||
@@ -566,4 +532,12 @@ function confirmPause() {
|
||||
.plan-card.active { border-color: var(--text); background: var(--bg); }
|
||||
.plan-name { font-size: 14px; font-weight: 500; }
|
||||
.plan-d { font-size: 12px; color: var(--text-mute); margin-top: 6px; }
|
||||
|
||||
/* 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>
|
||||
|
||||
@@ -1,49 +1,63 @@
|
||||
<script setup lang="ts">
|
||||
// Strict port of project/platform-screens.jsx `BrandingScreen` (lines 1542-1668)
|
||||
// with BrandingPreview (1669), UploadAssetModal (1733), EditEmailTemplatePanel
|
||||
// (1903), PublishBrandingModal (2031) and ResetBrandingModal (2148). Two-column
|
||||
// layout — controls on the left (420px), live preview on the right.
|
||||
// Customer whitelabel branding. Real data: product name + accent colour (on the
|
||||
// Tenant doc) and email-template overrides (TenantBranding), saved together via
|
||||
// PUT /api/tenants/:slug/branding. No backend yet for logo upload or custom-
|
||||
// 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 { request } = useApiFetch()
|
||||
const { fetchMe } = useMe()
|
||||
await fetchMe()
|
||||
const { tenant } = useTenant()
|
||||
const slug = computed(() => tenant.value?.slug ?? '')
|
||||
|
||||
const color = ref('#D4FF3A')
|
||||
const name = ref('Acme Workspace')
|
||||
const DEFAULT_COLOR = '#D4FF3A'
|
||||
const colorPalette = ['#D4FF3A', '#3F6BFF', '#FF6B4A', '#5B8C5A', '#9B59B6']
|
||||
|
||||
const uploadAsset = ref<typeof ASSETS[number] | null>(null)
|
||||
const uploaded = ref(false)
|
||||
const dragOver = ref(false)
|
||||
const { data: branding } = await useFetch<TenantBrandingView>(
|
||||
() => `/api/tenants/${slug.value}/branding`,
|
||||
{ key: 'admin-branding', default: () => ({ name: '', emailTemplates: [] }), immediate: !!slug.value, watch: [slug] },
|
||||
)
|
||||
|
||||
const editTemplate = ref<typeof TEMPLATES[number] | null>(null)
|
||||
const subject = ref('')
|
||||
const body = ref('')
|
||||
const testSent = ref(false)
|
||||
function sendTest() {
|
||||
testSent.value = true
|
||||
setTimeout(() => (testSent.value = false), 2500)
|
||||
// Local editable state, seeded from the fetched branding.
|
||||
const name = ref('')
|
||||
const color = ref(DEFAULT_COLOR)
|
||||
const overrides = reactive<Record<string, { subject: string; body: string }>>({})
|
||||
function seed() {
|
||||
const b = branding.value
|
||||
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 publishState = ref<'confirm' | 'publishing' | 'done'>('confirm')
|
||||
const resetOpen = ref(false)
|
||||
const primaryDomain = computed(() => branding.value?.primaryDomain ?? '')
|
||||
|
||||
const ASSETS = [
|
||||
{ id: 'full', l: 'Full logo', d: 'horizontal · 4:1 · png/svg', ratio: '4:1', formats: 'png · svg', maxKb: 400, current: false, currentName: '', currentSize: '' },
|
||||
{ id: 'mark', l: 'Square mark', d: '1:1 · transparent · png/svg', ratio: '1:1', formats: 'png · svg', maxKb: 200, current: true, currentName: 'acme-mark.svg', currentSize: '12 KB' },
|
||||
{ id: 'favicon', l: 'Favicon', d: '32×32 · ico/png', ratio: '1:1', formats: 'ico · png', maxKb: 50, current: true, currentName: 'favicon.ico', currentSize: '4 KB' },
|
||||
] as const
|
||||
// Auto-contrast foreground for surfaces painted in the accent colour, so the
|
||||
// preview stays legible whether the accent is bright or dark.
|
||||
const accentFg = computed(() => readableOn(color.value))
|
||||
const accentBtnText = computed(() => readableOn(accentFg.value))
|
||||
|
||||
// Canonical template defaults. Overrides (saved per tenant) win over these.
|
||||
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: 'reset', name: 'Password reset', subject: 'Reset your {{workspace.name}} password', desc: 'sent on forgot-password requests', edited: 'default' },
|
||||
{ id: 'digest', name: 'Notification digest', subject: 'Your weekly summary from {{workspace.name}}', desc: 'sent weekly to users opted-in for digests', edited: '2 weeks ago' },
|
||||
{ id: 'trial', name: 'Trial expiring', subject: 'Your trial ends in {{trial.days_left}} days', desc: 'sent 7 / 3 / 1 days before trial expiry', edited: 'default' },
|
||||
{ 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' },
|
||||
{ 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' },
|
||||
] as const
|
||||
|
||||
const TEMPLATE_BODIES: Record<string, string> = {
|
||||
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.
|
||||
|
||||
@@ -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'],
|
||||
}
|
||||
|
||||
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]) {
|
||||
editTemplate.value = t
|
||||
subject.value = t.subject
|
||||
body.value = TEMPLATE_BODIES[t.id] || ''
|
||||
subject.value = overrides[t.id]?.subject ?? t.subject
|
||||
body.value = overrides[t.id]?.body ?? (TEMPLATE_BODIES[t.id] || '')
|
||||
testSent.value = false
|
||||
}
|
||||
|
||||
function insertTag(tag: string) {
|
||||
body.value += `{{${tag}}}`
|
||||
}
|
||||
|
||||
// Reset the currently-open template's subject + body to the canonical default.
|
||||
function resetTemplate() {
|
||||
if (!editTemplate.value) return
|
||||
subject.value = editTemplate.value.subject
|
||||
body.value = TEMPLATE_BODIES[editTemplate.value.id] || ''
|
||||
toast.info('Template reset to default')
|
||||
}
|
||||
|
||||
// Wrap a merge-tag name in mustaches via JS so the template doesn't have to
|
||||
// nest `{{ ... }}` inside `{{ ... }}` (which Vue's parser scans positionally
|
||||
// and breaks on).
|
||||
// Wrap a merge-tag name in mustaches via JS so the template never nests
|
||||
// `{{ ... }}` inside `{{ ... }}` (Vue's parser scans positionally and breaks).
|
||||
function wrapTag(tag: string) {
|
||||
return '{' + '{' + tag + '}' + '}'
|
||||
}
|
||||
|
||||
function startPublish() {
|
||||
publishState.value = 'publishing'
|
||||
setTimeout(() => { publishState.value = 'done' }, 1800)
|
||||
function insertTag(tag: string) {
|
||||
body.value += wrapTag(tag)
|
||||
}
|
||||
|
||||
function openPublish() {
|
||||
publishOpen.value = true
|
||||
publishState.value = 'confirm'
|
||||
// Commit the open template into local overrides (header Save persists).
|
||||
function applyTemplate() {
|
||||
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(() =>
|
||||
@@ -139,13 +162,13 @@ const renderedSubject = computed(() =>
|
||||
const renderedBody = computed(() =>
|
||||
body.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\.email\}\}/g, 'anne@acme.dk')
|
||||
.replace(/\{\{user\.email\}\}/g, 'anne@example.com')
|
||||
.replace(/\{\{inviter\.name\}\}/g, 'Mikkel Nørgaard')
|
||||
.replace(/\{\{invite\.url\}\}/g, 'workspace.acme.dk/accept/x9k2a')
|
||||
.replace(/\{\{reset\.url\}\}/g, 'workspace.acme.dk/reset/p2b7c')
|
||||
.replace(/\{\{billing\.url\}\}/g, 'workspace.acme.dk/billing')
|
||||
.replace(/\{\{invite\.url\}\}/g, `${primaryDomain.value || 'your-workspace'}/accept/x9k2a`)
|
||||
.replace(/\{\{reset\.url\}\}/g, `${primaryDomain.value || 'your-workspace'}/reset/p2b7c`)
|
||||
.replace(/\{\{billing\.url\}\}/g, `${primaryDomain.value || 'your-workspace'}/billing`)
|
||||
.replace(/\{\{trial\.days_left\}\}/g, '3')
|
||||
.replace(/\{\{stats\.messages\}\}/g, '1.840')
|
||||
.replace(/\{\{stats\.files\}\}/g, '24')
|
||||
@@ -153,6 +176,31 @@ const renderedBody = computed(() =>
|
||||
.replace(/\{\{stats\.users\}\}/g, '8')
|
||||
.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>
|
||||
|
||||
<template>
|
||||
@@ -160,11 +208,13 @@ const renderedBody = computed(() =>
|
||||
<PageHeader
|
||||
eyebrow="Whitelabel"
|
||||
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>
|
||||
<UiButton variant="ghost" @click="resetOpen = true">Reset</UiButton>
|
||||
<UiButton variant="primary" @click="openPublish">Publish</UiButton>
|
||||
<UiButton variant="primary" :disabled="saving || !slug" @click="save">
|
||||
<template #leading><UiIcon name="check" :size="14" /></template>
|
||||
{{ saving ? 'Saving…' : 'Save changes' }}
|
||||
</UiButton>
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
@@ -175,9 +225,11 @@ const renderedBody = computed(() =>
|
||||
<div class="card-head"><Eyebrow>Identity</Eyebrow><div class="card-title">Product identity</div></div>
|
||||
<label class="field"><Eyebrow>Product name (shown to users)</Eyebrow><input class="input" v-model="name" /></label>
|
||||
<label class="field"><Eyebrow>Custom domain</Eyebrow>
|
||||
<div class="input-row">
|
||||
<input value="workspace.acme.dk" readonly />
|
||||
<UiIcon name="check" :size="12" stroke="var(--ok)" :stroke-width="2.5" />
|
||||
<div v-if="primaryDomain" class="input-row">
|
||||
<input :value="primaryDomain" readonly />
|
||||
</div>
|
||||
<div v-else class="soon-inline">
|
||||
<Mono dim>Custom domains coming soon</Mono>
|
||||
</div>
|
||||
</label>
|
||||
</Card>
|
||||
@@ -199,32 +251,22 @@ const renderedBody = computed(() =>
|
||||
|
||||
<Card>
|
||||
<div class="card-head"><Eyebrow>Assets</Eyebrow><div class="card-title">Logo upload</div></div>
|
||||
<div class="assets">
|
||||
<div v-for="a in ASSETS" :key="a.id" class="asset" :class="{ has: a.current }">
|
||||
<div class="asset-icon" :style="{ color: a.current ? 'var(--ok)' : 'var(--text-mute)' }">
|
||||
<UiIcon :name="a.current ? 'check' : 'upload'" :size="16" :stroke-width="a.current ? 2.5 : 2" />
|
||||
</div>
|
||||
<div class="asset-meta">
|
||||
<div class="asset-l">{{ a.l }}</div>
|
||||
<Mono dim>{{ a.current ? `${a.currentName} · ${a.currentSize}` : a.d }}</Mono>
|
||||
</div>
|
||||
<UiButton size="sm" :variant="a.current ? 'ghost' : 'secondary'" @click="uploadAsset = a as any; uploaded = false">
|
||||
{{ a.current ? 'Replace' : 'Upload' }}
|
||||
</UiButton>
|
||||
</div>
|
||||
<div class="soon-box">
|
||||
<UiIcon name="upload" :size="16" stroke="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>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<div class="card-head"><Eyebrow>Templates</Eyebrow><div class="card-title">Email templates</div></div>
|
||||
<div class="templates">
|
||||
<button v-for="t in TEMPLATES" :key="t.id" class="tmpl-row" @click="openTemplate(t as any)">
|
||||
<button v-for="t in TEMPLATES" :key="t.id" class="tmpl-row" @click="openTemplate(t)">
|
||||
<div class="tmpl-meta">
|
||||
<div class="tmpl-name-row">
|
||||
<span class="tmpl-name">{{ t.name }}</span>
|
||||
<Badge :tone="t.edited === 'default' ? 'neutral' : 'info'">{{ t.edited === 'default' ? 'default' : 'edited' }}</Badge>
|
||||
<Badge :tone="isEdited(t.id) ? 'info' : 'neutral'">{{ isEdited(t.id) ? 'edited' : 'default' }}</Badge>
|
||||
</div>
|
||||
<Mono dim>edited {{ t.edited }}</Mono>
|
||||
<Mono dim>{{ t.desc }}</Mono>
|
||||
</div>
|
||||
<UiIcon name="chevRight" :size="14" stroke="var(--text-mute)" />
|
||||
</button>
|
||||
@@ -236,18 +278,18 @@ const renderedBody = computed(() =>
|
||||
<div class="preview-col">
|
||||
<div class="preview-head">
|
||||
<Eyebrow>Live preview</Eyebrow>
|
||||
<Mono dim>workspace.acme.dk</Mono>
|
||||
<Mono dim>{{ primaryDomain || slug }}</Mono>
|
||||
</div>
|
||||
<div class="preview-frame">
|
||||
<div class="frame-topbar">
|
||||
<div class="frame-mark" :style="{ background: color }">{{ name[0]?.toLowerCase() || 'a' }}</div>
|
||||
<div class="frame-mark" :style="{ background: color, color: accentFg }">{{ name[0]?.toLowerCase() || 'a' }}</div>
|
||||
<div class="frame-brand">{{ name.toLowerCase() }}</div>
|
||||
<div class="frame-spacer" />
|
||||
<div class="frame-user">anne@acme.dk</div>
|
||||
<div class="frame-user">{{ primaryDomain ? `you@${primaryDomain}` : 'your team' }}</div>
|
||||
</div>
|
||||
<div class="frame-hero">
|
||||
<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 v-for="n in ['Mail', 'Drev', 'Møder', 'Chat']" :key="n" class="frame-tile">
|
||||
<div class="frame-tile-icon">{{ n[0] }}</div>
|
||||
@@ -256,10 +298,10 @@ const renderedBody = computed(() =>
|
||||
</div>
|
||||
<div class="frame-cta" :style="{ background: color }">
|
||||
<div>
|
||||
<div class="frame-cta-title">Welcome to {{ name }}.</div>
|
||||
<div class="frame-cta-sub">Your team's workspace is ready.</div>
|
||||
<div class="frame-cta-title" :style="{ color: accentFg }">Welcome to {{ name || 'your workspace' }}.</div>
|
||||
<div class="frame-cta-sub" :style="{ color: accentFg, opacity: 0.75 }">Your team's workspace is ready.</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 class="frame-foot">
|
||||
@@ -270,86 +312,6 @@ const renderedBody = computed(() =>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Upload asset modal -->
|
||||
<Modal :open="!!uploadAsset" :eyebrow="uploadAsset ? `Branding · ${uploadAsset.l.toLowerCase()}` : ''" :title="uploadAsset ? `Upload ${uploadAsset.l.toLowerCase()}` : ''" size="md" @close="uploadAsset = null">
|
||||
<div v-if="uploadAsset" class="upload">
|
||||
<button v-if="!uploaded" class="dropzone" :class="{ over: dragOver }"
|
||||
@dragover.prevent="dragOver = true"
|
||||
@dragleave="dragOver = false"
|
||||
@drop.prevent="dragOver = false; uploaded = true"
|
||||
@click="uploaded = true">
|
||||
<UiIcon name="upload" :size="28" stroke="var(--text-mute)" />
|
||||
<div class="drop-text">
|
||||
<div class="drop-title">Drop {{ uploadAsset.l.toLowerCase() }} here, or click to browse</div>
|
||||
<Mono dim>{{ uploadAsset.formats }} · {{ uploadAsset.ratio }} ratio · up to {{ uploadAsset.maxKb }} KB</Mono>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<template v-if="uploaded">
|
||||
<div class="upload-preview">
|
||||
<div class="upload-mark" :style="{ width: uploadAsset.id === 'full' ? '96px' : '56px' }">
|
||||
{{ uploadAsset.id === 'full' ? 'acme' : 'a' }}
|
||||
</div>
|
||||
<div class="upload-meta">
|
||||
<div class="upload-name">{{ uploadAsset.id === 'favicon' ? 'favicon-new.png' : uploadAsset.id === 'mark' ? 'acme-mark-v2.svg' : 'acme-logo.svg' }}</div>
|
||||
<Mono dim>{{ uploadAsset.id === 'favicon' ? '6 KB' : uploadAsset.id === 'mark' ? '14 KB' : '38 KB' }} · {{ uploadAsset.id === 'favicon' ? '32×32' : uploadAsset.id === 'mark' ? '512×512' : '1200×300' }} · clean alpha</Mono>
|
||||
</div>
|
||||
<UiButton size="sm" variant="ghost" @click="uploaded = false">Replace</UiButton>
|
||||
</div>
|
||||
<Eyebrow>Looks good</Eyebrow>
|
||||
<div class="check-list">
|
||||
<div class="check-row">
|
||||
<UiIcon name="check" :size="12" stroke="var(--ok)" :stroke-width="2.5" />
|
||||
<Mono dim>Format</Mono>
|
||||
<span>{{ uploadAsset.formats.split(' · ')[0] }} ✓</span>
|
||||
</div>
|
||||
<div class="check-row">
|
||||
<UiIcon name="check" :size="12" stroke="var(--ok)" :stroke-width="2.5" />
|
||||
<Mono dim>Dimensions</Mono>
|
||||
<span>{{ uploadAsset.id === 'favicon' ? '32×32 ✓' : uploadAsset.ratio + ' ✓' }}</span>
|
||||
</div>
|
||||
<div class="check-row">
|
||||
<UiIcon name="check" :size="12" stroke="var(--ok)" :stroke-width="2.5" />
|
||||
<Mono dim>Size</Mono>
|
||||
<span>{{ uploadAsset.id === 'favicon' ? '6 KB' : uploadAsset.id === 'mark' ? '14 KB' : '38 KB' }} (under {{ uploadAsset.maxKb }} KB)</span>
|
||||
</div>
|
||||
<div class="check-row">
|
||||
<UiIcon name="check" :size="12" stroke="var(--ok)" :stroke-width="2.5" />
|
||||
<Mono dim>Transparency</Mono>
|
||||
<span>{{ uploadAsset.id === 'favicon' ? 'opaque background OK' : 'transparent background ✓' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ld-preview">
|
||||
<Eyebrow>Preview · on light + dark</Eyebrow>
|
||||
<div class="ld-grid">
|
||||
<div class="ld-light">
|
||||
<div class="ld-mark dark" :style="{ width: uploadAsset.id === 'full' ? '80px' : '32px' }">{{ uploadAsset.id === 'full' ? 'acme' : 'a' }}</div>
|
||||
</div>
|
||||
<div class="ld-dark">
|
||||
<div class="ld-mark light" :style="{ width: uploadAsset.id === 'full' ? '80px' : '32px' }">{{ uploadAsset.id === 'full' ? 'acme' : 'a' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="req-box">
|
||||
<Mono dim>// requirements</Mono>
|
||||
<div class="req-body">
|
||||
<template v-if="uploadAsset.id === 'full'">Used in the top navigation bar, login screen, and email headers. Roughly 200×50 displayed — supply at 2× minimum.</template>
|
||||
<template v-else-if="uploadAsset.id === 'mark'">Used as the app icon, favicon fallback, and any compact context (PWA install, notifications). Must read at 24×24.</template>
|
||||
<template v-else>Browser tab icon and bookmark badge. 32×32 is the standard size — modern browsers use the same file at 16×16.</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<UiButton variant="ghost" @click="uploadAsset = null">Cancel</UiButton>
|
||||
<UiButton variant="primary" :disabled="!uploaded" @click="uploadAsset = null">
|
||||
<template #leading><UiIcon name="check" :size="13" /></template>
|
||||
{{ uploaded ? 'Use this asset' : 'Select a file to continue' }}
|
||||
</UiButton>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
<!-- Edit email template side panel -->
|
||||
<SidePanel :open="!!editTemplate" :eyebrow="'Email template'" :title="editTemplate?.name || ''" width="lg" @close="editTemplate = null">
|
||||
<div v-if="editTemplate" class="tmpl-edit">
|
||||
@@ -372,12 +334,12 @@ const renderedBody = computed(() =>
|
||||
<div class="email-head">
|
||||
<div class="from-row">
|
||||
<div class="from-mark" :style="{ background: '#0A0A0A', color }">{{ name[0]?.toLowerCase() || 'a' }}</div>
|
||||
<Mono dim>From: {{ name.toLowerCase().replace(/\s+/g, '-') }}@dezky.com</Mono>
|
||||
<Mono dim>From: {{ (name || 'workspace').toLowerCase().replace(/\s+/g, '-') }}</Mono>
|
||||
</div>
|
||||
<div class="email-subj">{{ renderedSubject }}</div>
|
||||
</div>
|
||||
<div class="email-body">{{ renderedBody }}</div>
|
||||
<div class="email-foot" :style="{ background: color }">{{ name }} · workspace.acme.dk</div>
|
||||
<div class="email-foot" :style="{ background: color, color: accentFg }">{{ name || 'Your workspace' }}{{ primaryDomain ? ` · ${primaryDomain}` : '' }}</div>
|
||||
</div>
|
||||
<Mono dim style="text-align: center; display: block;">preview substitutes sample data · real send uses recipient's data</Mono>
|
||||
</div>
|
||||
@@ -390,123 +352,14 @@ const renderedBody = computed(() =>
|
||||
<div style="flex: 1" />
|
||||
<UiButton variant="secondary" @click="sendTest">
|
||||
<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 variant="primary" @click="editTemplate = null">
|
||||
<UiButton variant="primary" @click="applyTemplate">
|
||||
<template #leading><UiIcon name="check" :size="13" /></template>
|
||||
Save template
|
||||
Apply
|
||||
</UiButton>
|
||||
</template>
|
||||
</SidePanel>
|
||||
|
||||
<!-- Publish modal -->
|
||||
<Modal :open="publishOpen" eyebrow="Branding · publish" :title="publishState === 'done' ? 'Branding published' : 'Publish branding changes?'" size="md" @close="publishState !== 'publishing' ? (publishOpen = false) : null">
|
||||
<template v-if="publishState === 'confirm'">
|
||||
<div class="publish-intro">These changes will replace dezky's branding for everyone in your workspace within ~30 seconds.</div>
|
||||
<Eyebrow>Will go live</Eyebrow>
|
||||
<div class="publish-summary">
|
||||
<div class="ps-row"><Mono dim>Product name</Mono><span>{{ name }}</span></div>
|
||||
<div class="ps-row">
|
||||
<Mono dim>Primary color</Mono>
|
||||
<span class="color-line">
|
||||
<span class="color-chip" :style="{ background: color }" />
|
||||
<Mono>{{ color }}</Mono>
|
||||
</span>
|
||||
</div>
|
||||
<div class="ps-row">
|
||||
<Mono dim>Custom domain</Mono>
|
||||
<Mono>workspace.acme.dk</Mono>
|
||||
<Badge tone="ok" dot>verified</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<Eyebrow>Propagates to</Eyebrow>
|
||||
<div class="prop-grid">
|
||||
<div v-for="[k, t] in [
|
||||
['Web app · workspace shell', '~10s'],
|
||||
['Login + auth pages', '~10s'],
|
||||
['Outbound email templates', '~30s'],
|
||||
['Mobile app · next session', 'on next launch'],
|
||||
['Status page', '~30s'],
|
||||
['PDF invoices', 'next billing cycle'],
|
||||
]" :key="k" class="prop-cell">
|
||||
<UiIcon name="check" :size="11" stroke="var(--ok)" :stroke-width="2.5" />
|
||||
<span>{{ k }}</span>
|
||||
<Mono dim>{{ t }}</Mono>
|
||||
</div>
|
||||
</div>
|
||||
<div class="publish-warn">
|
||||
<UiIcon name="shield" :size="14" stroke="var(--warn)" />
|
||||
<div>Users may need to hard-refresh to see the new branding immediately. You can revert with one click for the next 7 days.</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="publishState === 'publishing'">
|
||||
<div class="publishing">
|
||||
<div class="spinner" />
|
||||
<div class="publish-title">Publishing across services…</div>
|
||||
<Mono dim>web shell · auth · mail templates · CDN</Mono>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="done-head">
|
||||
<div class="done-badge" :style="{ background: color }">
|
||||
<UiIcon name="check" :size="20" :stroke-width="2.5" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="publish-title">{{ name }} branding is live</div>
|
||||
<Mono dim>5 services updated · 1 queued for next cycle</Mono>
|
||||
</div>
|
||||
</div>
|
||||
<div class="done-list">
|
||||
<dl class="def">
|
||||
<div><dt>Web app + auth</dt><dd>live · 8 seconds</dd></div>
|
||||
<div><dt>Email templates</dt><dd>live · 18 seconds</dd></div>
|
||||
<div><dt>Mobile · status · CDN</dt><dd>queued · ~30s</dd></div>
|
||||
<div><dt>PDF invoices</dt><dd>starts 01 Jun 2026</dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<template v-if="publishState === 'confirm'">
|
||||
<UiButton variant="ghost" @click="publishOpen = false">Cancel</UiButton>
|
||||
<UiButton variant="primary" @click="startPublish">
|
||||
<template #leading><UiIcon name="external" :size="13" /></template>
|
||||
Publish now
|
||||
</UiButton>
|
||||
</template>
|
||||
<template v-else-if="publishState === 'publishing'">
|
||||
<UiButton variant="ghost" disabled>Publishing…</UiButton>
|
||||
</template>
|
||||
<template v-else>
|
||||
<UiButton variant="primary" @click="publishOpen = false">Done</UiButton>
|
||||
</template>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
<!-- Reset branding modal -->
|
||||
<Modal :open="resetOpen" eyebrow="Destructive · reverts to defaults" title="Reset branding to dezky defaults?" size="sm" @close="resetOpen = false">
|
||||
<div class="reset-box bad">
|
||||
<UiIcon name="shield" :size="16" stroke="var(--bad)" />
|
||||
<div>Reverts product name, colors, logos, and email templates to dezky defaults. Your custom domain stays connected. Edits made today are kept for 7 days and can be restored from your audit log.</div>
|
||||
</div>
|
||||
<div class="reset-list">
|
||||
<dl class="def">
|
||||
<div><dt>Product name</dt><dd>Acme Workspace → dezky</dd></div>
|
||||
<div><dt>Primary color</dt><dd>#D4FF3A → #D4FF3A (default)</dd></div>
|
||||
<div><dt>Full logo</dt><dd>will be removed</dd></div>
|
||||
<div><dt>Square mark</dt><dd>will be removed</dd></div>
|
||||
<div><dt>Favicon</dt><dd>will be removed</dd></div>
|
||||
<div><dt>Email templates</dt><dd>2 edited templates → defaults</dd></div>
|
||||
<div><dt>Custom domain</dt><dd>workspace.acme.dk · kept</dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
<template #footer>
|
||||
<UiButton variant="ghost" @click="resetOpen = false">Cancel</UiButton>
|
||||
<UiButton variant="danger" @click="resetOpen = false">
|
||||
<template #leading><UiIcon name="refresh" :size="13" /></template>
|
||||
Reset everything
|
||||
</UiButton>
|
||||
</template>
|
||||
</Modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -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); }
|
||||
.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 button { width: 38px; height: 38px; border-radius: 6px; cursor: pointer; }
|
||||
|
||||
.assets { display: flex; flex-direction: column; gap: 10px; }
|
||||
.asset {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
border: 1px dashed var(--border);
|
||||
border-radius: 6px;
|
||||
}
|
||||
.asset.has { background: var(--surface); border-style: solid; }
|
||||
.asset-icon { width: 40px; height: 40px; border-radius: 6px; background: var(--bg); display: inline-flex; align-items: center; justify-content: center; }
|
||||
.asset-meta { flex: 1; min-width: 0; }
|
||||
.asset-l { font-size: 13px; font-weight: 500; }
|
||||
|
||||
.templates { display: flex; flex-direction: column; }
|
||||
.tmpl-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 12px 0; border-bottom: 1px solid var(--border); background: transparent; border-left: none; border-right: none; border-top: none; text-align: left; color: var(--text); font-family: inherit; font-size: 13px; cursor: pointer; }
|
||||
.tmpl-row:last-child { border-bottom: none; }
|
||||
@@ -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-foot { padding: 12px 32px; border-top: 1px solid #E6E4DC; background: #F4F3EE; font-size: 11px; color: #5A5A55; font-family: var(--font-mono); display: flex; justify-content: space-between; }
|
||||
|
||||
/* Upload modal */
|
||||
.upload { display: flex; flex-direction: column; gap: 14px; }
|
||||
.dropzone {
|
||||
padding: 48px 24px;
|
||||
background: var(--bg);
|
||||
border: 2px dashed var(--border);
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
}
|
||||
.dropzone.over { background: var(--surface); border-color: var(--text); }
|
||||
.drop-text { text-align: center; }
|
||||
.drop-title { font-size: 14px; font-weight: 500; color: var(--text); }
|
||||
.upload-preview {
|
||||
padding: 16px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
}
|
||||
.upload-mark {
|
||||
height: 56px;
|
||||
background: var(--text);
|
||||
color: var(--bg);
|
||||
border-radius: 6px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: var(--font-mono);
|
||||
font-weight: 700;
|
||||
font-size: 18px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.upload-meta { flex: 1; min-width: 0; }
|
||||
.upload-name { font-size: 13px; font-weight: 500; }
|
||||
.check-list { display: flex; flex-direction: column; gap: 6px; }
|
||||
.check-row { display: flex; align-items: center; gap: 10px; padding: 8px 12px; background: var(--bg); border-radius: 6px; border: 1px solid var(--border); font-size: 12px; }
|
||||
.check-row > :first-of-type { flex-shrink: 0; }
|
||||
.ld-preview { padding: 12px; background: var(--bg); border-radius: 6px; border: 1px solid var(--border); }
|
||||
.ld-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-top: 8px; }
|
||||
.ld-light, .ld-dark { border-radius: 6px; padding: 18px; display: flex; align-items: center; justify-content: center; }
|
||||
.ld-light { background: #FAFAF7; }
|
||||
.ld-dark { background: #0A0A0A; }
|
||||
.ld-mark {
|
||||
height: 32px;
|
||||
border-radius: 4px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: var(--font-mono);
|
||||
font-weight: 700;
|
||||
font-size: 12px;
|
||||
}
|
||||
.ld-mark.dark { background: #0A0A0A; color: #F4F3EE; }
|
||||
.ld-mark.light { background: #F4F3EE; color: #0A0A0A; }
|
||||
.req-box { padding: 12px; background: var(--bg); border-radius: 6px; border: 1px solid var(--border); font-size: 12px; color: var(--text-mute); line-height: 1.55; }
|
||||
.req-body { margin-top: 6px; }
|
||||
|
||||
/* Email template editor */
|
||||
.tmpl-edit { display: grid; grid-template-columns: 1fr 1fr; min-height: 0; height: 100%; }
|
||||
.tmpl-col { padding: 24px; border-right: 1px solid var(--border); display: flex; flex-direction: column; gap: 14px; }
|
||||
@@ -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-body { padding: 20px; font-size: 13px; line-height: 1.65; color: #3A3A35; white-space: pre-wrap; flex: 1; overflow-y: auto; }
|
||||
.email-foot { padding: 14px 20px; border-top: 1px solid #E6E4DC; color: #0A0A0A; font-size: 11px; font-family: var(--font-mono); text-align: center; }
|
||||
|
||||
/* Publish modal */
|
||||
.publish-intro { font-size: 13px; color: var(--text-dim); line-height: 1.55; margin-bottom: 14px; }
|
||||
.publish-summary { padding: 14px; background: var(--bg); border-radius: 6px; border: 1px solid var(--border); display: flex; flex-direction: column; gap: 10px; margin-top: 8px; margin-bottom: 14px; }
|
||||
.ps-row { display: flex; align-items: center; gap: 12px; }
|
||||
.ps-row > :first-child { width: 100px; }
|
||||
.color-line { display: inline-flex; align-items: center; gap: 8px; font-size: 13px; }
|
||||
.color-chip { width: 14px; height: 14px; border-radius: 3px; border: 1px solid var(--border); }
|
||||
.prop-grid { padding: 14px; background: var(--bg); border-radius: 6px; border: 1px solid var(--border); display: grid; grid-template-columns: 1fr 1fr; gap: 10px; font-size: 12px; margin-top: 8px; margin-bottom: 14px; }
|
||||
.prop-cell { display: flex; align-items: center; gap: 8px; }
|
||||
.prop-cell span:first-of-type { flex: 1; }
|
||||
.publish-warn { padding: 12px; background: rgba(232, 154, 31, 0.06); border-radius: 6px; border: 1px solid rgba(232, 154, 31, 0.2); font-size: 12px; color: var(--text-dim); line-height: 1.55; display: flex; gap: 10px; }
|
||||
.publishing { padding: 32px 0; text-align: center; }
|
||||
.spinner {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
margin: 0 auto 18px auto;
|
||||
border-radius: 999px;
|
||||
border: 3px solid var(--border);
|
||||
border-top-color: var(--accent);
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg) } }
|
||||
.publish-title { font-family: var(--font-display); font-size: 20px; font-weight: 600; }
|
||||
.done-head { display: flex; align-items: center; gap: 14px; margin-bottom: 14px; }
|
||||
.done-badge {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 10px;
|
||||
color: #0A0A0A;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.done-list { padding: 14px; background: var(--bg); border-radius: 6px; border: 1px solid var(--border); }
|
||||
.def { margin: 0; display: grid; grid-template-columns: 140px 1fr; row-gap: 12px; column-gap: 16px; }
|
||||
.def > div { display: contents; }
|
||||
.def dt { font-family: var(--font-mono); font-size: 10px; letter-spacing: 0.12em; text-transform: uppercase; color: var(--text-mute); }
|
||||
.def dd { margin: 0; font-size: 13px; color: var(--text); }
|
||||
|
||||
/* Reset modal */
|
||||
.reset-box { padding: 14px; border-radius: 6px; display: flex; gap: 10px; align-items: flex-start; margin-bottom: 14px; }
|
||||
.reset-box.bad { background: rgba(226, 48, 48, 0.06); border: 1px solid rgba(226, 48, 48, 0.2); }
|
||||
.reset-box > div { font-size: 13px; color: var(--text-dim); line-height: 1.5; }
|
||||
.reset-list { padding: 12px; background: var(--bg); border-radius: 6px; border: 1px solid var(--border); }
|
||||
</style>
|
||||
|
||||
@@ -1,319 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
// Strict port of project/platform-app.jsx `DomainsScreen` (lines 440-585) +
|
||||
// `DomainCard` (502) + `DomainRecordDetail` (586). Each domain card shows
|
||||
// monospace name, status badge, "X records to fix" hint, Re-check button,
|
||||
// and a 4-record grid (MX/SPF/DKIM/DMARC) clickable to expand inline detail.
|
||||
|
||||
|
||||
import { sampleDomainsFlat } from '~/data/workspace'
|
||||
|
||||
const router = useRouter()
|
||||
const toast = useToast()
|
||||
|
||||
type Tone = 'ok' | 'warn' | 'bad'
|
||||
type RecordKey = 'mx' | 'spf' | 'dkim' | 'dmarc'
|
||||
|
||||
// DNS_FIX (platform-app.jsx line 459) — copy strings, record values, per-status headlines.
|
||||
const DNS_FIX: Record<RecordKey, {
|
||||
label: string
|
||||
purpose: string
|
||||
record: { type: string; host: string; value: string; priority?: number; ttl: number }
|
||||
states: Record<Tone, { headline: string; body: string }>
|
||||
}> = {
|
||||
mx: {
|
||||
label: 'MX · mail exchange',
|
||||
purpose: 'Routes inbound mail for this domain to dezky.',
|
||||
record: { type: 'MX', host: '@', value: 'mx.dezky.com', priority: 10, ttl: 3600 },
|
||||
states: {
|
||||
ok: { headline: 'Mail routing healthy', body: 'Inbound mail flows to dezky correctly. Verified 4 minutes ago.' },
|
||||
warn: { headline: 'Lower-priority MX detected', body: 'A secondary MX outside of dezky was found. This is allowed for failover but make sure it forwards back to mx.dezky.com.' },
|
||||
bad: { headline: 'No MX record found', body: 'Mail to this domain will not reach dezky. Add the record below at your DNS provider.' },
|
||||
},
|
||||
},
|
||||
spf: {
|
||||
label: 'SPF · sender policy',
|
||||
purpose: 'Tells receiving servers which IPs are allowed to send for this domain.',
|
||||
record: { type: 'TXT', host: '@', value: 'v=spf1 include:_spf.dezky.com -all', ttl: 3600 },
|
||||
states: {
|
||||
ok: { headline: 'SPF aligned', body: 'Your SPF record correctly authorises dezky as a sender. Verified 4 minutes ago.' },
|
||||
warn: { headline: 'SPF includes dezky but ends with ~all (softfail)', body: 'Receiving mail servers may still accept spoofed mail. Change the trailing mechanism to -all (hardfail) for stronger protection.' },
|
||||
bad: { headline: 'No SPF record', body: 'Mail sent from this domain via dezky will fail Gmail/Outlook authentication.' },
|
||||
},
|
||||
},
|
||||
dkim: {
|
||||
label: 'DKIM · message signing',
|
||||
purpose: 'Cryptographic signature proving the message was not altered in transit.',
|
||||
record: { type: 'CNAME', host: 'dezky._domainkey', value: 'dkim.dezky.com', ttl: 3600 },
|
||||
states: {
|
||||
ok: { headline: 'DKIM signing live', body: 'Outbound mail is signed with selector dezky. Verified 4 minutes ago.' },
|
||||
warn: { headline: 'DKIM CNAME points somewhere else', body: 'A DKIM record exists but does not delegate to dezky. Replace it with the CNAME below.' },
|
||||
bad: { headline: 'No DKIM record', body: 'Outbound mail will be signed but receiving servers cannot verify the signature.' },
|
||||
},
|
||||
},
|
||||
dmarc: {
|
||||
label: 'DMARC · policy enforcement',
|
||||
purpose: 'Tells receiving servers what to do with mail that fails SPF or DKIM.',
|
||||
record: { type: 'TXT', host: '_dmarc', value: 'v=DMARC1; p=quarantine; rua=mailto:dmarc@dezky.com; pct=100; adkim=s; aspf=s', ttl: 3600 },
|
||||
states: {
|
||||
ok: { headline: 'DMARC at quarantine', body: 'Spoofed mail will be sent to spam at Gmail/Outlook. Aggregate reports flowing.' },
|
||||
warn: { headline: 'DMARC at p=none', body: 'You’re collecting reports but not enforcing. Raise to quarantine once your SPF/DKIM look stable for a week.' },
|
||||
bad: { headline: 'No DMARC record', body: 'Anyone can spoof this domain. Mail from this domain may fail Gmail / Outlook spam checks.' },
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const expanded = reactive<Record<string, RecordKey | null>>({})
|
||||
const copied = ref<string | null>(null)
|
||||
function toggle(domain: string, key: RecordKey) {
|
||||
expanded[domain] = expanded[domain] === key ? null : key
|
||||
}
|
||||
function copyValue(text: string) {
|
||||
if (typeof navigator !== 'undefined' && navigator.clipboard) navigator.clipboard.writeText(text)
|
||||
copied.value = text
|
||||
setTimeout(() => { if (copied.value === text) copied.value = null }, 1400)
|
||||
toast.ok('Copied to clipboard')
|
||||
}
|
||||
|
||||
function issuesFor(d: typeof sampleDomainsFlat[number]) {
|
||||
return (['mx', 'spf', 'dkim', 'dmarc'] as const).filter((k) => d[k] !== 'ok')
|
||||
}
|
||||
|
||||
function statusIcon(tone: Tone): 'check' | 'shield' | 'x' {
|
||||
return tone === 'ok' ? 'check' : tone === 'warn' ? 'shield' : 'x'
|
||||
}
|
||||
|
||||
function recordTint(tone: Tone) {
|
||||
return tone === 'bad' ? 'rgba(226,48,48,0.12)'
|
||||
: tone === 'warn' ? 'rgba(232,154,31,0.12)'
|
||||
: 'rgba(91,140,90,0.12)'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<PageHeader
|
||||
eyebrow="Identity"
|
||||
title="Domains"
|
||||
subtitle="Your verified domains for mail, SSO, and user provisioning."
|
||||
>
|
||||
<template #actions>
|
||||
<UiButton variant="primary" @click="router.push('/admin/domains/add')">
|
||||
<template #leading><UiIcon name="plus" :size="14" /></template>
|
||||
Add domain
|
||||
</UiButton>
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<div class="content">
|
||||
<Card v-for="d in sampleDomainsFlat" :key="d.domain">
|
||||
<div class="head">
|
||||
<UiIcon name="globe" :size="20" stroke="var(--text-mute)" />
|
||||
<div class="title">
|
||||
<div class="domain-name">{{ d.domain }}</div>
|
||||
<div class="domain-sub">
|
||||
{{ d.users }} mailboxes
|
||||
<template v-if="issuesFor(d).length">
|
||||
· <span class="warn">{{ issuesFor(d).length }} record{{ issuesFor(d).length === 1 ? '' : 's' }} to fix</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<UiButton v-if="issuesFor(d).length" size="sm" variant="secondary" @click.stop="toast.ok('Re-checking ' + d.domain)">
|
||||
<template #leading><UiIcon name="refresh" :size="12" /></template>
|
||||
Re-check now
|
||||
</UiButton>
|
||||
<Badge :tone="d.status === 'ok' ? 'ok' : 'warn'" dot>{{ d.status === 'ok' ? 'verified' : 'attention' }}</Badge>
|
||||
</div>
|
||||
|
||||
<div class="records">
|
||||
<button
|
||||
v-for="k in (['mx', 'spf', 'dkim', 'dmarc'] as RecordKey[])"
|
||||
:key="k"
|
||||
class="rec"
|
||||
:class="{ active: expanded[d.domain] === k }"
|
||||
@click="toggle(d.domain, k)"
|
||||
>
|
||||
<Mono>{{ k.toUpperCase() }}</Mono>
|
||||
<div class="rec-right">
|
||||
<Badge :tone="d[k]" dot>{{ d[k] }}</Badge>
|
||||
<UiIcon :name="expanded[d.domain] === k ? 'chevDown' : 'chevRight'" :size="11" stroke="var(--text-mute)" />
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="expanded[d.domain]" class="detail" :data-tone="d[expanded[d.domain]!]">
|
||||
<div class="detail-head">
|
||||
<div class="detail-icon" :style="{ background: recordTint(d[expanded[d.domain]!] as Tone), color: `var(--${d[expanded[d.domain]!]})` }">
|
||||
<UiIcon :name="statusIcon(d[expanded[d.domain]!] as Tone)" :size="14" :stroke-width="d[expanded[d.domain]!] === 'ok' ? 2.5 : 2" />
|
||||
</div>
|
||||
<div class="detail-body">
|
||||
<div class="detail-title">
|
||||
{{ DNS_FIX[expanded[d.domain]!].states[d[expanded[d.domain]!] as Tone].headline }}
|
||||
<Mono dim>{{ DNS_FIX[expanded[d.domain]!].label }}</Mono>
|
||||
</div>
|
||||
<div class="detail-text">{{ DNS_FIX[expanded[d.domain]!].states[d[expanded[d.domain]!] as Tone].body }}</div>
|
||||
<Mono dim style="display: block; margin-top: 10px">{{ DNS_FIX[expanded[d.domain]!].purpose }}</Mono>
|
||||
</div>
|
||||
<button class="detail-close" @click="expanded[d.domain] = null"><UiIcon name="x" :size="14" /></button>
|
||||
</div>
|
||||
|
||||
<template v-if="d[expanded[d.domain]!] !== 'ok'">
|
||||
<div class="rec-action">
|
||||
<Eyebrow>Add this record at your DNS provider</Eyebrow>
|
||||
<div class="rec-grid">
|
||||
<div class="rec-grid-label">Type</div>
|
||||
<div class="rec-grid-val">{{ DNS_FIX[expanded[d.domain]!].record.type }}</div>
|
||||
<div class="rec-grid-ttl">TTL {{ DNS_FIX[expanded[d.domain]!].record.ttl }}</div>
|
||||
|
||||
<div class="rec-grid-label sep">Host</div>
|
||||
<div class="rec-grid-span sep">
|
||||
<span>{{ DNS_FIX[expanded[d.domain]!].record.host }} <span class="muted">· resolves to {{ DNS_FIX[expanded[d.domain]!].record.host === '@' ? d.domain : `${DNS_FIX[expanded[d.domain]!].record.host}.${d.domain}` }}</span></span>
|
||||
<button class="copy" @click="copyValue(DNS_FIX[expanded[d.domain]!].record.host)"><UiIcon name="copy" :size="12" /></button>
|
||||
</div>
|
||||
|
||||
<div class="rec-grid-label sep">Value</div>
|
||||
<div class="rec-grid-span sep">
|
||||
<span class="break">{{ DNS_FIX[expanded[d.domain]!].record.value }}</span>
|
||||
<button class="copy" @click="copyValue(DNS_FIX[expanded[d.domain]!].record.value)"><UiIcon name="copy" :size="12" /></button>
|
||||
</div>
|
||||
|
||||
<template v-if="DNS_FIX[expanded[d.domain]!].record.priority !== undefined">
|
||||
<div class="rec-grid-label sep">Priority</div>
|
||||
<div class="rec-grid-span sep">{{ DNS_FIX[expanded[d.domain]!].record.priority }}</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="rec-actions-row">
|
||||
<UiButton size="sm" variant="primary" @click="copyValue(DNS_FIX[expanded[d.domain]!].record.value)">
|
||||
<template #leading><UiIcon name="copy" :size="13" /></template>
|
||||
{{ copied === DNS_FIX[expanded[d.domain]!].record.value ? 'Copied · paste at your DNS provider' : 'Copy record value' }}
|
||||
</UiButton>
|
||||
<UiButton size="sm" variant="secondary" @click="toast.info('Opening DNS provider guide…')">
|
||||
<template #leading><UiIcon name="external" :size="13" /></template>
|
||||
Open DNS guide
|
||||
</UiButton>
|
||||
<UiButton size="sm" variant="ghost" @click="toast.ok('Re-checking record')">
|
||||
<template #leading><UiIcon name="refresh" :size="13" /></template>
|
||||
Re-check this record
|
||||
</UiButton>
|
||||
<div class="spacer" />
|
||||
<Mono dim>changes can take up to 24h to propagate</Mono>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div class="currently-set">
|
||||
<Eyebrow>Currently set</Eyebrow>
|
||||
<div class="set-value">{{ DNS_FIX[expanded[d.domain]!].record.value }}</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.content { padding: 24px 40px 64px 40px; display: flex; flex-direction: column; gap: 12px; }
|
||||
|
||||
.head { display: flex; align-items: center; gap: 16px; }
|
||||
.title { flex: 1; min-width: 0; }
|
||||
.domain-name { font-family: var(--font-mono); font-size: 16px; font-weight: 600; }
|
||||
.domain-sub { font-size: 12px; color: var(--text-mute); margin-top: 2px; }
|
||||
.warn { color: var(--warn); }
|
||||
|
||||
.records {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 8px;
|
||||
margin-top: 20px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.rec {
|
||||
padding: 10px 12px;
|
||||
background: var(--bg);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-family: inherit;
|
||||
text-align: left;
|
||||
transition: background 120ms, border-color 120ms;
|
||||
}
|
||||
.rec:hover { background: var(--surface); }
|
||||
.rec.active { background: var(--surface); border-color: var(--text); }
|
||||
.rec-right { display: flex; align-items: center; gap: 6px; }
|
||||
|
||||
.detail {
|
||||
margin-top: 16px;
|
||||
padding: 16px;
|
||||
background: var(--bg);
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border);
|
||||
border-left: 3px solid var(--border);
|
||||
}
|
||||
.detail[data-tone='ok'] { border-left-color: var(--ok); }
|
||||
.detail[data-tone='warn'] { border-left-color: var(--warn); }
|
||||
.detail[data-tone='bad'] { border-left-color: var(--bad); }
|
||||
.detail-head { display: flex; align-items: flex-start; gap: 12px; }
|
||||
.detail-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 8px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.detail-body { flex: 1; }
|
||||
.detail-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
font-family: var(--font-display);
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
}
|
||||
.detail-text { font-size: 13px; color: var(--text-dim); margin-top: 6px; line-height: 1.55; }
|
||||
.detail-close { background: transparent; border: none; padding: 4px; border-radius: 4px; color: var(--text-mute); cursor: pointer; }
|
||||
|
||||
.rec-action { margin-top: 16px; }
|
||||
.rec-grid {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
display: grid;
|
||||
grid-template-columns: 70px 1fr 80px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
overflow: hidden;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.rec-grid-label { padding: 10px 12px; color: var(--text-mute); border-right: 1px solid var(--border); }
|
||||
.rec-grid-label.sep { border-top: 1px solid var(--border); }
|
||||
.rec-grid-val { padding: 10px 12px; border-right: 1px solid var(--border); }
|
||||
.rec-grid-ttl { padding: 10px 12px; color: var(--text-mute); }
|
||||
.rec-grid-span {
|
||||
padding: 10px 12px;
|
||||
grid-column: 2 / 4;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
.rec-grid-span.sep { border-top: 1px solid var(--border); }
|
||||
.break { word-break: break-all; }
|
||||
.muted { color: var(--text-mute); }
|
||||
.copy { background: transparent; border: none; padding: 4px; border-radius: 4px; color: var(--text-mute); cursor: pointer; }
|
||||
.copy:hover { background: var(--bg); }
|
||||
|
||||
.rec-actions-row { display: flex; align-items: center; gap: 10px; margin-top: 12px; flex-wrap: wrap; }
|
||||
.spacer { flex: 1; }
|
||||
|
||||
.currently-set { margin-top: 12px; padding: 12px; background: var(--surface); border-radius: 6px; border: 1px solid var(--border); }
|
||||
.set-value { font-family: var(--font-mono); font-size: 12px; color: var(--text-dim); word-break: break-all; margin-top: 4px; }
|
||||
</style>
|
||||
@@ -1,18 +1,38 @@
|
||||
<script setup lang="ts">
|
||||
// Strict port of platform-flows.jsx `DomainSetupWizard` (lines 134-176) +
|
||||
// step components 178-369. 6-step full-page route: Domain · Verify · Mail ·
|
||||
// DKIM · DMARC · Done. Same step rail at the top, same DNS record rows and
|
||||
// per-step copy.
|
||||
// Add-domain wizard, wired to platform-api. 6 full-page steps:
|
||||
// 1 Domain — POST the domain (provisions it in Stalwart, which auto-creates
|
||||
// DKIM keys and returns the records to publish + an ownership token)
|
||||
// 2 Verify — poll the ownership TXT until it resolves
|
||||
// 3 Mail — show + re-check the MX/SPF records
|
||||
// 4 DKIM — show + re-check the DKIM record(s)
|
||||
// 5 DMARC — pick a policy (PATCH) and re-check
|
||||
// 6 Done — summary of live status
|
||||
// All record values come from the server; only the guidance copy is static.
|
||||
|
||||
import type { DmarcPolicy, DomainRecordView, DomainView, RecordKind, RecordStatus } from '~/composables/useDomains'
|
||||
|
||||
const router = useRouter()
|
||||
const toast = useToast()
|
||||
const { add, recheck, setDmarcPolicy } = useDomains()
|
||||
|
||||
const step = ref(1)
|
||||
const domain = ref('lyngby-biler.dk')
|
||||
const policy = ref<'none' | 'quarantine' | 'reject'>('quarantine')
|
||||
const domainInput = ref('')
|
||||
const dv = ref<DomainView | null>(null)
|
||||
const busy = ref(false)
|
||||
const policy = ref<DmarcPolicy>('quarantine')
|
||||
const steps = ['Domain', 'Verify', 'Mail', 'DKIM', 'DMARC', 'Done']
|
||||
|
||||
const dmarcValue = computed(() => `v=DMARC1; p=${policy.value}; rua=mailto:dmarc@${domain.value}; pct=100; adkim=s; aspf=s`)
|
||||
const domainName = computed(() => dv.value?.domain ?? domainInput.value)
|
||||
|
||||
type Tone = 'ok' | 'warn' | 'bad'
|
||||
function tone(status: RecordStatus): Tone {
|
||||
return status === 'ok' ? 'ok' : status === 'warn' ? 'warn' : 'bad'
|
||||
}
|
||||
function recordsOfKind(kind: RecordKind): DomainRecordView[] {
|
||||
return dv.value?.records.filter((r) => r.kind === kind) ?? []
|
||||
}
|
||||
const ownershipRecord = computed(() => recordsOfKind('ownership')[0])
|
||||
const ownershipOk = computed(() => dv.value?.checks.ownership === 'ok')
|
||||
|
||||
const policyOptions = [
|
||||
{ v: 'none' as const, l: 'none · monitor only', d: 'Reports failures but never blocks. Use only for the first 2 weeks while you confirm legitimate mail flows.' },
|
||||
@@ -20,12 +40,67 @@ const policyOptions = [
|
||||
{ v: 'reject' as const, l: 'reject · strictest', d: "Suspicious mail is bounced. Use after you've been at quarantine for 30+ days with no surprises." },
|
||||
]
|
||||
|
||||
function cancel() {
|
||||
router.push('/admin/domains')
|
||||
function toastError(err: unknown, title: string) {
|
||||
const e = err as { data?: { message?: string | string[] }; message?: string }
|
||||
const msg = e?.data?.message ?? e?.message ?? 'Unknown error'
|
||||
toast.bad(title, Array.isArray(msg) ? msg.join(', ') : msg)
|
||||
}
|
||||
function done() {
|
||||
router.push('/admin/domains')
|
||||
|
||||
// Step 1 → create the domain.
|
||||
async function createDomain() {
|
||||
const name = domainInput.value.trim().toLowerCase()
|
||||
if (!name) return
|
||||
busy.value = true
|
||||
try {
|
||||
dv.value = await add(name)
|
||||
step.value = 2
|
||||
} catch (err) {
|
||||
toastError(err, 'Could not add domain')
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Re-run DNS checks and refresh the wizard's domain snapshot.
|
||||
async function recheckNow() {
|
||||
if (!dv.value) return
|
||||
busy.value = true
|
||||
try {
|
||||
dv.value = await recheck(dv.value.domain)
|
||||
} catch (err) {
|
||||
toastError(err, 'Could not re-check')
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Step 5 → persist the DMARC policy, then finish.
|
||||
async function finishWithDmarc() {
|
||||
if (!dv.value) { step.value = 6; return }
|
||||
busy.value = true
|
||||
try {
|
||||
dv.value = await setDmarcPolicy(dv.value.domain, policy.value)
|
||||
step.value = 6
|
||||
} catch (err) {
|
||||
toastError(err, 'Could not set DMARC policy')
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// While on the Verify step, poll for the ownership TXT every 10s until it lands.
|
||||
let pollTimer: ReturnType<typeof setInterval> | null = null
|
||||
function stopPoll() { if (pollTimer) { clearInterval(pollTimer); pollTimer = null } }
|
||||
watch([step, ownershipOk], ([s, ok]) => {
|
||||
stopPoll()
|
||||
if (s === 2 && !ok) {
|
||||
pollTimer = setInterval(() => { if (!busy.value) recheckNow() }, 10000)
|
||||
}
|
||||
})
|
||||
onBeforeUnmount(stopPoll)
|
||||
|
||||
function cancel() { router.push('/admin/domains') }
|
||||
function done() { router.push('/admin/domains') }
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -43,15 +118,12 @@ function done() {
|
||||
</button>
|
||||
</div>
|
||||
<div class="row title-row">
|
||||
<h1>{{ step < 6 ? 'Verify and configure your domain' : `${domain} is ready` }}</h1>
|
||||
<h1>{{ step < 6 ? 'Verify and configure your domain' : `${domainName} is ready` }}</h1>
|
||||
<Mono dim>Step {{ step }} of 6</Mono>
|
||||
</div>
|
||||
<div class="rail">
|
||||
<div v-for="(s, i) in steps" :key="s" class="rail-cell">
|
||||
<div
|
||||
class="bar"
|
||||
:class="i + 1 < step ? 'done' : i + 1 === step ? 'active' : 'todo'"
|
||||
/>
|
||||
<div class="bar" :class="i + 1 < step ? 'done' : i + 1 === step ? 'active' : 'todo'" />
|
||||
<div class="rail-label">
|
||||
<Mono dim>0{{ i + 1 }}</Mono>
|
||||
<span :class="i + 1 === step ? 'is-active' : i + 1 < step ? 'is-done' : 'is-todo'">{{ s }}</span>
|
||||
@@ -70,7 +142,7 @@ function done() {
|
||||
<Eyebrow>Domain</Eyebrow>
|
||||
<div class="input-wrap">
|
||||
<UiIcon name="globe" :size="14" stroke="var(--text-mute)" />
|
||||
<input v-model="domain" placeholder="acme.dk" />
|
||||
<input v-model="domainInput" placeholder="acme.dk" @keyup.enter="createDomain" />
|
||||
</div>
|
||||
</label>
|
||||
<div class="info-box">
|
||||
@@ -83,98 +155,76 @@ function done() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 2: Verify -->
|
||||
<!-- Step 2: Verify ownership -->
|
||||
<div v-else-if="step === 2" class="step2">
|
||||
<p class="lead">
|
||||
Add this TXT record to <Mono>{{ domain }}</Mono>. We check every 30 seconds until it appears.
|
||||
Add this TXT record to <Mono>{{ domainName }}</Mono>. We check every 10 seconds until it appears.
|
||||
</p>
|
||||
<div class="dns-rows">
|
||||
<div v-if="ownershipRecord" class="dns-rows">
|
||||
<div class="dns-row">
|
||||
<div><Mono dim>TYPE</Mono><div class="dns-val">TXT</div></div>
|
||||
<div><Mono dim>HOST</Mono><div class="dns-val">_dezky-verify.{{ domain }}</div></div>
|
||||
<div><Mono dim>VALUE</Mono><div class="dns-val dim">dezky-verify=8a3f9c2e-4b7d-4e1a-9c8f-2d6e1a3b5c7e</div></div>
|
||||
<div><Mono dim>TYPE</Mono><div class="dns-val">{{ ownershipRecord.type }}</div></div>
|
||||
<div><Mono dim>HOST</Mono><div class="dns-val">{{ ownershipRecord.fqdn }}</div></div>
|
||||
<div><Mono dim>VALUE</Mono><div class="dns-val dim">{{ ownershipRecord.expected }}</div></div>
|
||||
<div class="dns-right">
|
||||
<Badge tone="warn" dot>pending</Badge>
|
||||
<button class="copy-btn"><UiIcon name="copy" :size="11" stroke="var(--text-mute)" /> COPY</button>
|
||||
<Badge :tone="ownershipOk ? 'ok' : 'warn'" dot>{{ ownershipOk ? 'verified' : 'pending' }}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="banner warn">
|
||||
<UiIcon name="refresh" :size="14" stroke="var(--warn)" />
|
||||
<div class="banner" :class="ownershipOk ? 'ok' : 'warn'">
|
||||
<UiIcon :name="ownershipOk ? 'check' : 'refresh'" :size="14" :stroke="ownershipOk ? 'var(--ok)' : 'var(--warn)'" />
|
||||
<div class="banner-body">
|
||||
<div class="banner-title">Last check · 14:42:08 · still waiting</div>
|
||||
<div class="banner-title">{{ ownershipOk ? 'Ownership verified' : 'Waiting for the TXT record' }}</div>
|
||||
<div class="banner-text">
|
||||
We saw <Mono>NS · ns1.gratisdns.dk</Mono> but no TXT record at <Mono>_dezky-verify.{{ domain }}</Mono> yet. Add the record above and click verify, or wait — we'll check every 30 seconds.
|
||||
{{ ownershipOk
|
||||
? 'We found the verification record. Continue to set up mail.'
|
||||
: 'Add the record above, then click verify — or wait, we re-check automatically every 10 seconds.' }}
|
||||
</div>
|
||||
</div>
|
||||
<UiButton size="sm" variant="primary">Verify now</UiButton>
|
||||
<UiButton size="sm" variant="primary" :disabled="busy" @click="recheckNow">{{ busy ? 'Checking…' : 'Verify now' }}</UiButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 3: Mail -->
|
||||
<!-- Step 3: Mail (MX + SPF) -->
|
||||
<div v-else-if="step === 3" class="step3">
|
||||
<p class="lead">
|
||||
Add these records so mail to <Mono>@{{ domain }}</Mono> reaches dezky and outgoing mail is trusted.
|
||||
Add these records so mail to <Mono>@{{ domainName }}</Mono> reaches dezky and outgoing mail is trusted.
|
||||
</p>
|
||||
<Eyebrow style="display: block; margin-top: 20px; margin-bottom: 10px">MX · inbound</Eyebrow>
|
||||
<div class="dns-rows">
|
||||
<div class="dns-row">
|
||||
<div><Mono dim>TYPE</Mono><div class="dns-val">MX</div></div>
|
||||
<div><Mono dim>HOST</Mono><div class="dns-val">{{ domain }}</div></div>
|
||||
<div><Mono dim>VALUE</Mono><div class="dns-val dim">10 inbound.mx.dezky.com</div></div>
|
||||
<div class="dns-right"><Badge tone="ok" dot>verified</Badge></div>
|
||||
</div>
|
||||
<div class="dns-row">
|
||||
<div><Mono dim>TYPE</Mono><div class="dns-val">MX</div></div>
|
||||
<div><Mono dim>HOST</Mono><div class="dns-val">{{ domain }}</div></div>
|
||||
<div><Mono dim>VALUE</Mono><div class="dns-val dim">20 inbound-backup.mx.dezky.com</div></div>
|
||||
<div class="dns-right"><Badge tone="ok" dot>verified</Badge></div>
|
||||
</div>
|
||||
<RecordRow v-for="(r, i) in recordsOfKind('mx')" :key="'mx' + i" :rec="r" />
|
||||
<div v-if="!recordsOfKind('mx').length" class="empty-note">No MX record yet — re-check after mail provisioning completes.</div>
|
||||
</div>
|
||||
<Eyebrow style="display: block; margin-top: 24px; margin-bottom: 10px">SPF · sender policy</Eyebrow>
|
||||
<div class="dns-rows">
|
||||
<div class="dns-row">
|
||||
<div><Mono dim>TYPE</Mono><div class="dns-val">TXT</div></div>
|
||||
<div><Mono dim>HOST</Mono><div class="dns-val">{{ domain }}</div></div>
|
||||
<div><Mono dim>VALUE</Mono><div class="dns-val dim">v=spf1 include:_spf.dezky.com -all</div></div>
|
||||
<div class="dns-right"><Badge tone="ok" dot>verified</Badge></div>
|
||||
<RecordRow v-for="(r, i) in recordsOfKind('spf')" :key="'spf' + i" :rec="r" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="banner ok">
|
||||
<UiIcon name="check" :size="14" stroke="var(--ok)" :stroke-width="2.5" />
|
||||
<div class="banner" :class="dv && dv.checks.mx === 'ok' && dv.checks.spf === 'ok' ? 'ok' : 'warn'">
|
||||
<UiIcon :name="dv && dv.checks.mx === 'ok' && dv.checks.spf === 'ok' ? 'check' : 'refresh'" :size="14" :stroke="dv && dv.checks.mx === 'ok' && dv.checks.spf === 'ok' ? 'var(--ok)' : 'var(--warn)'" />
|
||||
<div class="banner-body">
|
||||
<div class="banner-title">Mail routing verified</div>
|
||||
<div class="banner-text">All MX and SPF records resolve correctly. Test by sending mail to <Mono>postmaster@{{ domain }}</Mono>.</div>
|
||||
<div class="banner-title">{{ dv && dv.checks.mx === 'ok' && dv.checks.spf === 'ok' ? 'Mail routing verified' : 'Waiting for MX / SPF' }}</div>
|
||||
<div class="banner-text">Add the records above. You can continue now and re-check from the Domains page later.</div>
|
||||
</div>
|
||||
<UiButton size="sm" variant="secondary" :disabled="busy" @click="recheckNow">{{ busy ? 'Checking…' : 'Re-check' }}</UiButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 4: DKIM -->
|
||||
<div v-else-if="step === 4" class="step4">
|
||||
<p class="lead">
|
||||
DKIM signs every outgoing email so Gmail and Outlook trust it. Two records, then we'll rotate the keys for you automatically every 90 days.
|
||||
DKIM signs every outgoing email so Gmail and Outlook trust it. We rotate the keys for you automatically.
|
||||
</p>
|
||||
<Eyebrow style="display: block; margin-top: 20px; margin-bottom: 10px">DKIM · selector 1</Eyebrow>
|
||||
<Eyebrow style="display: block; margin-top: 20px; margin-bottom: 10px">DKIM · message signing</Eyebrow>
|
||||
<div class="dns-rows">
|
||||
<div class="dns-row">
|
||||
<div><Mono dim>TYPE</Mono><div class="dns-val">CNAME</div></div>
|
||||
<div><Mono dim>HOST</Mono><div class="dns-val">dezky1._domainkey.{{ domain }}</div></div>
|
||||
<div><Mono dim>VALUE</Mono><div class="dns-val dim">dezky1.dkim.dezky.com</div></div>
|
||||
<div class="dns-right"><Badge tone="ok" dot>verified</Badge></div>
|
||||
<RecordRow v-for="(r, i) in recordsOfKind('dkim')" :key="'dkim' + i" :rec="r" />
|
||||
<div v-if="!recordsOfKind('dkim').length" class="empty-note">No DKIM record yet — re-check after mail provisioning completes.</div>
|
||||
</div>
|
||||
<div class="dns-row">
|
||||
<div><Mono dim>TYPE</Mono><div class="dns-val">CNAME</div></div>
|
||||
<div><Mono dim>HOST</Mono><div class="dns-val">dezky2._domainkey.{{ domain }}</div></div>
|
||||
<div><Mono dim>VALUE</Mono><div class="dns-val dim">dezky2.dkim.dezky.com</div></div>
|
||||
<div class="dns-right"><Badge tone="ok" dot>verified</Badge></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="banner ok">
|
||||
<UiIcon name="check" :size="14" stroke="var(--ok)" :stroke-width="2.5" />
|
||||
<div class="banner" :class="dv && dv.checks.dkim === 'ok' ? 'ok' : 'warn'">
|
||||
<UiIcon :name="dv && dv.checks.dkim === 'ok' ? 'check' : 'refresh'" :size="14" :stroke="dv && dv.checks.dkim === 'ok' ? 'var(--ok)' : 'var(--warn)'" />
|
||||
<div class="banner-body">
|
||||
<div class="banner-title">DKIM is signing</div>
|
||||
<div class="banner-text">Selectors verified · key rotation enabled · next rotation 14 Aug 2026.</div>
|
||||
<div class="banner-title">{{ dv && dv.checks.dkim === 'ok' ? 'DKIM is signing' : 'Waiting for DKIM' }}</div>
|
||||
<div class="banner-text">Publish the record(s) above. Both selectors must be present for full coverage.</div>
|
||||
</div>
|
||||
<UiButton size="sm" variant="secondary" :disabled="busy" @click="recheckNow">{{ busy ? 'Checking…' : 'Re-check' }}</UiButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -198,9 +248,11 @@ function done() {
|
||||
<div class="dns-rows">
|
||||
<div class="dns-row">
|
||||
<div><Mono dim>TYPE</Mono><div class="dns-val">TXT</div></div>
|
||||
<div><Mono dim>HOST</Mono><div class="dns-val">_dmarc.{{ domain }}</div></div>
|
||||
<div><Mono dim>VALUE</Mono><div class="dns-val dim">{{ dmarcValue }}</div></div>
|
||||
<div class="dns-right"><button class="copy-btn"><UiIcon name="copy" :size="11" stroke="var(--text-mute)" /> COPY</button></div>
|
||||
<div><Mono dim>HOST</Mono><div class="dns-val">_dmarc.{{ domainName }}</div></div>
|
||||
<div><Mono dim>VALUE</Mono><div class="dns-val dim">v=DMARC1; p={{ policy }}; rua=mailto:postmaster@{{ domainName }}</div></div>
|
||||
<div class="dns-right">
|
||||
<Badge :tone="dv && dv.checks.dmarc === 'ok' ? 'ok' : 'warn'" dot>{{ dv ? dv.checks.dmarc : 'pending' }}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -210,14 +262,14 @@ function done() {
|
||||
<div class="check-badge">
|
||||
<UiIcon name="check" :size="36" :stroke-width="2.5" />
|
||||
</div>
|
||||
<h2>{{ domain }} is connected.</h2>
|
||||
<h2>{{ domainName }} is connected.</h2>
|
||||
<p class="lead-center">
|
||||
Mail is routing. DKIM is signing. DMARC is enforcing. You can now invite users on this domain and they'll receive working email immediately.
|
||||
The domain is provisioned. Publish any remaining DNS records and they'll go green automatically — you can track status from the Domains page.
|
||||
</p>
|
||||
<div class="summary-grid">
|
||||
<div v-for="k in ['MX', 'SPF', 'DKIM', 'DMARC']" :key="k" class="summary-cell">
|
||||
<Badge tone="ok" dot>verified</Badge>
|
||||
<Mono>{{ k }}</Mono>
|
||||
<div v-if="dv" class="summary-grid">
|
||||
<div v-for="k in (['mx','spf','dkim','dmarc'] as const)" :key="k" class="summary-cell">
|
||||
<Badge :tone="dv.checks[k] === 'ok' ? 'ok' : 'warn'" dot>{{ dv.checks[k] }}</Badge>
|
||||
<Mono>{{ k.toUpperCase() }}</Mono>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -227,11 +279,32 @@ function done() {
|
||||
<template v-if="step < 6">
|
||||
<UiButton variant="ghost" @click="cancel">Save and exit</UiButton>
|
||||
<div class="spacer" />
|
||||
<UiButton v-if="step === 5" variant="secondary" @click="step = 6">Skip DMARC for now</UiButton>
|
||||
<UiButton variant="primary" @click="step++">
|
||||
<template v-if="step === 5" #leading><UiIcon name="check" :size="13" /></template>
|
||||
{{ step === 1 ? 'Continue' : step === 5 ? 'Add DMARC & finish' : 'Verified · continue' }}
|
||||
<template v-if="step < 5" #trailing><UiIcon name="arrowRight" :size="13" /></template>
|
||||
<UiButton v-if="step === 5" variant="secondary" :disabled="busy" @click="step = 6">Skip DMARC for now</UiButton>
|
||||
<UiButton
|
||||
v-if="step === 1"
|
||||
variant="primary"
|
||||
:disabled="busy || !domainInput.trim()"
|
||||
@click="createDomain"
|
||||
>
|
||||
{{ busy ? 'Adding…' : 'Continue' }}
|
||||
<template #trailing><UiIcon name="arrowRight" :size="13" /></template>
|
||||
</UiButton>
|
||||
<UiButton
|
||||
v-else-if="step === 2"
|
||||
variant="primary"
|
||||
:disabled="!ownershipOk"
|
||||
@click="step = 3"
|
||||
>
|
||||
{{ ownershipOk ? 'Verified · continue' : 'Waiting for verification' }}
|
||||
<template #trailing><UiIcon name="arrowRight" :size="13" /></template>
|
||||
</UiButton>
|
||||
<UiButton v-else-if="step === 5" variant="primary" :disabled="busy" @click="finishWithDmarc">
|
||||
<template #leading><UiIcon name="check" :size="13" /></template>
|
||||
{{ busy ? 'Saving…' : 'Add DMARC & finish' }}
|
||||
</UiButton>
|
||||
<UiButton v-else variant="primary" @click="step++">
|
||||
Continue
|
||||
<template #trailing><UiIcon name="arrowRight" :size="13" /></template>
|
||||
</UiButton>
|
||||
</template>
|
||||
<template v-else>
|
||||
@@ -327,19 +400,7 @@ function done() {
|
||||
.dns-val { font-family: var(--font-mono); font-size: 13px; font-weight: 600; margin-top: 2px; }
|
||||
.dns-val.dim { color: var(--text-dim); font-weight: 400; font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.dns-right { display: flex; flex-direction: column; align-items: flex-end; gap: 6px; }
|
||||
.copy-btn {
|
||||
background: transparent;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
padding: 4px 8px;
|
||||
font-size: 11px;
|
||||
font-family: var(--font-mono);
|
||||
color: var(--text-dim);
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.empty-note { font-size: 12px; color: var(--text-mute); padding: 10px 2px; font-family: var(--font-mono); }
|
||||
|
||||
.banner {
|
||||
margin-top: 16px;
|
||||
|
||||
@@ -0,0 +1,436 @@
|
||||
<script setup lang="ts">
|
||||
// Customer-admin Domains page. Lists the tenant's email domains on real data
|
||||
// from platform-api (useDomains → /api/tenants/:slug/domains). Each card shows
|
||||
// the monospace name, an overall status badge, a "X records to fix" hint, a
|
||||
// Re-check button, and a 4-record grid (MX/SPF/DKIM/DMARC) clickable to expand
|
||||
// inline detail with the exact record to publish (sourced from Stalwart's zone,
|
||||
// so DKIM keys etc. are authoritative). The explanatory copy per status is
|
||||
// static (DNS_FIX); the record values come from the server.
|
||||
|
||||
import type { DomainRecordView, DomainView, RecordStatus } from '~/composables/useDomains'
|
||||
|
||||
const router = useRouter()
|
||||
const toast = useToast()
|
||||
const { domains, refresh, recheck, remove } = useDomains()
|
||||
|
||||
type Tone = 'ok' | 'warn' | 'bad'
|
||||
type RecordKey = 'mx' | 'spf' | 'dkim' | 'dmarc'
|
||||
|
||||
// Static explanatory copy per record + status. Record VALUES are no longer here
|
||||
// — those come from the server (the real MX host, DKIM public key, etc.). We
|
||||
// keep only the human guidance, keyed by record kind + observed tone.
|
||||
const DNS_FIX: Record<RecordKey, {
|
||||
label: string
|
||||
purpose: string
|
||||
states: Record<Tone | 'pending', { headline: string; body: string }>
|
||||
}> = {
|
||||
mx: {
|
||||
label: 'MX · mail exchange',
|
||||
purpose: 'Routes inbound mail for this domain to dezky.',
|
||||
states: {
|
||||
ok: { headline: 'Mail routing healthy', body: 'Inbound mail flows to dezky correctly.' },
|
||||
warn: { headline: 'Secondary MX detected', body: 'An MX outside of dezky was found. This is allowed for failover, but make sure it forwards back to dezky.' },
|
||||
bad: { headline: 'No MX record found', body: 'Mail to this domain will not reach dezky. Add the record below at your DNS provider.' },
|
||||
pending: { headline: 'Not checked yet', body: 'Add the record below, then re-check.' },
|
||||
},
|
||||
},
|
||||
spf: {
|
||||
label: 'SPF · sender policy',
|
||||
purpose: 'Tells receiving servers which IPs are allowed to send for this domain.',
|
||||
states: {
|
||||
ok: { headline: 'SPF aligned', body: 'Your SPF record correctly authorises dezky as a sender.' },
|
||||
warn: { headline: 'SPF present but weak', body: 'SPF resolves but ends with a softfail (~all) or is missing the dezky mechanism. Use the record below for stronger protection.' },
|
||||
bad: { headline: 'No SPF record', body: 'Mail sent from this domain via dezky will fail Gmail/Outlook authentication.' },
|
||||
pending: { headline: 'Not checked yet', body: 'Add the record below, then re-check.' },
|
||||
},
|
||||
},
|
||||
dkim: {
|
||||
label: 'DKIM · message signing',
|
||||
purpose: 'Cryptographic signature proving the message was not altered in transit.',
|
||||
states: {
|
||||
ok: { headline: 'DKIM signing live', body: 'Outbound mail is signed and verifiable.' },
|
||||
warn: { headline: 'DKIM record mismatch', body: 'A DKIM record exists but its public key differs from dezky’s. Replace it with the value(s) below.' },
|
||||
bad: { headline: 'No DKIM record', body: 'Receiving servers cannot verify the signature on your outbound mail.' },
|
||||
pending: { headline: 'Not checked yet', body: 'Add the record(s) below, then re-check.' },
|
||||
},
|
||||
},
|
||||
dmarc: {
|
||||
label: 'DMARC · policy enforcement',
|
||||
purpose: 'Tells receiving servers what to do with mail that fails SPF or DKIM.',
|
||||
states: {
|
||||
ok: { headline: 'DMARC enforcing', body: 'Spoofed mail will be quarantined or rejected at Gmail/Outlook.' },
|
||||
warn: { headline: 'DMARC at p=none', body: 'You’re collecting reports but not enforcing. Raise to quarantine once SPF/DKIM look stable.' },
|
||||
bad: { headline: 'No DMARC record', body: 'Anyone can spoof this domain. Mail may fail Gmail / Outlook spam checks.' },
|
||||
pending: { headline: 'Not checked yet', body: 'Add the record below, then re-check.' },
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const RECORD_KEYS: RecordKey[] = ['mx', 'spf', 'dkim', 'dmarc']
|
||||
|
||||
const expanded = reactive<Record<string, RecordKey | null>>({})
|
||||
const copied = ref<string | null>(null)
|
||||
const rechecking = ref<string | null>(null)
|
||||
|
||||
// Remove flow. A domain can only be removed when no mailboxes use it (enforced
|
||||
// server-side too); the button is disabled otherwise. removeTarget drives the
|
||||
// confirm dialog.
|
||||
const removeTarget = ref<DomainView | null>(null)
|
||||
const removing = ref(false)
|
||||
|
||||
function toggle(domain: string, key: RecordKey) {
|
||||
expanded[domain] = expanded[domain] === key ? null : key
|
||||
}
|
||||
function copyValue(text: string) {
|
||||
if (typeof navigator !== 'undefined' && navigator.clipboard) navigator.clipboard.writeText(text)
|
||||
copied.value = text
|
||||
setTimeout(() => { if (copied.value === text) copied.value = null }, 1400)
|
||||
toast.ok('Copied to clipboard')
|
||||
}
|
||||
|
||||
function issuesFor(d: DomainView): RecordKey[] {
|
||||
return RECORD_KEYS.filter((k) => d.checks[k] !== 'ok')
|
||||
}
|
||||
function recordsOfKind(d: DomainView, k: RecordKey): DomainRecordView[] {
|
||||
return d.records.filter((r) => r.kind === k)
|
||||
}
|
||||
function tone(status: RecordStatus): Tone {
|
||||
return status === 'ok' ? 'ok' : status === 'warn' ? 'warn' : status === 'pending' ? 'warn' : 'bad'
|
||||
}
|
||||
function statusIcon(t: Tone): 'check' | 'shield' | 'x' {
|
||||
return t === 'ok' ? 'check' : t === 'warn' ? 'shield' : 'x'
|
||||
}
|
||||
function recordTint(t: Tone) {
|
||||
return t === 'bad' ? 'rgba(226,48,48,0.12)'
|
||||
: t === 'warn' ? 'rgba(232,154,31,0.12)'
|
||||
: 'rgba(91,140,90,0.12)'
|
||||
}
|
||||
function badgeFor(d: DomainView): { tone: 'ok' | 'warn' | 'bad'; label: string } {
|
||||
if (d.status === 'active') return { tone: 'ok', label: 'verified' }
|
||||
if (d.status === 'error') return { tone: 'bad', label: 'error' }
|
||||
return { tone: 'warn', label: 'attention' }
|
||||
}
|
||||
|
||||
async function recheckDomain(domain: string) {
|
||||
rechecking.value = domain
|
||||
try {
|
||||
await recheck(domain)
|
||||
await refresh()
|
||||
toast.ok(`Re-checked ${domain}`)
|
||||
} catch (err) {
|
||||
const e = err as { data?: { message?: string }; message?: string }
|
||||
toast.bad('Could not re-check', e?.data?.message ?? e?.message ?? 'Unknown error')
|
||||
} finally {
|
||||
rechecking.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmRemove() {
|
||||
const d = removeTarget.value
|
||||
if (!d) return
|
||||
removing.value = true
|
||||
try {
|
||||
await remove(d.domain)
|
||||
await refresh()
|
||||
toast.ok(`Removed ${d.domain}`)
|
||||
removeTarget.value = null
|
||||
} catch (err) {
|
||||
const e = err as { data?: { message?: string }; message?: string }
|
||||
toast.bad('Could not remove domain', e?.data?.message ?? e?.message ?? 'Unknown error')
|
||||
} finally {
|
||||
removing.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<PageHeader
|
||||
eyebrow="Identity"
|
||||
title="Domains"
|
||||
subtitle="Your verified domains for mail, SSO, and user provisioning."
|
||||
>
|
||||
<template #actions>
|
||||
<UiButton variant="primary" @click="router.push('/admin/domains/add')">
|
||||
<template #leading><UiIcon name="plus" :size="14" /></template>
|
||||
Add domain
|
||||
</UiButton>
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<div class="content">
|
||||
<Card v-if="domains && domains.length === 0" class="empty">
|
||||
<UiIcon name="globe" :size="22" stroke="var(--text-mute)" />
|
||||
<div>
|
||||
<div class="empty-title">No domains yet</div>
|
||||
<div class="empty-sub">Add your first email domain to route mail and enable sign-in for your team.</div>
|
||||
</div>
|
||||
<UiButton variant="primary" @click="router.push('/admin/domains/add')">
|
||||
<template #leading><UiIcon name="plus" :size="14" /></template>
|
||||
Add domain
|
||||
</UiButton>
|
||||
</Card>
|
||||
|
||||
<Card v-for="d in domains" :key="d.id">
|
||||
<div class="head">
|
||||
<UiIcon name="globe" :size="20" stroke="var(--text-mute)" />
|
||||
<div class="title">
|
||||
<div class="domain-name">{{ d.domain }}</div>
|
||||
<div class="domain-sub">
|
||||
{{ d.mailboxes }} mailbox{{ d.mailboxes === 1 ? '' : 'es' }}
|
||||
<template v-if="issuesFor(d).length">
|
||||
· <span class="warn">{{ issuesFor(d).length }} record{{ issuesFor(d).length === 1 ? '' : 's' }} to fix</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<UiButton size="sm" variant="secondary" :disabled="rechecking === d.domain" @click.stop="recheckDomain(d.domain)">
|
||||
<template #leading><UiIcon name="refresh" :size="12" /></template>
|
||||
{{ rechecking === d.domain ? 'Checking…' : 'Re-check now' }}
|
||||
</UiButton>
|
||||
<button
|
||||
class="remove"
|
||||
:disabled="d.mailboxes > 0"
|
||||
:title="d.mailboxes > 0
|
||||
? `${d.mailboxes} mailbox${d.mailboxes === 1 ? '' : 'es'} use this domain — remove or reassign those users first`
|
||||
: 'Remove domain'"
|
||||
@click.stop="removeTarget = d"
|
||||
>
|
||||
<UiIcon name="trash" :size="14" />
|
||||
</button>
|
||||
<Badge :tone="badgeFor(d).tone" dot>{{ badgeFor(d).label }}</Badge>
|
||||
</div>
|
||||
|
||||
<div v-if="d.stalwartError" class="prov-error">
|
||||
<UiIcon name="x" :size="13" stroke="var(--bad)" />
|
||||
Provisioning error: {{ d.stalwartError }}
|
||||
</div>
|
||||
|
||||
<div class="records">
|
||||
<button
|
||||
v-for="k in RECORD_KEYS"
|
||||
:key="k"
|
||||
class="rec"
|
||||
:class="{ active: expanded[d.domain] === k }"
|
||||
@click="toggle(d.domain, k)"
|
||||
>
|
||||
<Mono>{{ k.toUpperCase() }}</Mono>
|
||||
<div class="rec-right">
|
||||
<Badge :tone="tone(d.checks[k])" dot>{{ d.checks[k] }}</Badge>
|
||||
<UiIcon :name="expanded[d.domain] === k ? 'chevDown' : 'chevRight'" :size="11" stroke="var(--text-mute)" />
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="expanded[d.domain]" class="detail" :data-tone="tone(d.checks[expanded[d.domain]!])">
|
||||
<div class="detail-head">
|
||||
<div class="detail-icon" :style="{ background: recordTint(tone(d.checks[expanded[d.domain]!])), color: `var(--${tone(d.checks[expanded[d.domain]!])})` }">
|
||||
<UiIcon :name="statusIcon(tone(d.checks[expanded[d.domain]!]))" :size="14" :stroke-width="d.checks[expanded[d.domain]!] === 'ok' ? 2.5 : 2" />
|
||||
</div>
|
||||
<div class="detail-body">
|
||||
<div class="detail-title">
|
||||
{{ DNS_FIX[expanded[d.domain]!].states[d.checks[expanded[d.domain]!]].headline }}
|
||||
<Mono dim>{{ DNS_FIX[expanded[d.domain]!].label }}</Mono>
|
||||
</div>
|
||||
<div class="detail-text">{{ DNS_FIX[expanded[d.domain]!].states[d.checks[expanded[d.domain]!]].body }}</div>
|
||||
<Mono dim style="display: block; margin-top: 10px">{{ DNS_FIX[expanded[d.domain]!].purpose }}</Mono>
|
||||
</div>
|
||||
<button class="detail-close" @click="expanded[d.domain] = null"><UiIcon name="x" :size="14" /></button>
|
||||
</div>
|
||||
|
||||
<template v-if="d.checks[expanded[d.domain]!] !== 'ok'">
|
||||
<div class="rec-action">
|
||||
<Eyebrow>Add {{ recordsOfKind(d, expanded[d.domain]!).length > 1 ? 'these records' : 'this record' }} at your DNS provider</Eyebrow>
|
||||
<div v-for="(rec, i) in recordsOfKind(d, expanded[d.domain]!)" :key="i" class="rec-grid">
|
||||
<div class="rec-grid-label">Type</div>
|
||||
<div class="rec-grid-val">{{ rec.type }}</div>
|
||||
<div class="rec-grid-ttl">TTL 3600</div>
|
||||
|
||||
<div class="rec-grid-label sep">Host</div>
|
||||
<div class="rec-grid-span sep">
|
||||
<span>{{ rec.host }} <span class="muted">· resolves to {{ rec.fqdn }}</span></span>
|
||||
<button class="copy" @click="copyValue(rec.host)"><UiIcon name="copy" :size="12" /></button>
|
||||
</div>
|
||||
|
||||
<div class="rec-grid-label sep">Value</div>
|
||||
<div class="rec-grid-span sep">
|
||||
<span class="break">{{ rec.expected }}</span>
|
||||
<button class="copy" @click="copyValue(rec.expected)"><UiIcon name="copy" :size="12" /></button>
|
||||
</div>
|
||||
|
||||
<template v-if="rec.priority !== undefined">
|
||||
<div class="rec-grid-label sep">Priority</div>
|
||||
<div class="rec-grid-span sep">{{ rec.priority }}</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="rec-actions-row">
|
||||
<UiButton size="sm" variant="ghost" :disabled="rechecking === d.domain" @click="recheckDomain(d.domain)">
|
||||
<template #leading><UiIcon name="refresh" :size="13" /></template>
|
||||
{{ rechecking === d.domain ? 'Checking…' : 'Re-check this record' }}
|
||||
</UiButton>
|
||||
<div class="spacer" />
|
||||
<Mono dim>changes can take up to 24h to propagate</Mono>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div v-for="(rec, i) in recordsOfKind(d, expanded[d.domain]!)" :key="i" class="currently-set">
|
||||
<Eyebrow>Currently set</Eyebrow>
|
||||
<div class="set-value">{{ rec.observed || rec.expected }}</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
:open="!!removeTarget"
|
||||
eyebrow="Identity · Domains"
|
||||
:title="`Remove ${removeTarget?.domain}?`"
|
||||
confirm-label="Remove domain"
|
||||
tone="danger"
|
||||
:busy="removing"
|
||||
@close="removeTarget = null"
|
||||
@confirm="confirmRemove"
|
||||
>
|
||||
Mail routing and DKIM signing for <strong>{{ removeTarget?.domain }}</strong> are deleted from the mail
|
||||
server immediately. Inbound mail to this domain will stop being delivered. This can't be undone — you'd
|
||||
need to add the domain again and re-publish its DNS records.
|
||||
</ConfirmDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.content { padding: 24px 40px 64px 40px; display: flex; flex-direction: column; gap: 12px; }
|
||||
|
||||
.empty { display: flex; align-items: center; gap: 16px; }
|
||||
.empty-title { font-weight: 600; }
|
||||
.empty-sub { font-size: 12px; color: var(--text-mute); margin-top: 2px; }
|
||||
|
||||
.head { display: flex; align-items: center; gap: 16px; }
|
||||
.title { flex: 1; min-width: 0; }
|
||||
|
||||
.remove {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
color: var(--text-mute);
|
||||
cursor: pointer;
|
||||
transition: color 120ms, border-color 120ms, background 120ms;
|
||||
}
|
||||
.remove:hover:not(:disabled) { color: var(--bad); border-color: var(--bad); }
|
||||
.remove:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
.domain-name { font-family: var(--font-mono); font-size: 16px; font-weight: 600; }
|
||||
.domain-sub { font-size: 12px; color: var(--text-mute); margin-top: 2px; }
|
||||
.warn { color: var(--warn); }
|
||||
|
||||
.prov-error {
|
||||
margin-top: 12px;
|
||||
font-size: 12px;
|
||||
color: var(--bad);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.records {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 8px;
|
||||
margin-top: 20px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.rec {
|
||||
padding: 10px 12px;
|
||||
background: var(--bg);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-family: inherit;
|
||||
text-align: left;
|
||||
transition: background 120ms, border-color 120ms;
|
||||
}
|
||||
.rec:hover { background: var(--surface); }
|
||||
.rec.active { background: var(--surface); border-color: var(--text); }
|
||||
.rec-right { display: flex; align-items: center; gap: 6px; }
|
||||
|
||||
.detail {
|
||||
margin-top: 16px;
|
||||
padding: 16px;
|
||||
background: var(--bg);
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border);
|
||||
border-left: 3px solid var(--border);
|
||||
}
|
||||
.detail[data-tone='ok'] { border-left-color: var(--ok); }
|
||||
.detail[data-tone='warn'] { border-left-color: var(--warn); }
|
||||
.detail[data-tone='bad'] { border-left-color: var(--bad); }
|
||||
.detail-head { display: flex; align-items: flex-start; gap: 12px; }
|
||||
.detail-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 8px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.detail-body { flex: 1; }
|
||||
.detail-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
font-family: var(--font-display);
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
}
|
||||
.detail-text { font-size: 13px; color: var(--text-dim); margin-top: 6px; line-height: 1.55; }
|
||||
.detail-close { background: transparent; border: none; padding: 4px; border-radius: 4px; color: var(--text-mute); cursor: pointer; }
|
||||
|
||||
.rec-action { margin-top: 16px; }
|
||||
.rec-grid {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
display: grid;
|
||||
grid-template-columns: 70px 1fr 80px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
overflow: hidden;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.rec-grid-label { padding: 10px 12px; color: var(--text-mute); border-right: 1px solid var(--border); }
|
||||
.rec-grid-label.sep { border-top: 1px solid var(--border); }
|
||||
.rec-grid-val { padding: 10px 12px; border-right: 1px solid var(--border); }
|
||||
.rec-grid-ttl { padding: 10px 12px; color: var(--text-mute); }
|
||||
.rec-grid-span {
|
||||
padding: 10px 12px;
|
||||
grid-column: 2 / 4;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
.rec-grid-span.sep { border-top: 1px solid var(--border); }
|
||||
.break { word-break: break-all; }
|
||||
.muted { color: var(--text-mute); }
|
||||
.copy { background: transparent; border: none; padding: 4px; border-radius: 4px; color: var(--text-mute); cursor: pointer; }
|
||||
.copy:hover { background: var(--bg); }
|
||||
|
||||
.rec-actions-row { display: flex; align-items: center; gap: 10px; margin-top: 12px; flex-wrap: wrap; }
|
||||
.spacer { flex: 1; }
|
||||
|
||||
.currently-set { margin-top: 12px; padding: 12px; background: var(--surface); border-radius: 6px; border: 1px solid var(--border); }
|
||||
.set-value { font-family: var(--font-mono); font-size: 12px; color: var(--text-dim); word-break: break-all; margin-top: 4px; }
|
||||
</style>
|
||||
+134
-110
@@ -1,61 +1,92 @@
|
||||
<script setup lang="ts">
|
||||
// Strict port of project/platform-screens.jsx `AdminDashboard` (lines 447-605).
|
||||
// Keep spacing tokens (24px 40px 64px 40px content, 16 gaps), the 4-column
|
||||
// stat strip in a single Card with per-column borders, the two-up
|
||||
// 1.4fr / 1fr blocks (License + Recent admin events; Issues + Quick actions),
|
||||
// the source's exact issue rows, audit slice, and quick-action buttons.
|
||||
|
||||
// Customer-admin dashboard. Layout descends from project/platform-screens.jsx
|
||||
// `AdminDashboard`, but the data is real: workspace identity, seats, spend,
|
||||
// plan and recent admin events all come from /api/me + /api/tenants/:slug/*.
|
||||
//
|
||||
// Storage usage is real now too (OCIS libregraph via /tenants/:slug/storage) —
|
||||
// shown as a second capacity bar in the Plan card. Sections still without a
|
||||
// backend (mail-flow health, "open issues" like DMARC/failed-login heuristics)
|
||||
// stay removed rather than faked until Stalwart metrics / a domain-health
|
||||
// checker exist.
|
||||
|
||||
import type { IconName } from '~/components/UiIcon.vue'
|
||||
import { sampleAudit } from '~/data/workspace'
|
||||
import type { AuditEventDoc, TenantUserDoc } from '~/types/workspace'
|
||||
|
||||
const toast = useToast()
|
||||
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] },
|
||||
)
|
||||
|
||||
// Aggregate storage usage (OCIS) — second capacity bar in the Plan card.
|
||||
interface StorageSummary {
|
||||
available: boolean
|
||||
usedBytes: number
|
||||
quotaBytes: number
|
||||
freeBytes: number
|
||||
}
|
||||
const { data: storage } = await useFetch<StorageSummary | null>(
|
||||
() => `/api/tenants/${slug.value}/storage`,
|
||||
{ key: 'admin-dash-storage', default: () => null, immediate: !!slug.value, watch: [slug] },
|
||||
)
|
||||
const storageAvailable = computed(() => storage.value?.available === true)
|
||||
const storagePct = computed(() => percent(storage.value?.usedBytes ?? 0, storage.value?.quotaBytes ?? 0))
|
||||
|
||||
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 inviteStep = ref(1)
|
||||
const seatsOpen = ref(false)
|
||||
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 }[] = [
|
||||
{ icon: 'users', label: 'Invite user', onClick: () => { inviteOpen.value = true } },
|
||||
{ icon: 'globe', label: 'Verify domain', onClick: () => router.push('/admin/domains') },
|
||||
@@ -71,16 +102,24 @@ function sendInvite() {
|
||||
toast.ok('Invitation sent to magnus@dezky.com')
|
||||
}
|
||||
|
||||
const pricePerSeat = 78
|
||||
const daysUntilRenewal = 96
|
||||
const monthly = computed(() => seatsExtra.value * pricePerSeat)
|
||||
const prorated = computed(() => Math.round(monthly.value * (daysUntilRenewal / 30)))
|
||||
// Add-seats modal math, fed by the real subscription. The seat-change mutation
|
||||
// itself isn't wired yet (subscription PATCH is operator-only), so confirming
|
||||
// still toasts — but the figures shown are the customer's real numbers.
|
||||
// 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>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<PageHeader
|
||||
eyebrow="Acme Workspace · dezky.com"
|
||||
:eyebrow="tenant ? `${tenant.name}${primaryDomain ? ` · ${primaryDomain}` : ''}` : 'Workspace'"
|
||||
title="Dashboard"
|
||||
subtitle="Health, activity, and quick actions across your workspace."
|
||||
>
|
||||
@@ -104,8 +143,6 @@ const prorated = computed(() => Math.round(monthly.value * (daysUntilRenewal / 3
|
||||
<Stat
|
||||
:label="s.label"
|
||||
:value="s.value"
|
||||
:delta="s.delta"
|
||||
:delta-tone="s.deltaTone"
|
||||
:hint="s.hint"
|
||||
/>
|
||||
</div>
|
||||
@@ -118,17 +155,29 @@ const prorated = computed(() => Math.round(monthly.value * (daysUntilRenewal / 3
|
||||
<div class="card-head card-head-inline">
|
||||
<div>
|
||||
<Eyebrow>Plan</Eyebrow>
|
||||
<div class="card-title">Business · 25 seats</div>
|
||||
<div class="card-sub">Renewing 28 August 2026 · 1.940 DKK / month</div>
|
||||
<div class="card-title">{{ planLabel }} · {{ seatLimit }} seats</div>
|
||||
<div class="card-sub">
|
||||
<template v-if="renewsAt">Renewing {{ fmtDate(renewsAt) }} · </template>{{ moneyFmt.format(monthlySpend) }} / month
|
||||
</div>
|
||||
</div>
|
||||
<UiButton size="sm" variant="secondary" @click="router.push('/admin/billing')">Manage plan</UiButton>
|
||||
</div>
|
||||
|
||||
<div class="progress-block">
|
||||
<div class="progress-bar"><span style="width: 44%" /></div>
|
||||
<div class="bar-label">Seats</div>
|
||||
<div class="progress-bar"><span :style="{ width: `${seatPct}%` }" /></div>
|
||||
<div class="progress-legend">
|
||||
<span>11 active</span>
|
||||
<span>14 available</span>
|
||||
<span>{{ seatsUsed }} active</span>
|
||||
<span>{{ seatsAvailable }} available</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="storageAvailable" class="progress-block">
|
||||
<div class="bar-label">Storage</div>
|
||||
<div class="progress-bar"><span :style="{ width: `${storagePct}%` }" /></div>
|
||||
<div class="progress-legend">
|
||||
<span>{{ formatBytes(storage!.usedBytes) }} used</span>
|
||||
<span>{{ formatBytes(storage!.freeBytes) }} free</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -160,31 +209,15 @@ const prorated = computed(() => Math.round(monthly.value * (daysUntilRenewal / 3
|
||||
</div>
|
||||
<Mono dim>{{ a.when }}</Mono>
|
||||
</div>
|
||||
<div v-if="recent.length === 0" class="audit-empty">
|
||||
<Mono dim>No recent activity yet.</Mono>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- Open issues + Quick actions -->
|
||||
<div class="row two-col-11">
|
||||
<Card>
|
||||
<div class="card-head card-head-inline">
|
||||
<div>
|
||||
<Eyebrow>Health</Eyebrow>
|
||||
<div class="card-title">Open issues</div>
|
||||
</div>
|
||||
<Badge tone="warn">2 to review</Badge>
|
||||
</div>
|
||||
<div class="issues">
|
||||
<div v-for="it in issues" :key="it.title" class="issue" :data-tone="it.tone">
|
||||
<div class="issue-body">
|
||||
<div class="issue-title">{{ it.title }}</div>
|
||||
<div class="issue-sub">{{ it.body }}</div>
|
||||
</div>
|
||||
<UiButton size="sm" variant="secondary" @click="it.onAction()">{{ it.action }}</UiButton>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<!-- Quick actions -->
|
||||
<div class="row">
|
||||
<Card>
|
||||
<div class="card-head card-head-inline">
|
||||
<div>
|
||||
@@ -192,7 +225,7 @@ const prorated = computed(() => Math.round(monthly.value * (daysUntilRenewal / 3
|
||||
<div class="card-title">Common tasks</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">
|
||||
<UiIcon :name="a.icon" :size="15" stroke="var(--text-mute)" />
|
||||
{{ a.label }}
|
||||
@@ -262,9 +295,9 @@ const prorated = computed(() => Math.round(monthly.value * (daysUntilRenewal / 3
|
||||
<Modal :open="seatsOpen" title="Add seats" eyebrow="Billing · seats" size="md" @close="seatsOpen = false">
|
||||
<div class="seats">
|
||||
<div class="seats-grid">
|
||||
<div class="seats-cell"><Eyebrow>Active users</Eyebrow><div class="seats-big">11</div></div>
|
||||
<div class="seats-cell"><Eyebrow>Current seats</Eyebrow><div class="seats-big">25</div></div>
|
||||
<div class="seats-cell"><Eyebrow>After change</Eyebrow><div class="seats-big ok">{{ 25 + seatsExtra }}</div></div>
|
||||
<div 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">{{ seatLimit }}</div></div>
|
||||
<div class="seats-cell"><Eyebrow>After change</Eyebrow><div class="seats-big ok">{{ seatLimit + seatsExtra }}</div></div>
|
||||
</div>
|
||||
<div>
|
||||
<Eyebrow>How many seats to add</Eyebrow>
|
||||
@@ -279,19 +312,19 @@ const prorated = computed(() => Math.round(monthly.value * (daysUntilRenewal / 3
|
||||
</div>
|
||||
<div class="charge-summary">
|
||||
<Eyebrow>What you'll pay</Eyebrow>
|
||||
<div class="charge-row"><span>{{ seatsExtra }} new seat{{ seatsExtra === 1 ? '' : 's' }} × {{ pricePerSeat }} DKK / month</span><Mono>{{ monthly.toLocaleString('da-DK') }} DKK / mo</Mono></div>
|
||||
<div class="charge-row sep"><span class="muted">Prorated for current cycle ({{ daysUntilRenewal }} days until renewal)</span><Mono dim>{{ prorated.toLocaleString('da-DK') }} DKK</Mono></div>
|
||||
<div class="charge-row total"><span>Charged today</span><span class="big">{{ prorated.toLocaleString('da-DK') }} DKK</span></div>
|
||||
<div class="charge-row"><span class="muted">Next invoice on 01 Jun 2026</span><Mono dim>{{ (1940 + monthly).toLocaleString('da-DK') }} DKK</Mono></div>
|
||||
<div 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>{{ moneyFmt.format(prorated) }}</Mono></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"><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 class="info-strip">
|
||||
<UiIcon name="card" :size="14" stroke="var(--text-mute)" />
|
||||
<span>Charged to <Mono>Visa •••• 4242</Mono>. Seats are added instantly — invitations can be sent right away.</span>
|
||||
<span>Seats are added instantly — invitations can be sent right away.</span>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<UiButton variant="ghost" @click="seatsOpen = false">Cancel</UiButton>
|
||||
<UiButton variant="primary" @click="() => { seatsOpen = false; toast.ok(`${seatsExtra} seats added · charged ${prorated.toLocaleString('da-DK')} DKK`) }">
|
||||
<UiButton variant="primary" @click="() => { seatsOpen = false; toast.ok(`${seatsExtra} seats added · charged ${moneyFmt.format(prorated)}`) }">
|
||||
<template #leading><UiIcon name="plus" :size="13" /></template>
|
||||
Add {{ seatsExtra }} seat{{ seatsExtra === 1 ? '' : 's' }}
|
||||
</UiButton>
|
||||
@@ -304,7 +337,6 @@ const prorated = computed(() => Math.round(monthly.value * (daysUntilRenewal / 3
|
||||
.content { padding: 24px 40px 64px 40px; }
|
||||
.row { display: grid; gap: 16px; margin-top: 16px; }
|
||||
.two-col-14 { grid-template-columns: 1.4fr 1fr; }
|
||||
.two-col-11 { grid-template-columns: 1fr 1fr; }
|
||||
|
||||
.strip { margin-bottom: 16px; }
|
||||
.strip-grid { display: grid; grid-template-columns: repeat(4, 1fr); }
|
||||
@@ -328,6 +360,14 @@ const prorated = computed(() => Math.round(monthly.value * (daysUntilRenewal / 3
|
||||
|
||||
/* License progress */
|
||||
.progress-block { margin-bottom: 16px; }
|
||||
.bar-label {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-mute);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.progress-bar {
|
||||
height: 8px;
|
||||
background: var(--bg);
|
||||
@@ -370,28 +410,12 @@ const prorated = computed(() => Math.round(monthly.value * (daysUntilRenewal / 3
|
||||
.audit-content { flex: 1; min-width: 0; }
|
||||
.audit-line { display: flex; gap: 6px; align-items: baseline; flex-wrap: wrap; }
|
||||
.audit-actor { font-weight: 500; }
|
||||
.audit-empty { padding: 24px 16px; text-align: center; }
|
||||
|
||||
/* Issues — strict bg with left tone border */
|
||||
.issues { display: flex; flex-direction: column; gap: 10px; }
|
||||
.issue {
|
||||
padding: 14px;
|
||||
background: var(--bg);
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
border-left: 2px solid var(--border);
|
||||
}
|
||||
.issue[data-tone='ok'] { border-left-color: var(--ok); }
|
||||
.issue[data-tone='warn'] { border-left-color: var(--warn); }
|
||||
.issue[data-tone='bad'] { border-left-color: var(--bad); }
|
||||
.issue[data-tone='info'] { border-left-color: var(--info); }
|
||||
.issue-body { flex: 1; min-width: 0; }
|
||||
.issue-title { font-size: 13px; font-weight: 500; }
|
||||
.issue-sub { font-size: 12px; color: var(--text-mute); margin-top: 2px; }
|
||||
|
||||
/* Quick actions — 2-col grid of "tiles" */
|
||||
/* Quick actions — grid of "tiles" */
|
||||
.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 {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
|
||||
@@ -1,42 +1,135 @@
|
||||
<script setup lang="ts">
|
||||
// Strict port of project/platform-screens.jsx `SecurityScreen` (lines 2187-2310)
|
||||
// and RadioBig (line 2311). Two tabs: Security · Audit log. Same cards, same
|
||||
// copy, same SSO apps, same audit-log column structure with sample rows.
|
||||
// Security & audit. Audit log: real (filterable, paginated, CSV export).
|
||||
// Security tab: MFA-enrollment overview is a live read; the policy (MFA mode,
|
||||
// session timeouts, allowed countries, IP allow-list) is saved as real intent —
|
||||
// enforcement via Authentik is wired in a later stage, so each control is
|
||||
// labelled with its enforcement status. SSO apps are still coming soon.
|
||||
|
||||
|
||||
import { sampleAudit } from '~/data/workspace'
|
||||
import type { AuditEventDoc, MfaStatus, SsoApp, SsoAppCreated } from '~/types/workspace'
|
||||
|
||||
const tab = ref<'security' | 'audit'>('security')
|
||||
const mfa = ref<'all' | 'admins' | 'optional'>('admins')
|
||||
|
||||
const toast = useToast()
|
||||
const addCountryOpen = ref(false)
|
||||
const newAllowCountry = ref('')
|
||||
|
||||
function ssoAction(name: string, id: string) {
|
||||
if (id === 'configure') toast.info(`Configure ${name}`)
|
||||
else if (id === 'test') toast.info(`Sending test sign-in to ${name}`)
|
||||
else if (id === 'rotate') toast.info(`Rotating certificate for ${name}`)
|
||||
else if (id === 'disconnect') toast.warn(`${name} disconnected`)
|
||||
}
|
||||
const ssoItems = [
|
||||
{ id: 'configure', label: 'Configure', icon: 'brush' as const },
|
||||
{ id: 'test', label: 'Send test sign-in', icon: 'key' as const },
|
||||
{ id: 'rotate', label: 'Rotate certificate', icon: 'refresh' as const },
|
||||
{ id: 'sep1', separator: true },
|
||||
{ id: 'disconnect', label: 'Disconnect', icon: 'plug' as const, danger: true },
|
||||
// ── Audit log (real) ─────────────────────────────────────────────────────
|
||||
const { fetchMe } = useMe()
|
||||
await fetchMe()
|
||||
const { tenant } = useTenant()
|
||||
const slug = computed(() => tenant.value?.slug ?? '')
|
||||
|
||||
const search = ref('')
|
||||
const actionFilter = ref('') // action prefix, e.g. 'billing'
|
||||
const outcomeFilter = ref<'' | 'success' | 'failure'>('')
|
||||
const sinceFilter = ref<'' | '1d' | '7d' | '30d'>('')
|
||||
|
||||
const QUICK_ACTIONS = [
|
||||
{ label: 'All', value: '' },
|
||||
{ label: 'Users', value: 'user' },
|
||||
{ label: 'Billing', value: 'billing' },
|
||||
{ label: 'Branding', value: 'tenant.branding' },
|
||||
{ label: 'Auth', value: 'authentik' },
|
||||
{ label: 'Tenant', value: 'tenant' },
|
||||
]
|
||||
|
||||
function removeCountry(c: string) {
|
||||
toast.info(`${c} removed from allow-list`)
|
||||
function sinceIso(v: string): string | undefined {
|
||||
if (!v) return undefined
|
||||
const days = v === '1d' ? 1 : v === '7d' ? 7 : 30
|
||||
return new Date(Date.now() - days * 86_400_000).toISOString()
|
||||
}
|
||||
|
||||
const ssoApps = [
|
||||
{ n: 'Notion', p: 'SAML', s: 'ok' as const },
|
||||
{ n: 'Figma', p: 'SAML', s: 'ok' as const },
|
||||
{ n: 'Linear', p: 'OIDC', s: 'ok' as const },
|
||||
{ n: 'GitHub', p: 'OIDC', s: 'warn' as const },
|
||||
]
|
||||
const params = computed<Record<string, string>>(() => {
|
||||
const p: Record<string, string> = { limit: '200' }
|
||||
if (search.value.trim()) p.q = search.value.trim()
|
||||
if (actionFilter.value) p.action = actionFilter.value
|
||||
if (outcomeFilter.value) p.outcome = outcomeFilter.value
|
||||
const since = sinceIso(sinceFilter.value)
|
||||
if (since) p.since = since
|
||||
return p
|
||||
})
|
||||
|
||||
const { data: auditPage } = await useFetch<AuditEventDoc[]>(
|
||||
() => `/api/tenants/${slug.value}/audit`,
|
||||
{ key: 'admin-audit', default: () => [], query: params, immediate: !!slug.value, watch: [slug] },
|
||||
)
|
||||
|
||||
const olderPages = ref<AuditEventDoc[]>([])
|
||||
const reachedEnd = ref(false)
|
||||
const loadingMore = ref(false)
|
||||
const auditRows = computed(() => [...(auditPage.value ?? []), ...olderPages.value])
|
||||
|
||||
// New filter set → drop accumulated older pages.
|
||||
watch(params, () => {
|
||||
olderPages.value = []
|
||||
reachedEnd.value = false
|
||||
})
|
||||
|
||||
async function loadMore() {
|
||||
const last = auditRows.value[auditRows.value.length - 1]
|
||||
if (!last || loadingMore.value) return
|
||||
loadingMore.value = true
|
||||
try {
|
||||
const next = await $fetch<AuditEventDoc[]>(`/api/tenants/${slug.value}/audit`, {
|
||||
query: { ...params.value, before: last.at },
|
||||
})
|
||||
if (!next.length) reachedEnd.value = true
|
||||
else olderPages.value.push(...next)
|
||||
} finally {
|
||||
loadingMore.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function fmtTime(iso: string): string {
|
||||
return new Date(iso).toLocaleString('da-DK', { dateStyle: 'short', timeStyle: 'medium' })
|
||||
}
|
||||
function actorName(e: AuditEventDoc): string {
|
||||
return e.actorType === 'system' ? 'system' : e.actorEmail ?? '—'
|
||||
}
|
||||
function targetOf(e: AuditEventDoc): string {
|
||||
return e.resourceName ?? e.resourceId ?? ''
|
||||
}
|
||||
function auditTone(e: AuditEventDoc): 'info' | 'warn' | 'bad' {
|
||||
if (e.outcome === 'failure') return 'bad'
|
||||
if (/suspend|delete|terminat|revok|disable/.test(e.action)) return 'warn'
|
||||
return 'info'
|
||||
}
|
||||
|
||||
function exportCsv() {
|
||||
const rows = auditRows.value
|
||||
if (!rows.length) {
|
||||
toast.info('Nothing to export', 'No events match the current filters')
|
||||
return
|
||||
}
|
||||
const esc = (s: string) => `"${String(s ?? '').replace(/"/g, '""')}"`
|
||||
const header = ['Time', 'Actor', 'Action', 'Target', 'Outcome', 'IP']
|
||||
const lines = rows.map((e) =>
|
||||
[fmtTime(e.at), actorName(e), e.action, targetOf(e), e.outcome, e.actorIp ?? ''].map(esc).join(','),
|
||||
)
|
||||
const csv = [header.map(esc).join(','), ...lines].join('\n')
|
||||
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `audit-${slug.value}-${new Date().toISOString().slice(0, 10)}.csv`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
toast.ok(`Exported ${rows.length} events`, 'CSV · current view')
|
||||
}
|
||||
|
||||
// ── Security policy (real, stored intent) ────────────────────────────────
|
||||
const { request } = useApiFetch()
|
||||
|
||||
// Live MFA-enrollment overview.
|
||||
const { data: mfaStatus } = await useFetch<MfaStatus>(
|
||||
() => `/api/tenants/${slug.value}/mfa-status`,
|
||||
{ key: 'admin-mfa', default: () => ({ total: 0, enrolled: 0, members: [] }), immediate: !!slug.value, watch: [slug] },
|
||||
)
|
||||
const notEnrolled = computed(() => (mfaStatus.value?.members ?? []).filter((m) => !m.enrolled))
|
||||
const mfaPct = computed(() => {
|
||||
const t = mfaStatus.value?.total ?? 0
|
||||
return t ? Math.round(((mfaStatus.value?.enrolled ?? 0) / t) * 100) : 0
|
||||
})
|
||||
|
||||
const mfaOptions = [
|
||||
{ v: 'all' as const, label: 'Required for everyone', d: 'All members must enroll TOTP or WebAuthn at next sign-in.' },
|
||||
@@ -44,7 +137,120 @@ const mfaOptions = [
|
||||
{ v: 'optional' as const, label: 'Optional', d: 'No enforcement. Not recommended for compliance work.' },
|
||||
]
|
||||
|
||||
const countries = ['Denmark', 'Sweden', 'Norway', 'Germany', 'Netherlands']
|
||||
// Editable policy, seeded from the tenant doc; persisted via PATCH.
|
||||
const policy = reactive({
|
||||
mfaMode: 'optional' as 'all' | 'admins' | 'optional',
|
||||
sessionIdleMinutes: 30,
|
||||
sessionAbsoluteHours: 24,
|
||||
allowedCountries: [] as string[],
|
||||
ipAllowlist: [] as string[],
|
||||
})
|
||||
function seedPolicy() {
|
||||
const p = tenant.value?.securityPolicy
|
||||
policy.mfaMode = p?.mfaMode ?? 'optional'
|
||||
policy.sessionIdleMinutes = p?.sessionIdleMinutes ?? 30
|
||||
policy.sessionAbsoluteHours = p?.sessionAbsoluteHours ?? 24
|
||||
policy.allowedCountries = [...(p?.allowedCountries ?? [])]
|
||||
policy.ipAllowlist = [...(p?.ipAllowlist ?? [])]
|
||||
}
|
||||
seedPolicy()
|
||||
watch(tenant, seedPolicy)
|
||||
|
||||
const savingPolicy = ref(false)
|
||||
async function savePolicy() {
|
||||
if (!slug.value) return
|
||||
savingPolicy.value = true
|
||||
try {
|
||||
await request(`/api/tenants/${slug.value}/security-policy`, { method: 'PATCH', body: { ...policy } })
|
||||
await fetchMe(true)
|
||||
toast.ok('Security policy saved')
|
||||
} catch (e: unknown) {
|
||||
const msg = (e as { data?: { message?: string | string[] } })?.data?.message
|
||||
toast.bad('Could not save policy', Array.isArray(msg) ? msg.join(', ') : msg)
|
||||
} finally {
|
||||
savingPolicy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function removeCountry(c: string) {
|
||||
policy.allowedCountries = policy.allowedCountries.filter((x) => x !== c)
|
||||
}
|
||||
function addCountry() {
|
||||
const c = newAllowCountry.value.trim().toUpperCase()
|
||||
if (c && !policy.allowedCountries.includes(c)) policy.allowedCountries.push(c)
|
||||
addCountryOpen.value = false
|
||||
newAllowCountry.value = ''
|
||||
}
|
||||
|
||||
const newIp = ref('')
|
||||
function addIp() {
|
||||
const ip = newIp.value.trim()
|
||||
if (ip && !policy.ipAllowlist.includes(ip)) policy.ipAllowlist.push(ip)
|
||||
newIp.value = ''
|
||||
}
|
||||
function removeIp(ip: string) {
|
||||
policy.ipAllowlist = policy.ipAllowlist.filter((x) => x !== ip)
|
||||
}
|
||||
|
||||
// ── SSO apps (Dezky as IdP) — real Authentik OIDC providers/applications ──
|
||||
const { data: ssoApps, refresh: refreshSso } = await useFetch<SsoApp[]>(
|
||||
() => `/api/tenants/${slug.value}/sso-apps`,
|
||||
{ key: 'admin-sso', default: () => [], immediate: !!slug.value, watch: [slug] },
|
||||
)
|
||||
|
||||
const ssoOpen = ref(false)
|
||||
const ssoName = ref('')
|
||||
const ssoRedirects = ref('')
|
||||
const creatingSso = ref(false)
|
||||
const ssoCreated = ref<SsoAppCreated | null>(null)
|
||||
|
||||
function openSso() {
|
||||
ssoName.value = ''
|
||||
ssoRedirects.value = ''
|
||||
ssoCreated.value = null
|
||||
ssoOpen.value = true
|
||||
}
|
||||
async function createSso() {
|
||||
const redirectUris = ssoRedirects.value.split('\n').map((s) => s.trim()).filter(Boolean)
|
||||
if (!ssoName.value.trim() || redirectUris.length === 0) return
|
||||
creatingSso.value = true
|
||||
try {
|
||||
ssoCreated.value = await request<SsoAppCreated>(`/api/tenants/${slug.value}/sso-apps`, {
|
||||
method: 'POST',
|
||||
body: { name: ssoName.value.trim(), redirectUris },
|
||||
})
|
||||
await refreshSso()
|
||||
toast.ok('SSO app created')
|
||||
} catch (e: unknown) {
|
||||
const msg = (e as { data?: { message?: string | string[] } })?.data?.message
|
||||
toast.bad('Could not create SSO app', Array.isArray(msg) ? msg.join(', ') : msg)
|
||||
} finally {
|
||||
creatingSso.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const ssoDeleteId = ref<string | null>(null)
|
||||
const ssoDeleteName = computed(() => (ssoApps.value ?? []).find((a) => a.id === ssoDeleteId.value)?.name ?? '')
|
||||
async function confirmDeleteSso() {
|
||||
const id = ssoDeleteId.value
|
||||
if (!id) return
|
||||
try {
|
||||
await request(`/api/tenants/${slug.value}/sso-apps/${id}`, { method: 'DELETE' })
|
||||
await refreshSso()
|
||||
toast.ok('SSO app removed')
|
||||
} catch (e: unknown) {
|
||||
const msg = (e as { data?: { message?: string } })?.data?.message
|
||||
toast.bad('Could not remove SSO app', msg)
|
||||
} finally {
|
||||
ssoDeleteId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function copyText(text?: string) {
|
||||
if (!text) return
|
||||
navigator.clipboard?.writeText(text)
|
||||
toast.ok('Copied to clipboard')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -52,8 +258,15 @@ const countries = ['Denmark', 'Sweden', 'Norway', 'Germany', 'Netherlands']
|
||||
<PageHeader
|
||||
eyebrow="Compliance"
|
||||
title="Security & audit"
|
||||
subtitle="Policies, identity controls, and a tamper-evident log of every administrative action."
|
||||
/>
|
||||
subtitle="Identity controls, network policy, and a full log of every administrative action."
|
||||
>
|
||||
<template #actions>
|
||||
<UiButton v-if="tab === 'security'" variant="primary" :disabled="savingPolicy || !slug" @click="savePolicy">
|
||||
<template #leading><UiIcon name="check" :size="14" /></template>
|
||||
{{ savingPolicy ? 'Saving…' : 'Save policy' }}
|
||||
</UiButton>
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<div class="tab-wrap">
|
||||
<Tabs
|
||||
@@ -71,78 +284,127 @@ const countries = ['Denmark', 'Sweden', 'Norway', 'Germany', 'Netherlands']
|
||||
<Eyebrow>Identity</Eyebrow>
|
||||
<div class="card-title">Multi-factor authentication</div>
|
||||
</div>
|
||||
|
||||
<!-- Live enrollment overview -->
|
||||
<div class="mfa-overview">
|
||||
<div class="mfa-stat">
|
||||
<div class="mfa-num">{{ mfaStatus.enrolled }} / {{ mfaStatus.total }}</div>
|
||||
<Mono dim>members enrolled</Mono>
|
||||
</div>
|
||||
<div class="mfa-bar-wrap">
|
||||
<div class="mfa-bar"><span :style="{ width: `${mfaPct}%` }" /></div>
|
||||
<Mono dim>{{ mfaPct }}% of active members have TOTP or WebAuthn</Mono>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="notEnrolled.length" class="mfa-missing">
|
||||
<Mono dim>Not enrolled:</Mono>
|
||||
<span class="missing-names">{{ notEnrolled.map((m) => m.name || m.email).join(', ') }}</span>
|
||||
</div>
|
||||
|
||||
<div class="sub-head">
|
||||
<Eyebrow>Enforcement policy</Eyebrow>
|
||||
<Badge tone="warn" dot>rolling out</Badge>
|
||||
</div>
|
||||
<div class="radio-big">
|
||||
<label v-for="o in mfaOptions" :key="o.v" :class="{ active: mfa === o.v }">
|
||||
<span class="radio-dot"><span v-if="mfa === o.v" /></span>
|
||||
<input type="radio" :value="o.v" v-model="mfa" />
|
||||
<label v-for="o in mfaOptions" :key="o.v" :class="{ active: policy.mfaMode === o.v }">
|
||||
<span class="radio-dot"><span v-if="policy.mfaMode === o.v" /></span>
|
||||
<input type="radio" :value="o.v" v-model="policy.mfaMode" />
|
||||
<div>
|
||||
<div class="radio-label">{{ o.label }}</div>
|
||||
<div class="radio-d">{{ o.d }}</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
<div class="enforce-note">
|
||||
<UiIcon name="shield" :size="13" stroke="var(--text-mute)" />
|
||||
<span>Saved now; automatic enforcement through your identity provider is being rolled out.</span>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<div class="card-head">
|
||||
<div class="card-head card-head-inline">
|
||||
<div>
|
||||
<Eyebrow>Sessions</Eyebrow>
|
||||
<div class="card-title">Session policy</div>
|
||||
</div>
|
||||
<Badge tone="neutral" dot>not enforced yet</Badge>
|
||||
</div>
|
||||
<div class="grid-2">
|
||||
<label class="field"><Eyebrow>Idle timeout</Eyebrow>
|
||||
<div class="input-faux">
|
||||
<input value="30 minutes" />
|
||||
<UiIcon name="chevDown" :size="12" stroke="var(--text-mute)" />
|
||||
</div>
|
||||
<label class="field"><Eyebrow>Idle timeout (minutes)</Eyebrow>
|
||||
<input class="input" type="number" min="0" max="1440" v-model.number="policy.sessionIdleMinutes" />
|
||||
</label>
|
||||
<label class="field"><Eyebrow>Absolute timeout</Eyebrow>
|
||||
<div class="input-faux">
|
||||
<input value="24 hours" />
|
||||
<UiIcon name="chevDown" :size="12" stroke="var(--text-mute)" />
|
||||
</div>
|
||||
<label class="field"><Eyebrow>Absolute timeout (hours)</Eyebrow>
|
||||
<input class="input" type="number" min="0" max="8760" v-model.number="policy.sessionAbsoluteHours" />
|
||||
</label>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<div class="card-head">
|
||||
<div class="card-head card-head-inline">
|
||||
<div>
|
||||
<Eyebrow>Network</Eyebrow>
|
||||
<div class="card-title">Geo-fencing & allow-lists</div>
|
||||
</div>
|
||||
<Badge tone="neutral" dot>not enforced yet</Badge>
|
||||
</div>
|
||||
<div class="field">
|
||||
<Eyebrow>Allowed countries</Eyebrow>
|
||||
<div class="chip-row">
|
||||
<Badge v-for="c in countries" :key="c" tone="neutral">
|
||||
<Badge v-for="c in policy.allowedCountries" :key="c" tone="neutral">
|
||||
{{ c }}
|
||||
<button class="badge-x" @click="removeCountry(c)" aria-label="Remove country">
|
||||
<UiIcon name="x" :size="10" />
|
||||
</button>
|
||||
</Badge>
|
||||
<Mono v-if="policy.allowedCountries.length === 0" dim>Any country allowed</Mono>
|
||||
<UiButton size="sm" variant="ghost" @click="addCountryOpen = true">
|
||||
<template #leading><UiIcon name="plus" :size="12" /></template>
|
||||
Add country
|
||||
</UiButton>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field" style="margin-top: 16px">
|
||||
<Eyebrow>IP allow-list (CIDR)</Eyebrow>
|
||||
<div class="chip-row">
|
||||
<Badge v-for="ip in policy.ipAllowlist" :key="ip" tone="neutral">
|
||||
<Mono>{{ ip }}</Mono>
|
||||
<button class="badge-x" @click="removeIp(ip)" aria-label="Remove IP range">
|
||||
<UiIcon name="x" :size="10" />
|
||||
</button>
|
||||
</Badge>
|
||||
<Mono v-if="policy.ipAllowlist.length === 0" dim>No IP restriction</Mono>
|
||||
</div>
|
||||
<div class="ip-add">
|
||||
<input class="input" v-model="newIp" placeholder="e.g. 203.0.113.0/24" @keyup.enter="addIp" />
|
||||
<UiButton size="sm" variant="secondary" :disabled="!newIp.trim()" @click="addIp">Add</UiButton>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<div class="card-head">
|
||||
<div class="card-head card-head-inline">
|
||||
<div>
|
||||
<Eyebrow>SSO</Eyebrow>
|
||||
<div class="card-title">dezky as identity provider</div>
|
||||
<div class="card-sub">Let external apps sign your team in with dezky via OpenID Connect.</div>
|
||||
</div>
|
||||
<div class="sso-intro">
|
||||
Connect external applications via OIDC or SAML. dezky's Authentik instance is the source of truth for identity.
|
||||
<UiButton size="sm" variant="primary" @click="openSso">
|
||||
<template #leading><UiIcon name="plus" :size="13" /></template>
|
||||
Add app
|
||||
</UiButton>
|
||||
</div>
|
||||
<div class="sso-list">
|
||||
<div v-for="a in ssoApps" :key="a.n" class="sso-row">
|
||||
<div class="sso-icon">{{ a.n[0] }}</div>
|
||||
<div v-for="a in ssoApps" :key="a.id" class="sso-row">
|
||||
<div class="sso-icon">{{ a.name[0]?.toUpperCase() }}</div>
|
||||
<div class="sso-meta">
|
||||
<div class="sso-name">{{ a.n }}</div>
|
||||
<Mono dim>{{ a.p }} · provisioned</Mono>
|
||||
<div class="sso-name">{{ a.name }}</div>
|
||||
<Mono dim>{{ a.protocol.toUpperCase() }} · {{ a.clientId }}</Mono>
|
||||
</div>
|
||||
<Badge :tone="a.s" dot>{{ a.s === 'ok' ? 'connected' : 'cert expiring' }}</Badge>
|
||||
<AdminKebabMenu :items="ssoItems" @select="(id) => ssoAction(a.n, id)" />
|
||||
<Badge tone="ok" dot>connected</Badge>
|
||||
<button class="icon-del" title="Remove" @click="ssoDeleteId = a.id"><UiIcon name="trash" :size="14" /></button>
|
||||
</div>
|
||||
<div v-if="(ssoApps?.length ?? 0) === 0" class="sso-empty">
|
||||
<Mono dim>No SSO apps yet. Add one to let an external app authenticate with dezky.</Mono>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
@@ -152,17 +414,36 @@ const countries = ['Denmark', 'Sweden', 'Norway', 'Germany', 'Netherlands']
|
||||
<div class="toolbar">
|
||||
<div class="input-search">
|
||||
<UiIcon name="search" :size="14" stroke="var(--text-mute)" />
|
||||
<input placeholder="action.type, actor, target…" />
|
||||
<input v-model="search" placeholder="action, actor, target…" />
|
||||
</div>
|
||||
<button class="chip"><Eyebrow>Actor:</Eyebrow> <span>All</span> <UiIcon name="chevDown" :size="12" stroke="var(--text-mute)" /></button>
|
||||
<button class="chip"><Eyebrow>Action:</Eyebrow> <span>All</span> <UiIcon name="chevDown" :size="12" stroke="var(--text-mute)" /></button>
|
||||
<button class="chip"><Eyebrow>Last:</Eyebrow> <span>7 days</span> <UiIcon name="chevDown" :size="12" stroke="var(--text-mute)" /></button>
|
||||
<select v-model="outcomeFilter" class="select">
|
||||
<option value="">Any outcome</option>
|
||||
<option value="success">Success</option>
|
||||
<option value="failure">Failure</option>
|
||||
</select>
|
||||
<select v-model="sinceFilter" class="select">
|
||||
<option value="">All time</option>
|
||||
<option value="1d">Last 24h</option>
|
||||
<option value="7d">Last 7 days</option>
|
||||
<option value="30d">Last 30 days</option>
|
||||
</select>
|
||||
<div class="spacer" />
|
||||
<UiButton variant="secondary" @click="toast.info('Exporting audit log…', 'CSV · last 7 days · ~4,218 events')">
|
||||
<UiButton variant="secondary" @click="exportCsv">
|
||||
<template #leading><UiIcon name="download" :size="14" /></template>
|
||||
Export CSV
|
||||
</UiButton>
|
||||
</div>
|
||||
|
||||
<div class="action-chips">
|
||||
<button
|
||||
v-for="qa in QUICK_ACTIONS"
|
||||
:key="qa.value"
|
||||
class="achip"
|
||||
:class="{ on: actionFilter === qa.value }"
|
||||
@click="actionFilter = qa.value"
|
||||
>{{ qa.label }}</button>
|
||||
</div>
|
||||
|
||||
<Card :pad="0">
|
||||
<table class="audit-table">
|
||||
<thead>
|
||||
@@ -176,25 +457,33 @@ const countries = ['Denmark', 'Sweden', 'Norway', 'Germany', 'Netherlands']
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="a in sampleAudit" :key="a.id">
|
||||
<td><Mono>{{ a.when }}</Mono></td>
|
||||
<tr v-for="a in auditRows" :key="a._id">
|
||||
<td><Mono>{{ fmtTime(a.at) }}</Mono></td>
|
||||
<td>
|
||||
<div class="actor-cell">
|
||||
<Avatar v-if="a.actor !== 'system'" :name="a.actor" :size="22" />
|
||||
<Avatar v-if="a.actorType !== 'system'" :name="actorName(a)" :size="22" />
|
||||
<div v-else class="sys">sys</div>
|
||||
<span>{{ a.actor }}</span>
|
||||
<span>{{ actorName(a) }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td><Mono>{{ a.action }}</Mono></td>
|
||||
<td class="target">{{ a.target }}</td>
|
||||
<td><Mono dim>{{ a.ip }}</Mono></td>
|
||||
<td class="right"><Badge :tone="a.tone" dot>{{ a.tone }}</Badge></td>
|
||||
<td class="target">{{ targetOf(a) }}</td>
|
||||
<td><Mono dim>{{ a.actorIp || '—' }}</Mono></td>
|
||||
<td class="right"><Badge :tone="auditTone(a)" dot>{{ a.outcome }}</Badge></td>
|
||||
</tr>
|
||||
<tr v-if="auditRows.length === 0" class="no-hover">
|
||||
<td colspan="6" class="empty-row"><Mono dim>No audit events match your filters.</Mono></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</Card>
|
||||
<div class="retention">
|
||||
<Mono dim>// retention · 365 days · tamper-evident · last verified 14:32:01 today</Mono>
|
||||
|
||||
<div class="audit-foot">
|
||||
<Mono dim>{{ auditRows.length }} event{{ auditRows.length === 1 ? '' : 's' }} shown</Mono>
|
||||
<UiButton v-if="!reachedEnd && auditRows.length > 0" size="sm" variant="secondary" :disabled="loadingMore" @click="loadMore">
|
||||
{{ loadingMore ? 'Loading…' : 'Load older' }}
|
||||
</UiButton>
|
||||
<Mono v-else-if="auditRows.length > 0" dim>· end of log</Mono>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -207,15 +496,61 @@ const countries = ['Denmark', 'Sweden', 'Norway', 'Germany', 'Netherlands']
|
||||
</div>
|
||||
<template #footer>
|
||||
<UiButton variant="ghost" @click="addCountryOpen = false">Cancel</UiButton>
|
||||
<UiButton
|
||||
variant="primary"
|
||||
:disabled="!newAllowCountry"
|
||||
@click="addCountryOpen = false; toast.ok(`Country ${newAllowCountry} added`); newAllowCountry = ''"
|
||||
>
|
||||
<UiButton variant="primary" :disabled="!newAllowCountry" @click="addCountry">
|
||||
Add
|
||||
</UiButton>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
<!-- Add SSO app modal -->
|
||||
<Modal :open="ssoOpen" eyebrow="Security · SSO" :title="ssoCreated ? 'SSO app created' : 'Add SSO app'" size="md" @close="ssoOpen = false">
|
||||
<!-- Step 1: form -->
|
||||
<div v-if="!ssoCreated" class="form-stack">
|
||||
<label class="field"><Eyebrow>App name</Eyebrow>
|
||||
<input class="input" v-model="ssoName" placeholder="e.g. Internal Wiki" />
|
||||
</label>
|
||||
<label class="field"><Eyebrow>Redirect URIs (one per line)</Eyebrow>
|
||||
<textarea v-model="ssoRedirects" class="ta" rows="3" placeholder="https://app.example.com/oauth/callback" />
|
||||
</label>
|
||||
<div class="enforce-note">
|
||||
<UiIcon name="key" :size="13" stroke="var(--text-mute)" />
|
||||
<span>Creates an OpenID Connect app in your identity provider, accessible only to this workspace.</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Step 2: credentials (shown once) -->
|
||||
<div v-else class="form-stack">
|
||||
<div class="cred-warn">
|
||||
<UiIcon name="shield" :size="14" stroke="var(--warn)" />
|
||||
<span>Copy the client secret now — it won't be shown again.</span>
|
||||
</div>
|
||||
<div class="cred-row"><Eyebrow>Client ID</Eyebrow><div class="cred-val"><Mono>{{ ssoCreated.clientId }}</Mono><button class="copy" @click="copyText(ssoCreated.clientId)"><UiIcon name="copy" :size="12" /></button></div></div>
|
||||
<div class="cred-row"><Eyebrow>Client secret</Eyebrow><div class="cred-val"><Mono>{{ ssoCreated.clientSecret }}</Mono><button class="copy" @click="copyText(ssoCreated.clientSecret)"><UiIcon name="copy" :size="12" /></button></div></div>
|
||||
<div class="cred-row"><Eyebrow>Discovery URL</Eyebrow><div class="cred-val"><Mono>{{ ssoCreated.wellKnownUrl }}</Mono><button class="copy" @click="copyText(ssoCreated.wellKnownUrl)"><UiIcon name="copy" :size="12" /></button></div></div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<template v-if="!ssoCreated">
|
||||
<UiButton variant="ghost" @click="ssoOpen = false">Cancel</UiButton>
|
||||
<UiButton variant="primary" :disabled="creatingSso || !ssoName.trim() || !ssoRedirects.trim()" @click="createSso">
|
||||
<template #leading><UiIcon name="check" :size="13" /></template>
|
||||
{{ creatingSso ? 'Creating…' : 'Create app' }}
|
||||
</UiButton>
|
||||
</template>
|
||||
<UiButton v-else variant="primary" @click="ssoOpen = false">Done</UiButton>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
<!-- Delete SSO app confirm -->
|
||||
<ConfirmDialog
|
||||
:open="!!ssoDeleteId"
|
||||
eyebrow="Security · SSO"
|
||||
:title="`Remove ${ssoDeleteName}?`"
|
||||
confirm-label="Remove app"
|
||||
tone="danger"
|
||||
@close="ssoDeleteId = null"
|
||||
@confirm="confirmDeleteSso"
|
||||
>
|
||||
The app's OpenID Connect provider is deleted from your identity provider. Anyone using it to sign in will lose access immediately.
|
||||
</ConfirmDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -342,6 +677,36 @@ const countries = ['Denmark', 'Sweden', 'Norway', 'Germany', 'Netherlands']
|
||||
.chip span { font-weight: 500; }
|
||||
.spacer { flex: 1; }
|
||||
|
||||
.select {
|
||||
height: 36px;
|
||||
padding: 0 10px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.action-chips { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 12px; }
|
||||
.achip {
|
||||
padding: 5px 12px;
|
||||
border-radius: 999px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
cursor: pointer;
|
||||
}
|
||||
.achip:hover { color: var(--text); }
|
||||
.achip.on { background: var(--text); color: var(--bg); border-color: var(--text); }
|
||||
|
||||
.audit-foot { display: flex; align-items: center; gap: 12px; margin-top: 12px; }
|
||||
.no-hover td { cursor: default; }
|
||||
.empty-row { text-align: center; padding: 40px 16px; }
|
||||
|
||||
.audit-table { width: 100%; border-collapse: collapse; }
|
||||
.audit-table thead th {
|
||||
text-align: left;
|
||||
@@ -397,4 +762,55 @@ const countries = ['Denmark', 'Sweden', 'Norway', 'Germany', 'Netherlands']
|
||||
.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); }
|
||||
|
||||
/* MFA overview */
|
||||
.card-head-inline { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
|
||||
.mfa-overview { display: flex; align-items: center; gap: 24px; margin-bottom: 12px; }
|
||||
.mfa-stat { flex-shrink: 0; }
|
||||
.mfa-num { font-family: var(--font-display); font-weight: 600; font-size: 28px; letter-spacing: -0.01em; }
|
||||
.mfa-bar-wrap { flex: 1; min-width: 0; }
|
||||
.mfa-bar { height: 8px; background: var(--bg); border-radius: 999px; overflow: hidden; margin-bottom: 6px; }
|
||||
.mfa-bar span { display: block; height: 100%; background: var(--ok); }
|
||||
.mfa-missing { display: flex; gap: 8px; align-items: baseline; flex-wrap: wrap; padding: 10px 12px; background: var(--bg); border-radius: 6px; font-size: 12px; }
|
||||
.missing-names { color: var(--text-dim); }
|
||||
|
||||
.sub-head { display: flex; align-items: center; gap: 8px; margin: 20px 0 10px; }
|
||||
.enforce-note { display: flex; align-items: center; gap: 8px; margin-top: 12px; font-size: 12px; color: var(--text-mute); }
|
||||
|
||||
.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;
|
||||
}
|
||||
.ip-add { display: flex; gap: 8px; margin-top: 10px; }
|
||||
.ip-add .input { flex: 1; }
|
||||
|
||||
/* SSO */
|
||||
.sso-empty { padding: 16px 0; }
|
||||
.icon-del {
|
||||
background: transparent; border: 1px solid var(--border); border-radius: 6px;
|
||||
width: 30px; height: 30px; display: inline-flex; align-items: center; justify-content: center;
|
||||
color: var(--text-mute); cursor: pointer;
|
||||
}
|
||||
.icon-del:hover { color: var(--bad); border-color: var(--bad); }
|
||||
.ta {
|
||||
width: 100%; box-sizing: border-box; padding: 10px 12px; background: var(--surface);
|
||||
border: 1px solid var(--border); border-radius: 6px; font-family: var(--font-mono);
|
||||
font-size: 12px; color: var(--text); outline: none; resize: vertical; line-height: 1.6;
|
||||
}
|
||||
.ta:focus { border-color: var(--text); }
|
||||
.cred-warn {
|
||||
display: flex; gap: 10px; align-items: center; padding: 12px;
|
||||
background: rgba(232, 154, 31, 0.06); border: 1px solid rgba(232, 154, 31, 0.2);
|
||||
border-radius: 6px; font-size: 12px; color: var(--text-dim);
|
||||
}
|
||||
.cred-row { display: flex; flex-direction: column; gap: 6px; }
|
||||
.cred-val {
|
||||
display: flex; align-items: center; gap: 8px; padding: 8px 12px;
|
||||
background: var(--bg); border: 1px solid var(--border); border-radius: 6px;
|
||||
font-size: 12px; overflow: hidden;
|
||||
}
|
||||
.cred-val :deep(.mono) { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.copy { background: transparent; border: none; color: var(--text-mute); cursor: pointer; flex-shrink: 0; padding: 2px; }
|
||||
.copy:hover { color: var(--text); }
|
||||
</style>
|
||||
|
||||
@@ -1,22 +1,60 @@
|
||||
<script setup lang="ts">
|
||||
// Strict port of project/platform-app.jsx `StorageScreen` (lines 970-1020).
|
||||
// Two-card 1.4fr/1fr layout: aggregate + top users on the left, type breakdown
|
||||
// on the right. No tabs in the source — just two cards.
|
||||
// Aggregate file storage for the workspace, read-only. Real data: the summary
|
||||
// comes from /api/tenants/:slug/storage, which platform-api computes live from
|
||||
// OCIS libregraph (per-drive quota for the tenant's members).
|
||||
//
|
||||
// Layout note: the original design had a "By type" (Documents/Images/Video…)
|
||||
// card. libregraph exposes per-drive quota but NOT a file-type breakdown, so
|
||||
// there's no honest source for it — it's replaced with a real aggregate
|
||||
// breakdown card (allocated/used/free/trash/drives).
|
||||
|
||||
interface StorageTopUser {
|
||||
name: string
|
||||
email: string
|
||||
usedBytes: number
|
||||
}
|
||||
|
||||
import { sampleUsersFlat } from '~/data/workspace'
|
||||
interface StorageSummary {
|
||||
available: boolean
|
||||
plan: string
|
||||
usedBytes: number
|
||||
quotaBytes: number
|
||||
freeBytes: number
|
||||
trashBytes: number
|
||||
driveCount: number
|
||||
topUsers: StorageTopUser[]
|
||||
}
|
||||
|
||||
const topUsers = computed(() =>
|
||||
[...sampleUsersFlat].slice(0, 5).sort((a, b) => b.storage - a.storage),
|
||||
const { fetchMe } = useMe()
|
||||
await fetchMe()
|
||||
const { tenant } = useTenant()
|
||||
const slug = computed(() => tenant.value?.slug ?? '')
|
||||
|
||||
const { data: storage } = await useFetch<StorageSummary | null>(
|
||||
() => `/api/tenants/${slug.value}/storage`,
|
||||
{ key: 'admin-storage', default: () => null, immediate: !!slug.value, watch: [slug] },
|
||||
)
|
||||
|
||||
const typeBreakdown: Array<[string, number, string]> = [
|
||||
['Documents', 42, 'var(--text)'],
|
||||
['Images', 24, 'var(--info)'],
|
||||
['Video', 18, 'var(--warn)'],
|
||||
['Archives', 9, 'var(--ok)'],
|
||||
['Other', 7, 'var(--text-mute)'],
|
||||
]
|
||||
const available = computed(() => storage.value?.available === true)
|
||||
const usedPct = computed(() => percent(storage.value?.usedBytes ?? 0, storage.value?.quotaBytes ?? 0))
|
||||
|
||||
const topUsers = computed(() => storage.value?.topUsers ?? [])
|
||||
const hasUsers = computed(() => topUsers.value.length > 0)
|
||||
// Scale each user's bar relative to the heaviest user, so the top user fills it.
|
||||
const maxUserBytes = computed(() => Math.max(1, ...topUsers.value.map((u) => u.usedBytes)))
|
||||
|
||||
// Right-hand breakdown rows — all real figures from the summary.
|
||||
const breakdown = computed(() => {
|
||||
const s = storage.value
|
||||
if (!s) return []
|
||||
return [
|
||||
['Allocated', formatBytes(s.quotaBytes)],
|
||||
['Used', formatBytes(s.usedBytes)],
|
||||
['Free', formatBytes(s.freeBytes)],
|
||||
['In trash', formatBytes(s.trashBytes)],
|
||||
['Active drives', String(s.driveCount)],
|
||||
] as Array<[string, string]>
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -24,51 +62,68 @@ const typeBreakdown: Array<[string, number, string]> = [
|
||||
<PageHeader
|
||||
eyebrow="Drev"
|
||||
title="Storage"
|
||||
subtitle="Aggregate file storage across your workspace, by user and type."
|
||||
subtitle="Aggregate file storage across your workspace, by user."
|
||||
/>
|
||||
<div class="content">
|
||||
<Card>
|
||||
<div class="card-head">
|
||||
<Eyebrow>Aggregate</Eyebrow>
|
||||
<div class="card-title">1.4 TB used</div>
|
||||
<div class="card-sub">64% of 2.2 TB allocated · Business plan</div>
|
||||
<div class="card-title">{{ available ? formatBytes(storage!.usedBytes) + ' used' : 'Storage' }}</div>
|
||||
<div class="card-sub">
|
||||
<template v-if="available">
|
||||
{{ usedPct }}% of {{ formatBytes(storage!.quotaBytes) }} allocated · {{ storage!.plan }} plan
|
||||
</template>
|
||||
<template v-else>Storage data unavailable</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-if="available">
|
||||
<div class="progress" style="height: 10px;">
|
||||
<span style="width: 64%" />
|
||||
<span :style="{ width: usedPct + '%' }" />
|
||||
</div>
|
||||
<div class="progress-legend">
|
||||
<span>1.4 TB used</span>
|
||||
<span>820 GB free</span>
|
||||
<span>{{ formatBytes(storage!.usedBytes) }} used</span>
|
||||
<span>{{ formatBytes(storage!.freeBytes) }} free</span>
|
||||
</div>
|
||||
|
||||
<div class="top-block">
|
||||
<Eyebrow>Top users</Eyebrow>
|
||||
<div class="top-list">
|
||||
<div v-for="u in topUsers" :key="u.id" class="top-row">
|
||||
<div v-if="hasUsers" class="top-list">
|
||||
<div v-for="u in topUsers" :key="u.email" class="top-row">
|
||||
<div class="user-cell">
|
||||
<Avatar :name="u.name" :size="22" />
|
||||
<span>{{ u.name }}</span>
|
||||
</div>
|
||||
<div class="progress thin"><span :style="{ width: Math.min(100, (u.storage / 50) * 100) + '%' }" /></div>
|
||||
<Mono>{{ u.storage }} GB</Mono>
|
||||
<div class="progress thin">
|
||||
<span :style="{ width: Math.min(100, (u.usedBytes / maxUserBytes) * 100) + '%' }" />
|
||||
</div>
|
||||
<Mono>{{ formatBytes(u.usedBytes) }}</Mono>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="empty">
|
||||
<Mono dim>No storage in use yet.</Mono>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-else class="empty">
|
||||
<Mono dim>Couldn't reach the file storage service. Try again shortly.</Mono>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<div class="card-head">
|
||||
<Eyebrow>By type</Eyebrow>
|
||||
<div class="card-title">What's taking space</div>
|
||||
<Eyebrow>Breakdown</Eyebrow>
|
||||
<div class="card-title">Where it stands</div>
|
||||
</div>
|
||||
<div class="types">
|
||||
<div v-for="[n, p, c] in typeBreakdown" :key="n">
|
||||
<div class="type-head">
|
||||
<span>{{ n }}</span>
|
||||
<span class="pct">{{ p }}%</span>
|
||||
<div v-if="available" class="rows">
|
||||
<div v-for="[label, value] in breakdown" :key="label" class="row">
|
||||
<span class="row-label">{{ label }}</span>
|
||||
<Mono>{{ value }}</Mono>
|
||||
</div>
|
||||
<div class="progress thinner"><span :style="{ width: p + '%', background: c }" /></div>
|
||||
</div>
|
||||
<div v-else class="empty">
|
||||
<Mono dim>No data available.</Mono>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
@@ -84,7 +139,6 @@ const typeBreakdown: Array<[string, number, string]> = [
|
||||
|
||||
.progress { background: var(--bg); border-radius: 999px; overflow: hidden; }
|
||||
.progress.thin { height: 6px; }
|
||||
.progress.thinner { height: 5px; }
|
||||
.progress span { display: block; height: 100%; background: var(--text); }
|
||||
|
||||
.progress-legend {
|
||||
@@ -98,11 +152,13 @@ const typeBreakdown: Array<[string, number, string]> = [
|
||||
|
||||
.top-block { margin-top: 32px; }
|
||||
.top-list { margin-top: 12px; display: flex; flex-direction: column; gap: 10px; }
|
||||
.top-row { display: grid; grid-template-columns: 180px 1fr 60px; gap: 12px; align-items: center; }
|
||||
.top-row { display: grid; grid-template-columns: 180px 1fr 70px; gap: 12px; align-items: center; }
|
||||
.user-cell { display: flex; align-items: center; gap: 8px; font-size: 12px; }
|
||||
.top-row > .mono, .top-row :deep(.mono) { font-family: var(--font-mono); font-size: 11px; text-align: right; }
|
||||
|
||||
.types { display: flex; flex-direction: column; gap: 12px; }
|
||||
.type-head { display: flex; justify-content: space-between; font-size: 12px; margin-bottom: 4px; }
|
||||
.pct { font-family: var(--font-mono); color: var(--text-mute); }
|
||||
.rows { display: flex; flex-direction: column; gap: 12px; }
|
||||
.row { display: flex; justify-content: space-between; align-items: center; font-size: 13px; }
|
||||
.row-label { color: var(--text-mute); }
|
||||
|
||||
.empty { margin-top: 16px; padding: 12px 0; }
|
||||
</style>
|
||||
|
||||
+415
-451
File diff suppressed because it is too large
Load Diff
@@ -11,6 +11,7 @@ import type { EmailTemplate } from '~/components/partner/EmailTemplateEditor.vue
|
||||
import type { BrandIdentity } from '~/components/partner/EditIdentityModal.vue'
|
||||
|
||||
const toast = useToast()
|
||||
const { request } = useApiFetch()
|
||||
|
||||
const identityOpen = ref(false)
|
||||
const editing = ref<EmailTemplate | null>(null)
|
||||
@@ -74,7 +75,7 @@ watch(branding, syncBranding)
|
||||
|
||||
async function putBranding(): Promise<boolean> {
|
||||
try {
|
||||
await $fetch('/api/partner/branding', {
|
||||
await request('/api/partner/branding', {
|
||||
method: 'PUT',
|
||||
body: {
|
||||
identity: identity.value,
|
||||
|
||||
@@ -9,6 +9,7 @@ import type { CustomerOrg, CustomerStatus, PartnerTenantDoc } from '~/types/part
|
||||
|
||||
const toast = useToast()
|
||||
const router = useRouter()
|
||||
const { request } = useApiFetch()
|
||||
const partnerMode = usePartnerMode()
|
||||
|
||||
const view = ref<'table' | 'cards'>('table')
|
||||
@@ -181,7 +182,7 @@ async function saveEdit() {
|
||||
}
|
||||
savingEdit.value = true
|
||||
try {
|
||||
await $fetch(`/api/partner/tenants/${editCustomer.value.slug}`, {
|
||||
await request(`/api/partner/tenants/${editCustomer.value.slug}`, {
|
||||
method: 'PATCH',
|
||||
body: {
|
||||
name: editForm.name,
|
||||
@@ -204,7 +205,7 @@ async function saveEdit() {
|
||||
async function toggleSuspend(c: CustomerRow) {
|
||||
const action = c.status === 'suspended' ? 'resume' : 'suspend'
|
||||
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)
|
||||
editCustomer.value = null
|
||||
await refreshAll()
|
||||
|
||||
@@ -12,6 +12,7 @@ import type { CustomerOrg, CustomerStatus } from '~/types/partner'
|
||||
import type { TaskContext } from '~/components/partner/CustomerTaskPanel.vue'
|
||||
|
||||
const toast = useToast()
|
||||
const { request } = useApiFetch()
|
||||
|
||||
// Decorative MRR sparkline shape only — historical MRR isn't stored yet (a
|
||||
// daily-snapshot job lands later; see useMrrTrendline). The live numbers
|
||||
@@ -262,7 +263,7 @@ async function deleteReport() {
|
||||
return
|
||||
}
|
||||
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)
|
||||
confirmDeleteId.value = null
|
||||
await Promise.all([refreshSaved(), refreshNuxtData('partner-reports-saved')])
|
||||
@@ -284,7 +285,7 @@ async function onCreated(payload: {
|
||||
format: string
|
||||
}) {
|
||||
try {
|
||||
await $fetch('/api/partner/reports/saved', {
|
||||
await request('/api/partner/reports/saved', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
name: payload.name,
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
|
||||
const toast = useToast()
|
||||
const { request } = useApiFetch()
|
||||
|
||||
const tab = ref<'agreement' | 'contact' | 'tax' | 'notifications'>('agreement')
|
||||
const tabs = [
|
||||
@@ -93,7 +94,7 @@ watch(settings, syncContact)
|
||||
async function saveContact() {
|
||||
savingContact.value = true
|
||||
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')
|
||||
await Promise.all([refresh(), refreshNuxtData('partner-settings')])
|
||||
} catch (e: unknown) {
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
import type { TeamMember } from '~/components/partner/TeammatePanel.vue'
|
||||
|
||||
const toast = useToast()
|
||||
const { request } = useApiFetch()
|
||||
|
||||
const inviteOpen = ref(false)
|
||||
const openMember = ref<TeamMember | null>(null)
|
||||
@@ -72,7 +73,7 @@ function accessLabel(m: TeamMember) {
|
||||
|
||||
async function onSent(payload: { name: string; email: string; role: string }) {
|
||||
try {
|
||||
await $fetch('/api/partner/users', {
|
||||
await request('/api/partner/users', {
|
||||
method: 'POST',
|
||||
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) {
|
||||
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`)
|
||||
openMember.value = null
|
||||
await Promise.all([refresh(), refreshNuxtData('partner-users')])
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
// Tenant-scoped audit slice for the customer-admin Security & audit page.
|
||||
// Proxies GET /tenants/:slug/audit with the signed-in user's access token and
|
||||
// forwards the filter/pagination params. platform-api enforces tenant
|
||||
// membership and pins the query to this tenant's slug.
|
||||
|
||||
import { getUserSession } from 'nuxt-oidc-auth/runtime/server/utils/session.js'
|
||||
|
||||
const PASS_THROUGH = ['limit', 'q', 'action', 'outcome', 'actorEmail', 'since', 'before'] as const
|
||||
|
||||
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 incoming = getQuery(event)
|
||||
const query: Record<string, string> = {}
|
||||
for (const k of PASS_THROUGH) {
|
||||
const v = incoming[k]
|
||||
if (v != null && v !== '') query[k] = String(v)
|
||||
}
|
||||
const base = process.env.PLATFORM_API_INTERNAL_URL ?? 'http://platform-api:3001'
|
||||
return $fetch(`${base}/tenants/${slug}/audit`, {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
query,
|
||||
})
|
||||
})
|
||||
@@ -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,22 @@
|
||||
// Set the DMARC policy for a domain (wizard step 5). Proxies
|
||||
// PATCH /tenants/:slug/domains/:domain/dmarc with { dmarcPolicy };
|
||||
// platform-api updates the expected record, re-verifies, and 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 domain = getRouterParam(event, 'domain')
|
||||
const body = await readBody(event)
|
||||
const base = process.env.PLATFORM_API_INTERNAL_URL ?? 'http://platform-api:3001'
|
||||
return $fetch(`${base}/tenants/${slug}/domains/${encodeURIComponent(domain ?? '')}/dmarc`, {
|
||||
method: 'PATCH',
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
body,
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,20 @@
|
||||
// Remove a domain. Proxies DELETE /tenants/:slug/domains/:domain; platform-api
|
||||
// deletes it from Stalwart (DKIM sigs first) and 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 domain = getRouterParam(event, 'domain')
|
||||
const base = process.env.PLATFORM_API_INTERNAL_URL ?? 'http://platform-api:3001'
|
||||
await $fetch(`${base}/tenants/${slug}/domains/${encodeURIComponent(domain ?? '')}`, {
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
})
|
||||
return { ok: true }
|
||||
})
|
||||
@@ -0,0 +1,18 @@
|
||||
// Single domain detail (expected + observed records). Proxies
|
||||
// GET /tenants/:slug/domains/:domain; 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 domain = getRouterParam(event, 'domain')
|
||||
const base = process.env.PLATFORM_API_INTERNAL_URL ?? 'http://platform-api:3001'
|
||||
return $fetch(`${base}/tenants/${slug}/domains/${encodeURIComponent(domain ?? '')}`, {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,20 @@
|
||||
// Re-run the live DNS checks for a domain. Proxies
|
||||
// POST /tenants/:slug/domains/:domain/recheck; platform-api re-verifies
|
||||
// MX/SPF/DKIM/DMARC/ownership against public DNS and 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 domain = getRouterParam(event, 'domain')
|
||||
const base = process.env.PLATFORM_API_INTERNAL_URL ?? 'http://platform-api:3001'
|
||||
return $fetch(`${base}/tenants/${slug}/domains/${encodeURIComponent(domain ?? '')}/recheck`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,18 @@
|
||||
// List the workspace's email domains (Domains page + sidebar badge). Proxies
|
||||
// GET /tenants/:slug/domains 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}/domains`, {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,21 @@
|
||||
// Add an email domain. Proxies POST /tenants/:slug/domains with { domain };
|
||||
// platform-api provisions it in Stalwart (auto-generating DKIM), seeds the
|
||||
// expected records, runs an initial DNS check, and 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}/domains`, {
|
||||
method: 'POST',
|
||||
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,18 @@
|
||||
// Live MFA-enrollment overview for the workspace. Proxies GET
|
||||
// /tenants/:slug/mfa-status; platform-api enforces tenant membership and reads
|
||||
// each member's enrollment from Authentik.
|
||||
|
||||
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}/mfa-status`, {
|
||||
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,20 @@
|
||||
// Save the workspace security policy (stored intent). Proxies PATCH
|
||||
// /tenants/:slug/security-policy; 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}/security-policy`, {
|
||||
method: 'PATCH',
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
body,
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,20 @@
|
||||
// Remove an SSO app. Proxies DELETE /tenants/:slug/sso-apps/:id; platform-api
|
||||
// deletes the Authentik application + provider and 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 id = getRouterParam(event, 'id')
|
||||
const base = process.env.PLATFORM_API_INTERNAL_URL ?? 'http://platform-api:3001'
|
||||
await $fetch(`${base}/tenants/${slug}/sso-apps/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
})
|
||||
return { ok: true }
|
||||
})
|
||||
@@ -0,0 +1,17 @@
|
||||
// List the tenant's SSO apps. Proxies GET /tenants/:slug/sso-apps;
|
||||
// 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}/sso-apps`, {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,21 @@
|
||||
// Register a new SSO app (Dezky as IdP). Proxies POST /tenants/:slug/sso-apps
|
||||
// with { name, redirectUris }; platform-api creates the Authentik provider +
|
||||
// application and returns the client credentials (secret shown once).
|
||||
|
||||
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}/sso-apps`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
body,
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,19 @@
|
||||
// Aggregate storage usage for the customer-admin Storage page. Proxies
|
||||
// GET /tenants/:slug/storage with the signed-in user's access token;
|
||||
// platform-api enforces tenant membership and computes the summary live from
|
||||
// OCIS libregraph.
|
||||
|
||||
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}/storage`, {
|
||||
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,21 @@
|
||||
// Create a workspace member. Proxies POST /tenants/:slug/users; platform-api
|
||||
// provisions the user across Authentik (SSO), Stalwart (mailbox on the default
|
||||
// domain) and OCIS, then returns the email + one-time temp password.
|
||||
|
||||
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}/users`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
body,
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,21 @@
|
||||
// Remove a workspace member. Proxies DELETE /tenants/:slug/users/:userId;
|
||||
// platform-api tears down the mailbox, OCIS account and (if it was their last
|
||||
// workspace) the SSO identity. Enforces tenant membership + blocks self-removal.
|
||||
|
||||
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 userId = getRouterParam(event, 'userId')
|
||||
const base = process.env.PLATFORM_API_INTERNAL_URL ?? 'http://platform-api:3001'
|
||||
await $fetch(`${base}/tenants/${slug}/users/${userId}`, {
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
})
|
||||
return { ok: true }
|
||||
})
|
||||
@@ -0,0 +1,16 @@
|
||||
// Force-logout a member (terminate their SSO sessions). Proxies POST
|
||||
// /tenants/:slug/users/:userId/force-logout.
|
||||
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 userId = getRouterParam(event, 'userId')
|
||||
const base = process.env.PLATFORM_API_INTERNAL_URL ?? 'http://platform-api:3001'
|
||||
return $fetch(`${base}/tenants/${slug}/users/${userId}/force-logout`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,16 @@
|
||||
// Reset a member's password (new one-time password on SSO + mailbox). Proxies
|
||||
// POST /tenants/:slug/users/:userId/reset-password and returns { email, tempPassword }.
|
||||
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 userId = getRouterParam(event, 'userId')
|
||||
const base = process.env.PLATFORM_API_INTERNAL_URL ?? 'http://platform-api:3001'
|
||||
return $fetch(`${base}/tenants/${slug}/users/${userId}/reset-password`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,16 @@
|
||||
// Resume a suspended member. Proxies POST /tenants/:slug/users/:userId/resume.
|
||||
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 userId = getRouterParam(event, 'userId')
|
||||
const base = process.env.PLATFORM_API_INTERNAL_URL ?? 'http://platform-api:3001'
|
||||
await $fetch(`${base}/tenants/${slug}/users/${userId}/resume`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
})
|
||||
return { ok: true }
|
||||
})
|
||||
@@ -0,0 +1,17 @@
|
||||
// Suspend a member (freeze SSO + mailbox). Proxies POST
|
||||
// /tenants/:slug/users/:userId/suspend.
|
||||
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 userId = getRouterParam(event, 'userId')
|
||||
const base = process.env.PLATFORM_API_INTERNAL_URL ?? 'http://platform-api:3001'
|
||||
await $fetch(`${base}/tenants/${slug}/users/${userId}/suspend`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
})
|
||||
return { ok: true }
|
||||
})
|
||||
@@ -0,0 +1,157 @@
|
||||
// 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
|
||||
}
|
||||
|
||||
// Customer security policy (stored intent; enforcement wired incrementally).
|
||||
export interface TenantSecurityPolicy {
|
||||
mfaMode: 'all' | 'admins' | 'optional'
|
||||
sessionIdleMinutes?: number
|
||||
sessionAbsoluteHours?: number
|
||||
allowedCountries?: string[]
|
||||
ipAllowlist?: string[]
|
||||
}
|
||||
|
||||
// Live MFA-enrollment overview from GET /tenants/:slug/mfa-status.
|
||||
export interface MfaStatus {
|
||||
total: number
|
||||
enrolled: number
|
||||
members: Array<{ id: string; name: string; email: string; role: string; enrolled: boolean }>
|
||||
}
|
||||
|
||||
// An SSO app (Dezky as IdP) from GET /tenants/:slug/sso-apps.
|
||||
export interface SsoApp {
|
||||
id: string
|
||||
name: string
|
||||
protocol: 'oidc' | 'saml'
|
||||
clientId?: string
|
||||
redirectUris: string[]
|
||||
issuer?: string
|
||||
wellKnownUrl?: string
|
||||
createdAt?: string
|
||||
}
|
||||
// POST response also carries the one-time client secret.
|
||||
export interface SsoAppCreated extends SsoApp {
|
||||
clientSecret: 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
|
||||
securityPolicy?: TenantSecurityPolicy
|
||||
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
|
||||
actorIp?: string
|
||||
action: string
|
||||
outcome: 'success' | 'failure'
|
||||
resourceType?: string
|
||||
resourceId?: string
|
||||
resourceName?: string
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
// Byte formatting for storage figures. Binary units (GiB/TiB) to match what
|
||||
// OCIS reports. Auto-imported by Nuxt (utils/ is scanned by default).
|
||||
|
||||
const UNITS = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']
|
||||
|
||||
// Human-readable size, e.g. 1610612736 → "1.5 GB". Picks the largest unit that
|
||||
// keeps the number readable; trims trailing ".0".
|
||||
export function formatBytes(bytes: number, decimals = 1): string {
|
||||
if (!bytes || bytes < 0) return '0 GB'
|
||||
const i = Math.min(UNITS.length - 1, Math.floor(Math.log(bytes) / Math.log(1024)))
|
||||
const value = bytes / 1024 ** i
|
||||
const fixed = value.toFixed(decimals)
|
||||
return `${fixed.endsWith('.0') ? fixed.slice(0, -2) : fixed} ${UNITS[i]}`
|
||||
}
|
||||
|
||||
// Integer percentage of used vs total, clamped to 0–100. Returns 0 when total
|
||||
// is 0 (unlimited) to avoid NaN in width styles.
|
||||
export function percent(used: number, total: number): number {
|
||||
if (!total || total <= 0) return 0
|
||||
return Math.min(100, Math.max(0, Math.round((used / total) * 100)))
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<NuxtLayout>
|
||||
<NuxtPage />
|
||||
</NuxtLayout>
|
||||
</template>
|
||||
@@ -0,0 +1,84 @@
|
||||
/* Dezky marketing site — global styles. Ported from the design handoff's
|
||||
<style> block in Landing Page.html. The landing page is self-contained on
|
||||
colour (it threads a theme object through components), so the base just sets
|
||||
the page surface, resets, and the FAQ accordion marker animation. */
|
||||
|
||||
html,
|
||||
body,
|
||||
#__nuxt {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
min-height: 100vh;
|
||||
background: #FAFAF7;
|
||||
color: #0A0A0A;
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
button {
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
summary {
|
||||
cursor: pointer;
|
||||
}
|
||||
summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
summary::marker {
|
||||
content: '';
|
||||
}
|
||||
|
||||
/* FAQ accordion plus → rotates to an "open" state. Consumed by Faq.vue's
|
||||
.faq-plus span. */
|
||||
details[open] .faq-plus::before {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
.faq-plus::before,
|
||||
.faq-plus::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
background: currentColor;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
.faq-plus::before {
|
||||
width: 2px;
|
||||
height: 14px;
|
||||
left: 6px;
|
||||
top: 0;
|
||||
}
|
||||
.faq-plus::after {
|
||||
width: 14px;
|
||||
height: 2px;
|
||||
left: 0;
|
||||
top: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(128, 128, 128, 0.25);
|
||||
border-radius: 6px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
/* Dezky design tokens — shared brand palette (carbon dark by default).
|
||||
Identical variable set to apps/portal and apps/operator so the marketing
|
||||
site, the customer portal, and the operator console all speak the same
|
||||
design language. A Claude design handoff for the landing page should build
|
||||
on these tokens rather than introducing parallel hex values. */
|
||||
|
||||
:root {
|
||||
--bg: #0A0A0A;
|
||||
--surface: #141413;
|
||||
--elevated: #1C1C1A;
|
||||
--border: #262622;
|
||||
--border-hi: #33332E;
|
||||
|
||||
--text: #F4F3EE;
|
||||
--text-dim: rgba(244, 243, 238, 0.72);
|
||||
--text-mute: rgba(244, 243, 238, 0.45);
|
||||
|
||||
--side-bg: #0A0A0A;
|
||||
--side-surf: #141413;
|
||||
--side-border: #1F1F1C;
|
||||
--side-text: #F4F3EE;
|
||||
--side-dim: rgba(244, 243, 238, 0.55);
|
||||
--side-mute: rgba(244, 243, 238, 0.35);
|
||||
--side-hover: rgba(244, 243, 238, 0.06);
|
||||
--side-active: rgba(244, 243, 238, 0.1);
|
||||
|
||||
--accent: #D4FF3A;
|
||||
--accent-fg: #0A0A0A;
|
||||
--signal: #D4FF3A;
|
||||
|
||||
--ok: #34C77B;
|
||||
--warn: #F0B14A;
|
||||
--bad: #F05858;
|
||||
--info: #4D8BE8;
|
||||
|
||||
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
--font-display: 'Inter Tight', 'Inter', sans-serif;
|
||||
--font-mono: 'JetBrains Mono', ui-monospace, 'Menlo', monospace;
|
||||
|
||||
--input-bg: rgba(244, 243, 238, 0.04);
|
||||
|
||||
/* Cosmetic density. comfy=1, compact≈0.78. */
|
||||
--density-scale: 1;
|
||||
}
|
||||
|
||||
/* Tweaks: density overrides */
|
||||
:root[data-density='compact'] { --density-scale: 0.78; }
|
||||
|
||||
/* Tweaks: light theme (warm cream, charcoal text). Overrides every surface
|
||||
token so any component that uses var(--bg / --surface / --text / ...) flips
|
||||
without code changes. */
|
||||
:root[data-theme='light'] {
|
||||
--bg: #F6F4EF;
|
||||
--surface: #FAF8F2;
|
||||
--elevated: #FFFFFF;
|
||||
--border: #E2DED2;
|
||||
--border-hi: #D0CBBC;
|
||||
|
||||
--text: #1C1B17;
|
||||
--text-dim: rgba(28, 27, 23, 0.72);
|
||||
--text-mute: rgba(28, 27, 23, 0.50);
|
||||
|
||||
--side-bg: #F0EDE4;
|
||||
--side-surf: #FAF8F2;
|
||||
--side-border: #E2DED2;
|
||||
--side-text: #1C1B17;
|
||||
--side-dim: rgba(28, 27, 23, 0.62);
|
||||
--side-mute: rgba(28, 27, 23, 0.42);
|
||||
--side-hover: rgba(28, 27, 23, 0.05);
|
||||
--side-active: rgba(28, 27, 23, 0.08);
|
||||
|
||||
--accent: #1F8A5B;
|
||||
--accent-fg: #FAF8F2;
|
||||
--signal: #1F8A5B;
|
||||
|
||||
--ok: #1F8A5B;
|
||||
--warn: #C97F1F;
|
||||
--bad: #C03A3A;
|
||||
--info: #2A6FDB;
|
||||
|
||||
--input-bg: rgba(28, 27, 23, 0.04);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<script setup lang="ts">
|
||||
// Node mark + "dezky" wordmark (JetBrains Mono, tracked tight). Ported from
|
||||
// logos.jsx NodeLockup + NodeWordmark.
|
||||
import { C } from '~/utils/landingTokens'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
scale?: number
|
||||
fg?: string
|
||||
accent?: string
|
||||
}>(), {
|
||||
scale: 1,
|
||||
fg: C.carbon,
|
||||
accent: C.signal,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :style="{ display: 'flex', alignItems: 'center', gap: `${18 * scale}px` }">
|
||||
<BrandNodeMark :size="72 * scale" :fg="fg" :accent="accent" />
|
||||
<span :style="{
|
||||
fontFamily: '\'JetBrains Mono\', \'IBM Plex Mono\', ui-monospace, monospace',
|
||||
fontWeight: 600,
|
||||
fontSize: `${56 * scale * 0.78}px`,
|
||||
letterSpacing: '-0.04em',
|
||||
color: fg,
|
||||
lineHeight: 0.9,
|
||||
}">dezky</span>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,54 @@
|
||||
<script setup lang="ts">
|
||||
// Dezky "Node" mark — a lowercase d (donut style) inside a squircle, with a
|
||||
// corner node-dot. Geometry is the locked set from the brand handoff
|
||||
// (logos.jsx NodeMark + LOCKED). The squircle paints in `fg`; the letterform
|
||||
// and dot paint in `accent` (electric chartreuse) — the design's intent.
|
||||
import { C, LOCKED } from '~/utils/landingTokens'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
size?: number
|
||||
fg?: string
|
||||
accent?: string
|
||||
}>(), {
|
||||
size: 96,
|
||||
fg: C.carbon,
|
||||
accent: C.signal,
|
||||
})
|
||||
|
||||
const { bowlR, stemW, dotR, contR } = LOCKED
|
||||
|
||||
const overlap = stemW * 0.55
|
||||
const cy = 52
|
||||
const cx = 50 - stemW / 2 + overlap / 2
|
||||
const stemX = cx + bowlR - overlap
|
||||
const stemRight = stemX + stemW
|
||||
const capR = stemW / 2
|
||||
const stemTop = 26
|
||||
const stemBottom = cy + bowlR
|
||||
const holeR = Math.max(2.5, bowlR - stemW - 0.5)
|
||||
|
||||
const bowlPath =
|
||||
`M ${cx - bowlR} ${cy} ` +
|
||||
`a ${bowlR} ${bowlR} 0 1 0 ${bowlR * 2} 0 ` +
|
||||
`a ${bowlR} ${bowlR} 0 1 0 ${-bowlR * 2} 0 Z`
|
||||
const counterPath =
|
||||
`M ${cx - holeR} ${cy} ` +
|
||||
`a ${holeR} ${holeR} 0 1 0 ${holeR * 2} 0 ` +
|
||||
`a ${holeR} ${holeR} 0 1 0 ${-holeR * 2} 0 Z`
|
||||
const stemPath =
|
||||
`M ${stemX} ${stemTop + capR} ` +
|
||||
`a ${capR} ${capR} 0 0 1 ${stemW} 0 ` +
|
||||
`L ${stemRight} ${stemBottom} ` +
|
||||
`L ${stemX} ${stemBottom} Z`
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg :width="size" :height="size" viewBox="0 0 100 100" aria-label="dezky node mark">
|
||||
<rect x="8" y="8" width="84" height="84" :rx="contR" :fill="fg" />
|
||||
<g :fill="accent">
|
||||
<path :d="`${bowlPath} ${counterPath}`" fill-rule="evenodd" />
|
||||
<path :d="stemPath" />
|
||||
</g>
|
||||
<circle cx="74" cy="26" :r="dotR" :fill="accent" />
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,48 @@
|
||||
<script setup lang="ts">
|
||||
// Landing button. Variants: primary (signal fill), secondary (outline), ghost.
|
||||
// Ported from landing-sections.jsx Button. Named Btn.vue to avoid colliding
|
||||
// with any shared <Button> in @dezky/ui.
|
||||
import { computed } from 'vue'
|
||||
import { C } from '~/utils/landingTokens'
|
||||
import { useTheme } from '~/composables/useLanding'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
variant?: 'primary' | 'secondary' | 'ghost'
|
||||
size?: 'md' | 'lg'
|
||||
full?: boolean
|
||||
}>(), {
|
||||
variant: 'primary',
|
||||
size: 'md',
|
||||
full: false,
|
||||
})
|
||||
|
||||
const t = useTheme()
|
||||
|
||||
const style = computed(() => {
|
||||
const base = {
|
||||
primary: { background: C.signal, color: C.carbon, border: `1px solid ${C.signal}` },
|
||||
secondary: { background: 'transparent', color: t.value.fg, border: `1px solid ${t.value.borderStrong}` },
|
||||
ghost: { background: 'transparent', color: t.value.fg, border: '1px solid transparent' },
|
||||
}[props.variant]
|
||||
return {
|
||||
...base,
|
||||
padding: props.size === 'lg' ? '18px 28px' : '14px 22px',
|
||||
fontSize: props.size === 'lg' ? '15px' : '14px',
|
||||
fontWeight: 600,
|
||||
fontFamily: '\'Inter\', sans-serif',
|
||||
borderRadius: '4px',
|
||||
letterSpacing: '-0.005em',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '10px',
|
||||
width: props.full ? '100%' : 'auto',
|
||||
whiteSpace: 'nowrap',
|
||||
transition: 'transform 0.1s ease, box-shadow 0.15s ease',
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button :style="style"><slot /></button>
|
||||
</template>
|
||||
@@ -0,0 +1,46 @@
|
||||
<script setup lang="ts">
|
||||
// Section 05 — comparison table (Dezky vs US hyperscaler).
|
||||
// Ported from landing-sections.jsx Compare.
|
||||
import { useTheme, useCopy } from '~/composables/useLanding'
|
||||
const t = useTheme()
|
||||
const copy = useCopy()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section :style="{ background: t.bg, color: t.fg }">
|
||||
<LandingContainer pad="140px 64px">
|
||||
<LandingSectionLabel :label="copy.compare.label" />
|
||||
<div :style="{ display: 'grid', gridTemplateColumns: '1.1fr 1fr', gap: '80px', alignItems: 'end', marginBottom: '56px' }">
|
||||
<h2 :style="{ fontFamily: '\'Inter Tight\', sans-serif', fontWeight: 600, fontSize: 'clamp(36px, 4.4vw, 64px)', letterSpacing: '-0.032em', lineHeight: 1.0, margin: 0, textWrap: 'balance', color: t.fg }">{{ copy.compare.heading }}</h2>
|
||||
<p :style="{ fontFamily: '\'Inter\', sans-serif', fontSize: '17px', lineHeight: 1.6, maxWidth: '460px', color: t.fgMuted, margin: 0, textWrap: 'pretty' }">{{ copy.compare.lede }}</p>
|
||||
</div>
|
||||
|
||||
<div :style="{ border: `1px solid ${t.borderStrong}` }">
|
||||
<div :style="{ display: 'grid', gridTemplateColumns: '1.2fr 1fr 1fr', background: t.fg, color: t.bg }">
|
||||
<div :style="{ padding: '20px 28px', fontFamily: '\'JetBrains Mono\', monospace', fontSize: '11px', letterSpacing: '0.12em', textTransform: 'uppercase', opacity: 0.6 }">kategori</div>
|
||||
<div :style="{ padding: '20px 28px', display: 'flex', alignItems: 'center', gap: '10px', borderLeft: `1px solid ${t.fgDim}` }">
|
||||
<BrandNodeMark :size="18" :fg="t.bg" :accent="t.signal" />
|
||||
<span :style="{ fontFamily: '\'JetBrains Mono\', monospace', fontSize: '14px', fontWeight: 600 }">{{ copy.compare.cols[0] }}</span>
|
||||
</div>
|
||||
<div :style="{ padding: '20px 28px', fontFamily: '\'JetBrains Mono\', monospace', fontSize: '14px', fontWeight: 500, opacity: 0.7, borderLeft: `1px solid ${t.fgDim}` }">{{ copy.compare.cols[1] }}</div>
|
||||
</div>
|
||||
<div
|
||||
v-for="(row, i) in copy.compare.rows" :key="i"
|
||||
:style="{
|
||||
display: 'grid', gridTemplateColumns: '1.2fr 1fr 1fr',
|
||||
borderTop: `1px solid ${t.border}`,
|
||||
background: i % 2 === 0 ? t.bg : t.bgAlt,
|
||||
fontFamily: '\'Inter\', sans-serif', fontSize: '15px',
|
||||
}"
|
||||
>
|
||||
<div :style="{ padding: '22px 28px', color: t.fgMuted }">{{ row[0] }}</div>
|
||||
<div :style="{ padding: '22px 28px', color: t.fg, fontWeight: 600, borderLeft: `1px solid ${t.border}`, display: 'flex', alignItems: 'center', gap: '10px' }">
|
||||
<span :style="{ width: '4px', height: '4px', background: t.signal, borderRadius: '999px', flexShrink: 0 }" />
|
||||
{{ row[1] }}
|
||||
</div>
|
||||
<div :style="{ padding: '22px 28px', color: t.fgMuted, borderLeft: `1px solid ${t.border}` }">{{ row[2] }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</LandingContainer>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
// Padded, max-width-centred section wrapper. Ported from landing-sections.jsx Container.
|
||||
withDefaults(defineProps<{
|
||||
pad?: string
|
||||
max?: number
|
||||
}>(), {
|
||||
pad: '120px 64px',
|
||||
max: 1280,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :style="{ padding: pad }">
|
||||
<div :style="{ maxWidth: `${max}px`, margin: '0 auto' }">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,34 @@
|
||||
<script setup lang="ts">
|
||||
// Section 09 — FAQ. Native <details> accordion with an animated plus marker
|
||||
// (.faq-plus styles live in assets/styles/base.css). Ported from
|
||||
// landing-sections.jsx FAQ.
|
||||
import { useTheme, useCopy } from '~/composables/useLanding'
|
||||
const t = useTheme()
|
||||
const copy = useCopy()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section :style="{ background: t.bg, color: t.fg }">
|
||||
<LandingContainer pad="140px 64px" :max="960">
|
||||
<LandingSectionLabel :label="copy.faq.label" />
|
||||
<h2 :style="{ fontFamily: '\'Inter Tight\', sans-serif', fontWeight: 600, fontSize: 'clamp(36px, 4.4vw, 64px)', letterSpacing: '-0.032em', lineHeight: 1.0, margin: 0, textWrap: 'balance', color: t.fg }">{{ copy.faq.heading }}</h2>
|
||||
<div :style="{ marginTop: '56px' }">
|
||||
<details
|
||||
v-for="(item, i) in copy.faq.items" :key="i"
|
||||
:style="{
|
||||
borderTop: `1px solid ${t.borderStrong}`,
|
||||
borderBottom: i === copy.faq.items.length - 1 ? `1px solid ${t.borderStrong}` : 'none',
|
||||
}"
|
||||
>
|
||||
<summary :style="{ padding: '24px 0', display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '24px', listStyle: 'none' }">
|
||||
<span :style="{ fontFamily: '\'Inter Tight\', sans-serif', fontWeight: 500, fontSize: '19px', color: t.fg, letterSpacing: '-0.015em' }">{{ item[0] }}</span>
|
||||
<span class="faq-plus" :style="{ width: '14px', height: '14px', position: 'relative', color: t.fgMuted, flexShrink: 0 }" />
|
||||
</summary>
|
||||
<div :style="{ paddingBottom: '28px', paddingRight: '60px' }">
|
||||
<p :style="{ fontFamily: '\'Inter\', sans-serif', fontSize: '15px', lineHeight: 1.6, maxWidth: '720px', color: t.fgMuted, margin: 0, textWrap: 'pretty' }">{{ item[1] }}</p>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</LandingContainer>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,32 @@
|
||||
<script setup lang="ts">
|
||||
// Final CTA — carbon panel with a faint Node-mark watermark.
|
||||
// Ported from landing-sections.jsx FinalCTA.
|
||||
import { C } from '~/utils/landingTokens'
|
||||
import { useCopy } from '~/composables/useLanding'
|
||||
const copy = useCopy()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section id="final-cta" :style="{ background: C.carbon, color: C.bone, position: 'relative', overflow: 'hidden', scrollMarginTop: '72px' }">
|
||||
<div :style="{ position: 'absolute', right: '-180px', bottom: '-180px', opacity: 0.05 }">
|
||||
<BrandNodeMark :size="640" :fg="C.carbon" :accent="C.signal" />
|
||||
</div>
|
||||
<LandingContainer pad="140px 64px">
|
||||
<h2 :style="{ fontFamily: '\'Inter Tight\', sans-serif', fontWeight: 600, fontSize: 'clamp(48px, 6vw, 96px)', letterSpacing: '-0.04em', lineHeight: 0.98, margin: 0, color: C.bone, textWrap: 'balance', maxWidth: '900px' }">
|
||||
<template v-for="(part, i) in copy.finalCta.heading" :key="i">
|
||||
<template v-if="typeof part === 'string'">{{ part }} </template>
|
||||
<span v-else :style="{ color: C.signal }">{{ part.hl }}</span>
|
||||
</template>
|
||||
</h2>
|
||||
<div :style="{ marginTop: '28px', maxWidth: '520px', fontFamily: '\'Inter\', sans-serif', fontSize: '19px', color: 'rgba(244,243,238,0.7)' }">{{ copy.finalCta.sub }}</div>
|
||||
<div :style="{ marginTop: '40px' }">
|
||||
<button :style="{
|
||||
background: C.signal, color: C.carbon, border: 'none',
|
||||
padding: '20px 32px', fontFamily: '\'Inter\', sans-serif',
|
||||
fontSize: '16px', fontWeight: 600, borderRadius: '4px', cursor: 'pointer',
|
||||
letterSpacing: '-0.005em',
|
||||
}">{{ copy.finalCta.cta }} →</button>
|
||||
</div>
|
||||
</LandingContainer>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,60 @@
|
||||
<script setup lang="ts">
|
||||
// Footer — lockup + tagline + legal, four link columns, status row.
|
||||
// Ported from landing-sections.jsx Footer (light mode). Anchor links smooth-
|
||||
// scroll; "#" placeholders point at not-yet-built subpages.
|
||||
import { C } from '~/utils/landingTokens'
|
||||
import { useTheme, useCopy, scrollToAnchor } from '~/composables/useLanding'
|
||||
const t = useTheme()
|
||||
const copy = useCopy()
|
||||
|
||||
function onLink(e: MouseEvent, href: string) {
|
||||
if (href.startsWith('#') && href.length > 1) {
|
||||
e.preventDefault()
|
||||
scrollToAnchor(href)
|
||||
} else if (href === '#') {
|
||||
e.preventDefault()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<footer :style="{ background: C.bone, color: C.carbon, borderTop: `1px solid ${t.border}` }">
|
||||
<LandingContainer pad="80px 64px 40px">
|
||||
<div :style="{ display: 'grid', gridTemplateColumns: '1.4fr repeat(4, 1fr)', gap: '48px' }">
|
||||
<div>
|
||||
<BrandNodeLockup :scale="0.75" :fg="C.carbon" :accent="C.signal" />
|
||||
<div :style="{ marginTop: '20px', fontFamily: '\'Inter\', sans-serif', fontSize: '14px', color: 'rgba(10,10,10,0.6)', maxWidth: '280px', lineHeight: 1.5 }">{{ copy.footer.tagline }}</div>
|
||||
<div :style="{ marginTop: '28px', fontFamily: '\'JetBrains Mono\', monospace', fontSize: '11px', color: 'rgba(10,10,10,0.45)', lineHeight: 1.7 }">
|
||||
<div>{{ copy.footer.legal.name }}</div>
|
||||
<div>{{ copy.footer.legal.cvr }}</div>
|
||||
<div>{{ copy.footer.legal.addr }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-for="(col, i) in copy.footer.cols" :key="i">
|
||||
<div :style="{ fontFamily: '\'JetBrains Mono\', monospace', fontSize: '11px', color: 'rgba(10,10,10,0.45)', letterSpacing: '0.1em', textTransform: 'uppercase', marginBottom: '18px' }">{{ col[0] }}</div>
|
||||
<div :style="{ display: 'flex', flexDirection: 'column', gap: '12px' }">
|
||||
<a
|
||||
v-for="(link, j) in col[1]" :key="j" :href="link[1]"
|
||||
:style="{ fontFamily: '\'Inter\', sans-serif', fontSize: '14px', color: 'rgba(10,10,10,0.78)' }"
|
||||
@click="onLink($event, link[1])"
|
||||
>{{ link[0] }}</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div :style="{
|
||||
marginTop: '64px', paddingTop: '28px', borderTop: `1px solid ${t.border}`,
|
||||
display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: '24px',
|
||||
fontFamily: '\'JetBrains Mono\', monospace', fontSize: '11px', color: 'rgba(10,10,10,0.45)',
|
||||
}">
|
||||
<div>{{ copy.footer.copyright }}</div>
|
||||
<div :style="{ display: 'flex', alignItems: 'center', gap: '24px' }">
|
||||
<span :style="{ display: 'flex', alignItems: 'center', gap: '8px' }">
|
||||
<span :style="{ width: '6px', height: '6px', background: C.ok, borderRadius: '999px', boxShadow: `0 0 0 3px ${C.ok}33` }" />
|
||||
{{ copy.footer.status }}
|
||||
</span>
|
||||
<span>v1.0.4</span>
|
||||
</div>
|
||||
</div>
|
||||
</LandingContainer>
|
||||
</footer>
|
||||
</template>
|
||||
@@ -0,0 +1,68 @@
|
||||
<script setup lang="ts">
|
||||
// Hero — eyebrow, big headline with signal-yellow highlighter brush, sub,
|
||||
// dual CTAs, trust strip, and the product viewport mockup below.
|
||||
// Ported from landing-sections.jsx Hero (variant A, light mode).
|
||||
import { computed } from 'vue'
|
||||
import { C } from '~/utils/landingTokens'
|
||||
import { useTheme, useCopy } from '~/composables/useLanding'
|
||||
|
||||
const t = useTheme()
|
||||
const copy = useCopy()
|
||||
|
||||
// Variant A is the production default the user landed on.
|
||||
const headline = computed(() => copy.value.hero.headlineA)
|
||||
|
||||
// Light-mode highlight: a yellow marker swipe sitting low behind the phrase
|
||||
// (signal-yellow text on bone fails contrast, so we brush instead).
|
||||
const brush = {
|
||||
backgroundImage: `linear-gradient(180deg, transparent 0%, transparent 56%, ${C.signal} 56%, ${C.signal} 96%, transparent 96%)`,
|
||||
padding: '0 0.06em',
|
||||
boxDecorationBreak: 'clone',
|
||||
WebkitBoxDecorationBreak: 'clone',
|
||||
} as const
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section :style="{ background: t.bg, color: t.fg, paddingTop: '80px' }">
|
||||
<LandingContainer pad="60px 64px 0">
|
||||
<div :style="{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '56px' }">
|
||||
<span :style="{ width: '6px', height: '6px', borderRadius: '999px', background: t.signal, boxShadow: `0 0 0 4px ${t.signal}33` }" />
|
||||
<span :style="{ fontFamily: '\'JetBrains Mono\', monospace', fontSize: '12px', color: t.fgMuted, letterSpacing: '0.04em', whiteSpace: 'nowrap' }">{{ copy.hero.eyebrow }}</span>
|
||||
</div>
|
||||
|
||||
<h1 :style="{
|
||||
fontFamily: '\'Inter Tight\', \'Inter\', sans-serif',
|
||||
fontWeight: 600, fontSize: 'clamp(56px, 7.2vw, 112px)', letterSpacing: '-0.04em',
|
||||
lineHeight: 0.96, margin: 0, textWrap: 'balance', color: t.fg,
|
||||
}">
|
||||
<template v-for="(part, i) in headline" :key="i">
|
||||
<template v-if="typeof part === 'string'">{{ part }} </template>
|
||||
<span v-else :style="brush">{{ part.hl }} </span>
|
||||
</template>
|
||||
</h1>
|
||||
|
||||
<div :style="{ marginTop: '40px', maxWidth: '620px' }">
|
||||
<p :style="{ fontFamily: '\'Inter\', sans-serif', fontSize: '20px', lineHeight: 1.5, maxWidth: '620px', color: t.fgMuted, margin: 0, textWrap: 'pretty' }">{{ copy.hero.sub }}</p>
|
||||
</div>
|
||||
|
||||
<div :style="{ display: 'flex', gap: '12px', marginTop: '40px', flexWrap: 'wrap' }">
|
||||
<LandingBtn variant="primary" size="lg">{{ copy.hero.cta }} →</LandingBtn>
|
||||
<LandingBtn variant="secondary" size="lg">{{ copy.hero.sub_cta }}</LandingBtn>
|
||||
</div>
|
||||
|
||||
<div :style="{
|
||||
marginTop: '64px', paddingTop: '28px', borderTop: `1px solid ${t.border}`,
|
||||
display: 'flex', gap: '56px', flexWrap: 'wrap',
|
||||
}">
|
||||
<div v-for="(s, i) in copy.hero.trust" :key="i" :style="{ display: 'flex', alignItems: 'center', gap: '10px' }">
|
||||
<span :style="{ width: '4px', height: '4px', background: t.signal, borderRadius: '999px' }" />
|
||||
<span :style="{ fontFamily: '\'JetBrains Mono\', monospace', fontSize: '12px', color: t.fgMuted, letterSpacing: '0.04em' }">{{ s }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</LandingContainer>
|
||||
|
||||
<LandingContainer pad="80px 64px 120px">
|
||||
<LandingProductMockup />
|
||||
</LandingContainer>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
// Section 03 — setup. Three numbered steps on a top rule with node markers.
|
||||
// Ported from landing-sections.jsx HowItWorks.
|
||||
import { useTheme, useCopy } from '~/composables/useLanding'
|
||||
const t = useTheme()
|
||||
const copy = useCopy()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section :style="{ background: t.bg, color: t.fg }">
|
||||
<LandingContainer pad="120px 64px">
|
||||
<LandingSectionLabel :label="copy.how.label" />
|
||||
<h2 :style="{ fontFamily: '\'Inter Tight\', sans-serif', fontWeight: 600, fontSize: 'clamp(36px, 4.4vw, 64px)', letterSpacing: '-0.032em', lineHeight: 1.0, margin: 0, textWrap: 'balance', color: t.fg }">{{ copy.how.heading }}</h2>
|
||||
<div :style="{ marginTop: '80px', display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '0', position: 'relative' }">
|
||||
<div
|
||||
v-for="(step, i) in copy.how.steps" :key="i"
|
||||
:style="{ padding: '40px 36px 0 0', borderTop: `1px solid ${t.borderStrong}`, position: 'relative' }"
|
||||
>
|
||||
<div :style="{
|
||||
position: 'absolute', top: '-7px', left: '0', width: '14px', height: '14px',
|
||||
background: i === 0 ? t.signal : t.bg,
|
||||
border: `1px solid ${t.borderStrong}`, borderRadius: '999px',
|
||||
}" />
|
||||
<div :style="{ fontFamily: '\'JetBrains Mono\', monospace', fontSize: '12px', color: t.fgDim, letterSpacing: '0.08em' }">step {{ step.n }}</div>
|
||||
<h3 :style="{ fontFamily: '\'Inter Tight\', sans-serif', fontWeight: 600, fontSize: '28px', letterSpacing: '-0.025em', lineHeight: 1.1, margin: '20px 0 16px 0', color: t.fg }">{{ step.title }}</h3>
|
||||
<p :style="{ fontFamily: '\'Inter\', sans-serif', fontSize: '15px', lineHeight: 1.6, maxWidth: '300px', color: t.fgMuted, margin: 0, textWrap: 'pretty' }">{{ step.body }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</LandingContainer>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,50 @@
|
||||
<script setup lang="ts">
|
||||
// Tiny abstract per-module glyph (soft geometric, not literal icons).
|
||||
// Ported from landing-sections.jsx ModuleGlyph.
|
||||
import { computed } from 'vue'
|
||||
import { useTheme } from '~/composables/useLanding'
|
||||
|
||||
const props = defineProps<{ name: string }>()
|
||||
const t = useTheme()
|
||||
const stroke = 1.5
|
||||
|
||||
const kind = computed(() => {
|
||||
const n = props.name
|
||||
if (n === 'Mail' || n === 'mail') return 'mail'
|
||||
if (n === 'Drev' || n === 'Drive') return 'drive'
|
||||
if (n === 'Møder' || n === 'Meet') return 'meet'
|
||||
if (n === 'Chat') return 'chat'
|
||||
return 'identity'
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg width="36" height="36" viewBox="0 0 36 36">
|
||||
<template v-if="kind === 'mail'">
|
||||
<rect x="3" y="9" width="30" height="20" rx="2" fill="none" :stroke="t.fg" :stroke-width="stroke" />
|
||||
<path d="M3 11 L18 22 L33 11" fill="none" :stroke="t.fg" :stroke-width="stroke" />
|
||||
<circle cx="30" cy="9" r="3" :fill="t.signal" />
|
||||
</template>
|
||||
<template v-else-if="kind === 'drive'">
|
||||
<rect x="4" y="10" width="28" height="18" rx="2" fill="none" :stroke="t.fg" :stroke-width="stroke" />
|
||||
<path d="M4 14 L13 14 L16 10 L32 10" fill="none" :stroke="t.fg" :stroke-width="stroke" />
|
||||
<circle cx="18" cy="20" r="2.5" :fill="t.signal" />
|
||||
</template>
|
||||
<template v-else-if="kind === 'meet'">
|
||||
<rect x="3" y="10" width="22" height="16" rx="2" fill="none" :stroke="t.fg" :stroke-width="stroke" />
|
||||
<path d="M25 14 L33 10 L33 26 L25 22 Z" fill="none" :stroke="t.fg" :stroke-width="stroke" />
|
||||
<circle cx="14" cy="18" r="3" :fill="t.signal" />
|
||||
</template>
|
||||
<template v-else-if="kind === 'chat'">
|
||||
<path d="M5 6 L31 6 Q33 6 33 8 L33 22 Q33 24 31 24 L16 24 L9 30 L9 24 L7 24 Q5 24 5 22 Z" fill="none" :stroke="t.fg" :stroke-width="stroke" />
|
||||
<circle cx="14" cy="15" r="1.6" :fill="t.signal" />
|
||||
<circle cx="19" cy="15" r="1.6" :fill="t.fg" />
|
||||
<circle cx="24" cy="15" r="1.6" :fill="t.fg" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<rect x="8" y="14" width="20" height="16" rx="2" fill="none" :stroke="t.fg" :stroke-width="stroke" />
|
||||
<path d="M12 14 V10 Q12 5 18 5 Q24 5 24 10 V14" fill="none" :stroke="t.fg" :stroke-width="stroke" />
|
||||
<circle cx="18" cy="22" r="2.5" :fill="t.signal" />
|
||||
</template>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,64 @@
|
||||
<script setup lang="ts">
|
||||
// Sticky top nav with logo, anchor links, language toggle, login + demo CTA.
|
||||
// Ported from landing-sections.jsx Nav (light mode, production subset).
|
||||
import { computed } from 'vue'
|
||||
import { APP_URL } from '~/utils/landingTokens'
|
||||
import { useTheme, useCopy, useLang, toggleLang, scrollToAnchor } from '~/composables/useLanding'
|
||||
|
||||
const t = useTheme()
|
||||
const copy = useCopy()
|
||||
const lang = useLang()
|
||||
|
||||
const items = computed(() => [
|
||||
{ label: copy.value.nav.product, href: '#suite' },
|
||||
{ label: copy.value.nav.security, href: '#sovereignty' },
|
||||
{ label: copy.value.nav.whitelabel, href: '#whitelabel' },
|
||||
{ label: copy.value.nav.pricing, href: '#pricing' },
|
||||
{ label: copy.value.nav.docs, href: '#' },
|
||||
])
|
||||
|
||||
function onLogo() {
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header :style="{
|
||||
position: 'sticky', top: '0', zIndex: 100,
|
||||
background: 'rgba(250,250,247,0.84)',
|
||||
backdropFilter: 'blur(14px)', WebkitBackdropFilter: 'blur(14px)',
|
||||
borderBottom: `1px solid ${t.border}`,
|
||||
}">
|
||||
<div :style="{
|
||||
maxWidth: '1280px', margin: '0 auto', padding: '18px 64px',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
}">
|
||||
<a href="#" :style="{ display: 'flex', alignItems: 'center', gap: '12px', cursor: 'pointer' }" @click.prevent="onLogo">
|
||||
<BrandNodeMark :size="32" :fg="t.fg" :accent="t.signal" />
|
||||
<div :style="{ fontFamily: '\'JetBrains Mono\', monospace', fontWeight: 600, fontSize: '16px', letterSpacing: '-0.02em', color: t.fg }">dezky</div>
|
||||
</a>
|
||||
|
||||
<nav :style="{ display: 'flex', alignItems: 'center', gap: '36px' }">
|
||||
<a
|
||||
v-for="(it, i) in items" :key="i" :href="it.href"
|
||||
:style="{ fontFamily: '\'Inter\', sans-serif', fontSize: '14px', color: t.fgMuted, letterSpacing: '-0.005em' }"
|
||||
@click.prevent="scrollToAnchor(it.href)"
|
||||
>{{ it.label }}</a>
|
||||
</nav>
|
||||
|
||||
<div :style="{ display: 'flex', alignItems: 'center', gap: '14px' }">
|
||||
<button
|
||||
:style="{
|
||||
background: 'transparent', border: `1px solid ${t.border}`,
|
||||
color: t.fgMuted, fontSize: '11px', padding: '6px 10px', borderRadius: '4px',
|
||||
fontFamily: '\'JetBrains Mono\', monospace', letterSpacing: '0.06em', textTransform: 'uppercase',
|
||||
whiteSpace: 'nowrap',
|
||||
}"
|
||||
@click="toggleLang()"
|
||||
>{{ lang === 'da' ? 'da · en' : 'en · da' }}</button>
|
||||
<a :href="APP_URL" :style="{ fontFamily: '\'Inter\', sans-serif', fontSize: '14px', color: t.fgMuted }">{{ copy.nav.login }}</a>
|
||||
<LandingBtn variant="primary" @click="scrollToAnchor('#final-cta')">{{ copy.nav.cta }} →</LandingBtn>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
@@ -0,0 +1,37 @@
|
||||
<script setup lang="ts">
|
||||
// A single "partner brand" row in the whitelabel demo.
|
||||
// Ported from landing-sections.jsx PartnerCard.
|
||||
import { computed } from 'vue'
|
||||
const props = withDefaults(defineProps<{
|
||||
fg: string
|
||||
bg: string
|
||||
border: string
|
||||
accent: string
|
||||
name: string
|
||||
subtitle: string
|
||||
placeholder?: boolean
|
||||
}>(), { placeholder: false })
|
||||
|
||||
const initial = computed(() => props.name[0].toUpperCase())
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :style="{
|
||||
background: bg, border: `1px solid ${border}`, borderRadius: '4px',
|
||||
padding: '20px 22px', display: 'flex', alignItems: 'center', gap: '16px',
|
||||
opacity: placeholder ? 0.55 : 1,
|
||||
borderStyle: placeholder ? 'dashed' : 'solid',
|
||||
}">
|
||||
<div :style="{
|
||||
width: '44px', height: '44px', borderRadius: '4px', background: accent,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontFamily: '\'Inter Tight\', sans-serif', fontWeight: 700,
|
||||
fontSize: '22px', color: '#FFF', letterSpacing: '-0.02em', flexShrink: 0,
|
||||
}">{{ initial }}</div>
|
||||
<div :style="{ flex: 1 }">
|
||||
<div :style="{ fontFamily: '\'Inter Tight\', sans-serif', fontWeight: 600, fontSize: '17px', color: fg, letterSpacing: '-0.02em' }">{{ name }}</div>
|
||||
<div :style="{ fontFamily: '\'JetBrains Mono\', monospace', fontSize: '11px', color: 'rgba(0,0,0,0.5)', marginTop: '2px' }">{{ subtitle }}</div>
|
||||
</div>
|
||||
<div :style="{ fontFamily: '\'JetBrains Mono\', monospace', fontSize: '10px', color: 'rgba(0,0,0,0.4)', letterSpacing: '0.1em', textTransform: 'uppercase' }">powered by dezky</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,35 @@
|
||||
<script setup lang="ts">
|
||||
// Section 08 — pricing. Pitch column + a single price card.
|
||||
// Ported from landing-sections.jsx Pricing.
|
||||
import { useTheme, useCopy } from '~/composables/useLanding'
|
||||
const t = useTheme()
|
||||
const copy = useCopy()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section id="pricing" :style="{ background: t.bgAlt, color: t.fg, borderTop: `1px solid ${t.border}`, borderBottom: `1px solid ${t.border}`, scrollMarginTop: '72px' }">
|
||||
<LandingContainer pad="120px 64px">
|
||||
<LandingSectionLabel :label="copy.pricing.label" />
|
||||
<div :style="{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '80px', alignItems: 'center' }">
|
||||
<div>
|
||||
<h2 :style="{ fontFamily: '\'Inter Tight\', sans-serif', fontWeight: 600, fontSize: 'clamp(36px, 4.4vw, 64px)', letterSpacing: '-0.032em', lineHeight: 1.0, margin: 0, textWrap: 'balance', color: t.fg }">{{ copy.pricing.heading }}</h2>
|
||||
<div :style="{ marginTop: '28px', maxWidth: '520px' }">
|
||||
<p :style="{ fontFamily: '\'Inter\', sans-serif', fontSize: '20px', lineHeight: 1.5, maxWidth: '640px', color: t.fgMuted, margin: 0, textWrap: 'pretty' }">{{ copy.pricing.lede }}</p>
|
||||
</div>
|
||||
<div :style="{ marginTop: '36px', display: 'flex', alignItems: 'center', gap: '16px' }">
|
||||
<LandingBtn variant="primary" size="lg">{{ copy.pricing.cta }} →</LandingBtn>
|
||||
</div>
|
||||
</div>
|
||||
<div :style="{ background: t.surface, border: `1px solid ${t.border}`, borderRadius: '6px', padding: '36px 36px' }">
|
||||
<div :style="{ fontFamily: '\'JetBrains Mono\', monospace', fontSize: '11px', color: t.fgDim, letterSpacing: '0.1em', textTransform: 'uppercase' }">{{ copy.pricing.teaser }}</div>
|
||||
<div :style="{ display: 'flex', alignItems: 'baseline', gap: '12px', marginTop: '12px' }">
|
||||
<span :style="{ fontFamily: '\'Inter Tight\', sans-serif', fontSize: '96px', fontWeight: 600, letterSpacing: '-0.045em', lineHeight: 1, color: t.fg }">{{ copy.pricing.price }}</span>
|
||||
<span :style="{ fontFamily: '\'JetBrains Mono\', monospace', fontSize: '13px', color: t.fgMuted }">{{ copy.pricing.unit }}</span>
|
||||
</div>
|
||||
<div :style="{ height: '1px', background: t.border, margin: '28px 0' }" />
|
||||
<p :style="{ fontFamily: '\'Inter\', sans-serif', fontSize: '13px', lineHeight: 1.6, maxWidth: '640px', color: t.fgMuted, margin: 0, textWrap: 'pretty' }">{{ copy.pricing.note }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</LandingContainer>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
// Section 01 — the problem. Ported from landing-sections.jsx Problem.
|
||||
import { useTheme, useCopy } from '~/composables/useLanding'
|
||||
const t = useTheme()
|
||||
const copy = useCopy()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section :style="{ background: t.bg, color: t.fg }">
|
||||
<LandingContainer pad="120px 64px">
|
||||
<LandingSectionLabel :label="copy.problem.label" />
|
||||
<div :style="{ display: 'grid', gridTemplateColumns: '1fr 1.4fr', gap: '80px' }">
|
||||
<h2 :style="{ fontFamily: '\'Inter Tight\', sans-serif', fontWeight: 600, fontSize: 'clamp(36px, 4.4vw, 64px)', letterSpacing: '-0.032em', lineHeight: 1.0, margin: 0, textWrap: 'balance', color: t.fg }">{{ copy.problem.heading }}</h2>
|
||||
<div :style="{ display: 'flex', flexDirection: 'column', gap: '18px', paddingTop: '12px' }">
|
||||
<p :style="{ fontFamily: '\'Inter\', sans-serif', fontSize: '18px', lineHeight: 1.6, maxWidth: '620px', color: t.fgMuted, margin: 0, textWrap: 'pretty' }">{{ copy.problem.p1 }}</p>
|
||||
<p :style="{ fontFamily: '\'Inter\', sans-serif', fontSize: '18px', lineHeight: 1.6, maxWidth: '620px', color: t.fgMuted, margin: 0, textWrap: 'pretty' }">{{ copy.problem.p2 }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</LandingContainer>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,125 @@
|
||||
<script setup lang="ts">
|
||||
// Stylized customer-portal dashboard shown under the hero. Illustrative only —
|
||||
// labels are intentionally Danish in both languages. Ported from
|
||||
// landing-sections.jsx ProductMockup (light mode).
|
||||
import { computed } from 'vue'
|
||||
import { C } from '~/utils/landingTokens'
|
||||
import { useTheme, useDark } from '~/composables/useLanding'
|
||||
|
||||
const t = useTheme()
|
||||
const dark = useDark()
|
||||
|
||||
const m = computed(() => ({
|
||||
bg: dark.value ? '#171715' : '#FFFFFF',
|
||||
border: dark.value ? 'rgba(255,255,255,0.08)' : 'rgba(10,10,10,0.08)',
|
||||
fg: dark.value ? C.bone : C.carbon,
|
||||
muted: dark.value ? 'rgba(244,243,238,0.55)' : 'rgba(10,10,10,0.55)',
|
||||
subtle: dark.value ? 'rgba(244,243,238,0.08)' : 'rgba(10,10,10,0.04)',
|
||||
}))
|
||||
|
||||
const apps = [
|
||||
{ name: 'mail', badge: '12', active: true },
|
||||
{ name: 'drev' },
|
||||
{ name: 'møder' },
|
||||
{ name: 'chat', pill: '3' },
|
||||
{ name: 'admin' },
|
||||
]
|
||||
|
||||
const stats: [string, string, string][] = [
|
||||
['Ulæste', '12', 'mail'],
|
||||
['Møder i dag', '3', 'møder'],
|
||||
['Delte filer', '47', 'drev'],
|
||||
]
|
||||
|
||||
const recent: [string, string, string][] = [
|
||||
['Lone Frederiksen', 'Tilbud — Q3 retainer', '09:42'],
|
||||
['ops@stalwart.io', 'Sikkerhedsopdatering 1.12 udrullet', '08:30'],
|
||||
['Mads Holm', 'Re: Onboarding for nye seniors', 'i går'],
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :style="{
|
||||
background: m.bg, border: `1px solid ${m.border}`, borderRadius: '8px',
|
||||
boxShadow: dark ? '0 40px 80px rgba(0,0,0,0.5)' : '0 30px 80px rgba(10,10,10,0.08)',
|
||||
overflow: 'hidden',
|
||||
}">
|
||||
<!-- Window chrome -->
|
||||
<div :style="{ display: 'flex', alignItems: 'center', gap: '8px', padding: '14px 18px', borderBottom: `1px solid ${m.border}` }">
|
||||
<span :style="{ width: '10px', height: '10px', borderRadius: '999px', background: '#E23030' }" />
|
||||
<span :style="{ width: '10px', height: '10px', borderRadius: '999px', background: '#E89A1F' }" />
|
||||
<span :style="{ width: '10px', height: '10px', borderRadius: '999px', background: '#1F8A5B' }" />
|
||||
<div :style="{ marginLeft: '16px', padding: '4px 12px', background: m.subtle, borderRadius: '4px', fontFamily: '\'JetBrains Mono\', monospace', fontSize: '11px', color: m.muted }">app.dezky.com / dashboard</div>
|
||||
</div>
|
||||
|
||||
<div :style="{ display: 'grid', gridTemplateColumns: '220px 1fr', minHeight: '460px' }">
|
||||
<!-- Sidebar -->
|
||||
<div :style="{ borderRight: `1px solid ${m.border}`, padding: '20px 0' }">
|
||||
<div :style="{ display: 'flex', alignItems: 'center', gap: '8px', padding: '0 20px', marginBottom: '24px' }">
|
||||
<BrandNodeMark :size="20" :fg="m.fg" :accent="t.signal" />
|
||||
<span :style="{ fontFamily: '\'JetBrains Mono\', monospace', fontSize: '12px', fontWeight: 600, color: m.fg }">dezky</span>
|
||||
</div>
|
||||
<div
|
||||
v-for="(a, i) in apps" :key="a.name"
|
||||
:style="{
|
||||
padding: '9px 20px',
|
||||
background: a.active ? m.subtle : 'transparent',
|
||||
borderLeft: `2px solid ${a.active ? t.signal : 'transparent'}`,
|
||||
fontFamily: '\'Inter\', sans-serif', fontSize: '13px', fontWeight: a.active ? 600 : 500,
|
||||
color: a.active ? m.fg : m.muted,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
}"
|
||||
>
|
||||
<span>{{ a.name }}</span>
|
||||
<span v-if="a.badge" :style="{ fontFamily: '\'JetBrains Mono\', monospace', fontSize: '10px', color: m.muted }">{{ a.badge }}</span>
|
||||
<span v-else-if="a.pill" :style="{ fontFamily: '\'JetBrains Mono\', monospace', fontSize: '10px', background: t.signal, padding: '1px 6px', borderRadius: '2px', color: C.carbon }">{{ a.pill }}</span>
|
||||
</div>
|
||||
<div :style="{ margin: '32px 20px 0', padding: '14px', borderRadius: '4px', background: m.subtle, fontFamily: '\'Inter\', sans-serif' }">
|
||||
<div :style="{ fontSize: '10px', fontFamily: '\'JetBrains Mono\', monospace', color: m.muted, letterSpacing: '0.1em', textTransform: 'uppercase' }">lagring</div>
|
||||
<div :style="{ fontSize: '16px', fontWeight: 600, color: m.fg, marginTop: '6px' }">184 GB / 500</div>
|
||||
<div :style="{ height: '4px', background: m.border, borderRadius: '999px', marginTop: '8px', overflow: 'hidden' }">
|
||||
<div :style="{ height: '100%', width: '37%', background: t.signal }" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main -->
|
||||
<div :style="{ padding: '28px 32px' }">
|
||||
<div :style="{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }">
|
||||
<div>
|
||||
<div :style="{ fontFamily: '\'JetBrains Mono\', monospace', fontSize: '11px', color: m.muted, letterSpacing: '0.1em', textTransform: 'uppercase' }">indbakke</div>
|
||||
<div :style="{ fontFamily: '\'Inter Tight\', sans-serif', fontWeight: 600, fontSize: '28px', color: m.fg, marginTop: '6px', letterSpacing: '-0.02em' }">god morgen, anne</div>
|
||||
</div>
|
||||
<div :style="{ display: 'flex', gap: '8px' }">
|
||||
<div :style="{ width: '32px', height: '32px', borderRadius: '999px', background: m.subtle, display: 'flex', alignItems: 'center', justifyContent: 'center', fontFamily: '\'JetBrains Mono\', monospace', fontSize: '11px', color: m.fg, fontWeight: 600 }">AB</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div :style="{ marginTop: '24px', display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '12px' }">
|
||||
<div v-for="(s, i) in stats" :key="i" :style="{ padding: '16px 18px', background: m.subtle, borderRadius: '4px' }">
|
||||
<div :style="{ fontFamily: '\'JetBrains Mono\', monospace', fontSize: '10px', color: m.muted, letterSpacing: '0.1em', textTransform: 'uppercase' }">{{ s[2] }}</div>
|
||||
<div :style="{ fontFamily: '\'Inter Tight\', sans-serif', fontSize: '28px', fontWeight: 600, color: m.fg, marginTop: '4px' }">{{ s[1] }}</div>
|
||||
<div :style="{ fontSize: '12px', color: m.muted, marginTop: '2px' }">{{ s[0] }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div :style="{ marginTop: '24px' }">
|
||||
<div :style="{ fontFamily: '\'JetBrains Mono\', monospace', fontSize: '10px', color: m.muted, letterSpacing: '0.1em', textTransform: 'uppercase', marginBottom: '12px' }">seneste · indbakke</div>
|
||||
<div
|
||||
v-for="(r, i) in recent" :key="i"
|
||||
:style="{
|
||||
display: 'grid', gridTemplateColumns: '200px 1fr 60px',
|
||||
padding: '12px 0', borderTop: i === 0 ? `1px solid ${m.border}` : 'none',
|
||||
borderBottom: `1px solid ${m.border}`,
|
||||
fontFamily: '\'Inter\', sans-serif', fontSize: '13px', alignItems: 'center',
|
||||
}"
|
||||
>
|
||||
<span :style="{ fontWeight: 600, color: m.fg }">{{ r[0] }}</span>
|
||||
<span :style="{ color: m.muted }">{{ r[1] }}</span>
|
||||
<span :style="{ fontFamily: '\'JetBrains Mono\', monospace', fontSize: '11px', color: m.muted, textAlign: 'right' }">{{ r[2] }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
// Monospace "NN — label" section header with an underline. Ported from
|
||||
// landing-sections.jsx SectionLabel. Pass the full "01 — udfordringen" string.
|
||||
import { useTheme } from '~/composables/useLanding'
|
||||
|
||||
const props = defineProps<{ label: string }>()
|
||||
const t = useTheme()
|
||||
|
||||
const parts = computed(() => {
|
||||
const [index, ...rest] = props.label.split(' — ')
|
||||
return { index, text: rest.join(' — ') }
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :style="{
|
||||
display: 'flex', alignItems: 'center', gap: '14px',
|
||||
paddingBottom: '24px', marginBottom: '56px',
|
||||
borderBottom: `1px solid ${t.border}`,
|
||||
fontFamily: '\'JetBrains Mono\', monospace',
|
||||
fontSize: '11px', letterSpacing: '0.16em', textTransform: 'uppercase',
|
||||
color: t.fgMuted,
|
||||
}">
|
||||
<span :style="{ color: t.fgDim }">{{ parts.index }}</span>
|
||||
<span>—</span>
|
||||
<span>{{ parts.text }}</span>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,64 @@
|
||||
<script setup lang="ts">
|
||||
// Section 04 — sovereignty. Inverted (carbon) panel with a heading/body column
|
||||
// and a spec table. Ported from landing-sections.jsx Sovereignty.
|
||||
import { computed } from 'vue'
|
||||
import { C } from '~/utils/landingTokens'
|
||||
import { useTheme, useCopy } from '~/composables/useLanding'
|
||||
|
||||
const t = useTheme()
|
||||
const copy = useCopy()
|
||||
|
||||
// Borders/muted derive from whether the inverted foreground is bone (light
|
||||
// page → carbon panel → bone text) or carbon (dark page → bone panel).
|
||||
const onBone = computed(() => t.value.invertFg === C.bone)
|
||||
const rule14 = computed(() => (onBone.value ? 'rgba(244,243,238,0.14)' : 'rgba(10,10,10,0.14)'))
|
||||
const rule18 = computed(() => (onBone.value ? 'rgba(244,243,238,0.18)' : 'rgba(10,10,10,0.18)'))
|
||||
const rule10 = computed(() => (onBone.value ? 'rgba(244,243,238,0.1)' : 'rgba(10,10,10,0.1)'))
|
||||
const muted55 = computed(() => (onBone.value ? 'rgba(244,243,238,0.55)' : 'rgba(10,10,10,0.55)'))
|
||||
const muted70 = computed(() => (onBone.value ? 'rgba(244,243,238,0.7)' : 'rgba(10,10,10,0.7)'))
|
||||
|
||||
const labelParts = computed(() => {
|
||||
const [index, ...rest] = copy.value.sovereignty.label.split(' — ')
|
||||
return { index, text: rest.join(' — ') }
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section id="sovereignty" :style="{ background: t.invert, color: t.invertFg, scrollMarginTop: '72px' }">
|
||||
<LandingContainer pad="140px 64px">
|
||||
<div :style="{
|
||||
display: 'flex', alignItems: 'center', gap: '14px',
|
||||
paddingBottom: '24px', marginBottom: '56px',
|
||||
borderBottom: `1px solid ${rule14}`,
|
||||
fontFamily: '\'JetBrains Mono\', monospace', fontSize: '11px',
|
||||
letterSpacing: '0.16em', textTransform: 'uppercase', color: muted55,
|
||||
}">
|
||||
<span :style="{ opacity: 0.6 }">{{ labelParts.index }}</span>
|
||||
<span>—</span>
|
||||
<span>{{ labelParts.text }}</span>
|
||||
</div>
|
||||
<div :style="{ display: 'grid', gridTemplateColumns: '1.1fr 1fr', gap: '96px' }">
|
||||
<div>
|
||||
<h2 :style="{ fontFamily: '\'Inter Tight\', sans-serif', fontWeight: 600, fontSize: 'clamp(36px, 4.4vw, 64px)', letterSpacing: '-0.032em', lineHeight: 1.0, margin: 0, textWrap: 'balance', color: t.invertFg }">{{ copy.sovereignty.heading }}</h2>
|
||||
<div :style="{ marginTop: '36px', display: 'flex', flexDirection: 'column', gap: '18px', maxWidth: '540px' }">
|
||||
<p v-for="(p, i) in copy.sovereignty.body" :key="i" :style="{ fontFamily: '\'Inter\', sans-serif', fontSize: '17px', lineHeight: 1.55, color: muted70, margin: 0, textWrap: 'pretty' }">{{ p }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div :style="{ paddingTop: '8px' }">
|
||||
<div
|
||||
v-for="(row, i) in copy.sovereignty.checks" :key="i"
|
||||
:style="{
|
||||
display: 'grid', gridTemplateColumns: '1fr 1.4fr', padding: '20px 0',
|
||||
borderTop: i === 0 ? `1px solid ${rule18}` : 'none',
|
||||
borderBottom: `1px solid ${rule10}`,
|
||||
fontFamily: '\'Inter\', sans-serif', fontSize: '14px', alignItems: 'baseline',
|
||||
}"
|
||||
>
|
||||
<div :style="{ fontFamily: '\'JetBrains Mono\', monospace', fontSize: '11px', color: muted55, letterSpacing: '0.06em', textTransform: 'uppercase' }">{{ row[0] }}</div>
|
||||
<div :style="{ color: t.invertFg, fontWeight: 500 }">{{ row[1] }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</LandingContainer>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,37 @@
|
||||
<script setup lang="ts">
|
||||
// Section 07 — under the hood. Open-source component table.
|
||||
// Ported from landing-sections.jsx Stack.
|
||||
import { useTheme, useCopy } from '~/composables/useLanding'
|
||||
const t = useTheme()
|
||||
const copy = useCopy()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section id="stack" :style="{ background: t.bg, color: t.fg, scrollMarginTop: '72px' }">
|
||||
<LandingContainer pad="140px 64px">
|
||||
<LandingSectionLabel :label="copy.stack.label" />
|
||||
<div :style="{ display: 'grid', gridTemplateColumns: '1.1fr 1fr', gap: '80px', alignItems: 'end', marginBottom: '56px' }">
|
||||
<h2 :style="{ fontFamily: '\'Inter Tight\', sans-serif', fontWeight: 600, fontSize: 'clamp(36px, 4.4vw, 64px)', letterSpacing: '-0.032em', lineHeight: 1.0, margin: 0, textWrap: 'balance', color: t.fg }">{{ copy.stack.heading }}</h2>
|
||||
<p :style="{ fontFamily: '\'Inter\', sans-serif', fontSize: '20px', lineHeight: 1.5, maxWidth: '480px', color: t.fgMuted, margin: 0, textWrap: 'pretty' }">{{ copy.stack.lede }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
v-for="(row, i) in copy.stack.rows" :key="i"
|
||||
:style="{
|
||||
display: 'grid', gridTemplateColumns: '1fr 1.4fr 0.8fr 1fr 40px',
|
||||
gap: '24px', padding: '24px 0',
|
||||
borderTop: i === 0 ? `1px solid ${t.borderStrong}` : 'none',
|
||||
borderBottom: `1px solid ${t.border}`,
|
||||
alignItems: 'baseline', fontFamily: '\'Inter\', sans-serif', fontSize: '15px',
|
||||
}"
|
||||
>
|
||||
<div :style="{ fontFamily: '\'JetBrains Mono\', monospace', fontSize: '11px', color: t.fgDim, letterSpacing: '0.08em', textTransform: 'uppercase' }">{{ row[0] }}</div>
|
||||
<div :style="{ fontFamily: '\'Inter Tight\', sans-serif', fontSize: '20px', fontWeight: 600, color: t.fg, letterSpacing: '-0.015em' }">{{ row[1] }}</div>
|
||||
<div :style="{ fontFamily: '\'JetBrains Mono\', monospace', fontSize: '12px', color: t.fg, padding: '4px 10px', background: t.bgAlt, borderRadius: '3px', display: 'inline-block', justifySelf: 'start' }">{{ row[2] }}</div>
|
||||
<div :style="{ fontFamily: '\'JetBrains Mono\', monospace', fontSize: '12px', color: t.fgMuted }">{{ row[3] }}</div>
|
||||
<div :style="{ fontFamily: '\'JetBrains Mono\', monospace', fontSize: '14px', color: t.fgDim, textAlign: 'right' }">↗</div>
|
||||
</div>
|
||||
</div>
|
||||
</LandingContainer>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,38 @@
|
||||
<script setup lang="ts">
|
||||
// Section 02 — the suite. Five module cards in a bordered row.
|
||||
// Ported from landing-sections.jsx Suite.
|
||||
import { useTheme, useCopy } from '~/composables/useLanding'
|
||||
const t = useTheme()
|
||||
const copy = useCopy()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section id="suite" :style="{ background: t.bgAlt, color: t.fg, borderTop: `1px solid ${t.border}`, borderBottom: `1px solid ${t.border}`, scrollMarginTop: '72px' }">
|
||||
<LandingContainer pad="120px 64px">
|
||||
<LandingSectionLabel :label="copy.suite.label" />
|
||||
<div :style="{ display: 'grid', gridTemplateColumns: '1.1fr 1fr', gap: '80px', alignItems: 'end', marginBottom: '64px' }">
|
||||
<h2 :style="{ fontFamily: '\'Inter Tight\', sans-serif', fontWeight: 600, fontSize: 'clamp(36px, 4.4vw, 64px)', letterSpacing: '-0.032em', lineHeight: 1.0, margin: 0, textWrap: 'balance', color: t.fg }">{{ copy.suite.heading }}</h2>
|
||||
<p :style="{ fontFamily: '\'Inter\', sans-serif', fontSize: '20px', lineHeight: 1.5, maxWidth: '520px', color: t.fgMuted, margin: 0, textWrap: 'pretty' }">{{ copy.suite.lede }}</p>
|
||||
</div>
|
||||
<div :style="{ display: 'grid', gridTemplateColumns: 'repeat(5, 1fr)', gap: '0', border: `1px solid ${t.border}` }">
|
||||
<div
|
||||
v-for="(card, i) in copy.suite.cards" :key="i"
|
||||
:style="{
|
||||
padding: '32px 28px',
|
||||
borderRight: i < 4 ? `1px solid ${t.border}` : 'none',
|
||||
background: t.surface,
|
||||
display: 'flex', flexDirection: 'column', gap: '20px',
|
||||
minHeight: '280px',
|
||||
}"
|
||||
>
|
||||
<LandingModuleGlyph :name="card.name" />
|
||||
<div>
|
||||
<div :style="{ fontFamily: '\'Inter Tight\', sans-serif', fontWeight: 600, fontSize: '22px', color: t.fg, letterSpacing: '-0.02em' }">{{ card.name }}</div>
|
||||
<div :style="{ fontFamily: '\'JetBrains Mono\', monospace', fontSize: '10.5px', color: t.fgDim, marginTop: '6px', letterSpacing: '0.08em', textTransform: 'lowercase' }">{{ card.tag }}</div>
|
||||
</div>
|
||||
<p :style="{ fontFamily: '\'Inter\', sans-serif', fontSize: '13.5px', lineHeight: 1.6, maxWidth: '260px', color: t.fgMuted, margin: 0, textWrap: 'pretty' }">{{ card.desc }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</LandingContainer>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,43 @@
|
||||
<script setup lang="ts">
|
||||
// Section 06 — for partners (whitelabel). Pitch column + partner-brand demo.
|
||||
// Ported from landing-sections.jsx Whitelabel (light mode).
|
||||
import { computed } from 'vue'
|
||||
import { useTheme, useCopy, useDark } from '~/composables/useLanding'
|
||||
|
||||
const t = useTheme()
|
||||
const copy = useCopy()
|
||||
const dark = useDark()
|
||||
|
||||
const sectionBg = computed(() => (dark.value ? '#1A1A17' : '#EFEDE3'))
|
||||
const cardBg = computed(() => (dark.value ? '#0F0F0D' : '#FFFFFF'))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section id="whitelabel" :style="{ background: sectionBg, color: t.fg, borderTop: `1px solid ${t.border}`, borderBottom: `1px solid ${t.border}`, scrollMarginTop: '72px' }">
|
||||
<LandingContainer pad="120px 64px">
|
||||
<LandingSectionLabel :label="copy.whitelabel.label" />
|
||||
<div :style="{ display: 'grid', gridTemplateColumns: '1.2fr 1fr', gap: '80px', alignItems: 'start' }">
|
||||
<div>
|
||||
<h2 :style="{ fontFamily: '\'Inter Tight\', sans-serif', fontWeight: 600, fontSize: 'clamp(36px, 4.4vw, 64px)', letterSpacing: '-0.032em', lineHeight: 1.0, margin: 0, textWrap: 'balance', color: t.fg }">{{ copy.whitelabel.heading }}</h2>
|
||||
<div :style="{ marginTop: '32px', maxWidth: '520px' }">
|
||||
<p :style="{ fontFamily: '\'Inter\', sans-serif', fontSize: '20px', lineHeight: 1.5, maxWidth: '640px', color: t.fgMuted, margin: 0, textWrap: 'pretty' }">{{ copy.whitelabel.lede }}</p>
|
||||
</div>
|
||||
<div :style="{ marginTop: '32px', display: 'flex', flexDirection: 'column', gap: '12px' }">
|
||||
<div v-for="(b, i) in copy.whitelabel.bullets" :key="i" :style="{ display: 'flex', alignItems: 'center', gap: '14px', fontFamily: '\'Inter\', sans-serif', fontSize: '15px', color: t.fg }">
|
||||
<span :style="{ fontFamily: '\'JetBrains Mono\', monospace', fontSize: '10px', color: t.fgDim, width: '24px' }">0{{ i + 1 }}</span>
|
||||
<span>{{ b }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div :style="{ marginTop: '40px' }">
|
||||
<LandingBtn variant="secondary" size="lg">{{ copy.whitelabel.cta }} →</LandingBtn>
|
||||
</div>
|
||||
</div>
|
||||
<div :style="{ display: 'flex', flexDirection: 'column', gap: '16px' }">
|
||||
<LandingPartnerCard :fg="t.fg" :bg="cardBg" :border="t.border" accent="#D6502A" name="moltke it" subtitle="aalborg · 24 brugere" />
|
||||
<LandingPartnerCard :fg="t.fg" :bg="cardBg" :border="t.border" accent="#3956C8" name="kraft & partners" subtitle="københavn · 112 brugere" />
|
||||
<LandingPartnerCard :fg="t.fg" :bg="cardBg" :border="t.border" :accent="t.signal" name="dit firma her" subtitle="—" placeholder />
|
||||
</div>
|
||||
</div>
|
||||
</LandingContainer>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,36 @@
|
||||
import { computed } from 'vue'
|
||||
import { COPY, type Lang } from '~/utils/landingCopy'
|
||||
import { makeTheme } from '~/utils/landingTokens'
|
||||
|
||||
// Shared landing state. `lang` is a real production toggle (da/en, both fully
|
||||
// translated). `dark` is kept as machinery from the design's Tweaks panel but
|
||||
// defaults to light — the primary theme the user landed on — and no toggle is
|
||||
// surfaced. Flip the default (or add a control) to enable dark later.
|
||||
export const useLang = () => useState<Lang>('dz-lang', () => 'da')
|
||||
export const useDark = () => useState<boolean>('dz-dark', () => false)
|
||||
|
||||
export const useTheme = () => {
|
||||
const dark = useDark()
|
||||
return computed(() => makeTheme(dark.value))
|
||||
}
|
||||
|
||||
export const useCopy = () => {
|
||||
const lang = useLang()
|
||||
return computed(() => COPY[lang.value === 'en' ? 'en' : 'da'])
|
||||
}
|
||||
|
||||
export function toggleLang() {
|
||||
const lang = useLang()
|
||||
lang.value = lang.value === 'da' ? 'en' : 'da'
|
||||
}
|
||||
|
||||
// Smooth-scroll to an in-page anchor, accounting for the sticky 72px nav.
|
||||
// Non-anchor / placeholder links (#) are ignored.
|
||||
export function scrollToAnchor(hash: string) {
|
||||
if (!hash || hash === '#' || !hash.startsWith('#')) return
|
||||
const el = document.getElementById(hash.slice(1))
|
||||
if (!el) return
|
||||
const top = el.getBoundingClientRect().top + window.scrollY - 72
|
||||
window.scrollTo({ top, behavior: 'smooth' })
|
||||
history.replaceState(null, '', hash)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<template>
|
||||
<div class="site">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!--
|
||||
Minimal marketing-site layout. Intentionally bare — the Claude design
|
||||
handoff for the landing page will introduce the real header/footer chrome.
|
||||
-->
|
||||
<style scoped>
|
||||
.site {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,53 @@
|
||||
// Nuxt 4 configuration for the Dezky public marketing site (dezky.com).
|
||||
//
|
||||
// Unlike apps/portal and apps/operator this surface is fully public — no
|
||||
// OIDC, no sessions, no platform-api coupling. It can be statically
|
||||
// generated (`pnpm generate`) and served from a CDN/edge independently of
|
||||
// the Docker app stack. Locally it runs behind Traefik at dezky.local /
|
||||
// www.dezky.local with the same mkcert TLS as the rest of the platform.
|
||||
|
||||
export default defineNuxtConfig({
|
||||
compatibilityDate: '2026-01-01',
|
||||
devtools: { enabled: true },
|
||||
|
||||
css: ['~/assets/styles/tokens.css', '~/assets/styles/base.css'],
|
||||
|
||||
// Auto-import from the shared packages/ui workspace in addition to the
|
||||
// app's own components/. /shared-packages is bind-mounted in
|
||||
// docker-compose.yml — outside containers the same files live at
|
||||
// <repo>/packages/ui/components/. The local dir keeps the default
|
||||
// directory-based prefix; the shared dir uses no prefix so a shared
|
||||
// CountrySelect.vue is just <CountrySelect>. Mirrors portal/operator.
|
||||
components: [
|
||||
'~/components',
|
||||
{ path: '/shared-packages/ui/components', pathPrefix: false },
|
||||
],
|
||||
|
||||
app: {
|
||||
head: {
|
||||
// Marketing site is light by default (the design's primary theme). The
|
||||
// page sets <html lang> reactively based on the da/en toggle.
|
||||
htmlAttrs: { lang: 'da' },
|
||||
link: [
|
||||
{ rel: 'preconnect', href: 'https://fonts.googleapis.com' },
|
||||
{ rel: 'preconnect', href: 'https://fonts.gstatic.com', crossorigin: '' },
|
||||
{
|
||||
rel: 'stylesheet',
|
||||
href: 'https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Inter+Tight:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600;700&display=swap',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
vite: {
|
||||
server: {
|
||||
// Vite 7 added a strict host check; allow the Traefik-fronted hostnames
|
||||
// this site is served on in dev.
|
||||
allowedHosts: ['dezky.local', 'www.dezky.local'],
|
||||
hmr: {
|
||||
protocol: 'wss',
|
||||
clientPort: 443,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "@dezky/website",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"description": "Dezky public marketing site — dezky.com landing pages (Nuxt 4)",
|
||||
"scripts": {
|
||||
"dev": "nuxt dev --host 0.0.0.0 --port 3000",
|
||||
"build": "nuxt build",
|
||||
"generate": "nuxt generate",
|
||||
"preview": "nuxt preview",
|
||||
"typecheck": "nuxt typecheck",
|
||||
"lint": "eslint ."
|
||||
},
|
||||
"dependencies": {
|
||||
"nuxt": "^4.4.6",
|
||||
"vue": "^3.5.0",
|
||||
"vue-router": "^4.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.0.0",
|
||||
"typescript": "^5.6.0",
|
||||
"vue-tsc": "^3.2.6"
|
||||
},
|
||||
"packageManager": "pnpm@9.12.0"
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<script setup lang="ts">
|
||||
// Dezky marketing landing page. Ported from the Claude Design handoff
|
||||
// (Landing Page.html → landing-app.jsx + landing-sections.jsx). Light theme,
|
||||
// Danish default, hero variant A — the production defaults the user landed on.
|
||||
// Section order matches the design exactly.
|
||||
import { useTheme, useCopy, useLang } from '~/composables/useLanding'
|
||||
|
||||
const t = useTheme()
|
||||
const copy = useCopy()
|
||||
const lang = useLang()
|
||||
|
||||
const description = computed(() => copy.value.hero.sub)
|
||||
|
||||
useHead({
|
||||
title: 'dezky · suveræn produktivitet',
|
||||
htmlAttrs: { lang },
|
||||
meta: [
|
||||
{ name: 'description', content: description },
|
||||
],
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :style="{ background: t.bg, color: t.fg, minHeight: '100vh' }">
|
||||
<LandingNav />
|
||||
<LandingHero />
|
||||
<LandingProblem />
|
||||
<LandingSuite />
|
||||
<LandingHowItWorks />
|
||||
<LandingSovereignty />
|
||||
<LandingCompare />
|
||||
<LandingWhitelabel />
|
||||
<LandingStack />
|
||||
<LandingPricing />
|
||||
<LandingFaq />
|
||||
<LandingFinalCta />
|
||||
<LandingFooter />
|
||||
</div>
|
||||
</template>
|
||||
Generated
+7070
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": "./.nuxt/tsconfig.json"
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
// Dezky landing — all copy, da + en. Ported verbatim from the Claude Design
|
||||
// handoff (landing-sections.jsx COPY). Headline arrays mix plain strings with
|
||||
// { hl } objects: the hl phrase gets the signal-yellow highlighter brush.
|
||||
|
||||
export type HeadlinePart = string | { hl: string }
|
||||
|
||||
export const COPY = {
|
||||
da: {
|
||||
nav: { product: 'produkt', security: 'sikkerhed', whitelabel: 'whitelabel', pricing: 'priser', docs: 'docs', login: 'log ind', cta: 'book en demo' },
|
||||
hero: {
|
||||
eyebrow: '// suveræn produktivitet · v1.0',
|
||||
headlineA: ['Den produktivitetssuite,', { hl: 'dine data bliver i Danmark med.' }] as HeadlinePart[],
|
||||
headlineB: ['Værktøjerne du kender.', { hl: 'Suveræniteten du har brug for.' }] as HeadlinePart[],
|
||||
sub: 'Mail, filer, video, chat og login — fuldt integreret, hostet i EU, uden lock-in. Bygget på licensren open source.',
|
||||
cta: 'Book en demo',
|
||||
sub_cta: 'Se hvordan det virker',
|
||||
trust: ['Drevet af open source', 'Hostet i EU', 'Bygget i Danmark'],
|
||||
},
|
||||
problem: {
|
||||
label: '01 — udfordringen',
|
||||
heading: 'Det er blevet juridisk svært at lade som om, at amerikansk cloud er neutral.',
|
||||
p1: 'Schrems II gjorde transatlantiske dataoverførsler juridisk skrøbelige. CLOUD Act betyder, at amerikanske udbydere kan tvinges til at udlevere data, uanset hvor det er hostet.',
|
||||
p2: 'M365 og Workspace ændrer priser og licensvilkår uden varsel. Lock-in er en strategisk risiko, de fleste SMB\'er først opdager, når de prøver at flytte.',
|
||||
},
|
||||
suite: {
|
||||
label: '02 — suiten',
|
||||
heading: 'Alt det du forventer. Intet du ikke vil have.',
|
||||
lede: 'Fem moduler. Ét login. Bygget til at virke sammen — ikke bare leve i samme browser.',
|
||||
cards: [
|
||||
{ name: 'Mail', tag: 'mail · kalender · kontakter', desc: 'Domæne-mail, kalender og kontakter med fuld kompatibilitet til Outlook og Apple Mail via IMAP, CalDAV og CardDAV.' },
|
||||
{ name: 'Drev', tag: 'filer · deling · versioner', desc: 'Filer i skyen med deling, versionering og indbygget redigering i Office-formater. Synk-klient til Mac, Windows og Linux.' },
|
||||
{ name: 'Møder', tag: 'video · skærmdeling', desc: 'Videomøder i browseren. Ingen download. Skærmdeling, optagelse og baggrundsudviskning out-of-the-box.' },
|
||||
{ name: 'Chat', tag: 'kanaler · tråde · søgning', desc: 'Team-chat med tråde, kanaler og fuld historiksøgning. Designet til at læses asynkront, ikke til at afbryde.' },
|
||||
{ name: 'Login & adgang', tag: 'sso · mfa · livscyklus', desc: 'Single sign-on, multifaktor og brugerstyring i ét panel. Tilføj én bruger — de får mail, drev, møder og chat med det samme.' },
|
||||
],
|
||||
},
|
||||
how: {
|
||||
label: '03 — opsætning',
|
||||
heading: 'Tre skridt. Ingen migrationskonsulent.',
|
||||
steps: [
|
||||
{ n: '01', title: 'Tag dit domæne med.', body: 'Vi hjælper med DNS-opsætning. Dine brugere beholder deres @ditfirma.dk-adresser.' },
|
||||
{ n: '02', title: 'Oprett dit team.', body: 'Tilføj brugere én gang — de får mail, filer, chat og video bag single sign-on.' },
|
||||
{ n: '03', title: 'Brug det som du plejer.', body: 'Velkendte web- og mobil-apps. Migration fra M365 og Google Workspace er inkluderet.' },
|
||||
],
|
||||
},
|
||||
sovereignty: {
|
||||
label: '04 — suverænitet',
|
||||
heading: 'Dine data falder under dansk lov. Punktum.',
|
||||
body: ['Dezky kører i EU-datacentre med Tier III-certificering. Krypteret i hvile og i transit. Vi har ingen amerikansk moder, ingen amerikansk datterselskab, og ingen forretningsmæssig grund til at lade os tvinge af en udenlandsk dommer.', 'For virksomheder i regulerede sektorer kan vi tilbyde kundekontrollerede krypteringsnøgler (BYOK), så selv vi ikke kan læse jeres data.'],
|
||||
checks: [
|
||||
['Datajurisdiktion', 'EU · Tyskland'],
|
||||
['Datacentre', 'Hetzner · Tyskland'],
|
||||
['Kryptering', 'AES-256 i hvile · TLS 1.3 i transit'],
|
||||
['BYOK', 'Tilgængelig på Enterprise'],
|
||||
['Audit-log', 'Indbygget · 13 mdr. retention'],
|
||||
['Compliance', 'GDPR · NIS2-readiness · ISO 27001 (på vej)'],
|
||||
],
|
||||
},
|
||||
compare: {
|
||||
label: '05 — sammenligning',
|
||||
heading: 'Dezky vs. den amerikanske standard.',
|
||||
lede: 'Vi er ikke billigere fordi vi er værre. Vi har bare færre forpligtelser overfor amerikanske aktieanalytikere.',
|
||||
cols: ['Dezky', 'Amerikansk hyperscaler'],
|
||||
rows: [
|
||||
['Datajurisdiktion', 'EU · Tyskland', 'USA (CLOUD Act gælder)'],
|
||||
['Licensgrundlag', 'Apache 2.0 / MIT', 'Proprietær'],
|
||||
['Whitelabel', 'Inkluderet', 'Ikke tilgængeligt'],
|
||||
['Pris-forudsigelighed', 'Fastlåst i kontraktperiode', 'Kan ændres ensidigt'],
|
||||
['Dansk support', 'Indbygget', 'Begrænset · ofte engelsk'],
|
||||
['Migrationshjælp', 'Inkluderet', 'DIY eller partner'],
|
||||
],
|
||||
},
|
||||
whitelabel: {
|
||||
label: '06 — for partnere',
|
||||
heading: 'Sælg det som dit eget.',
|
||||
lede: 'MSP\'er og IT-konsulenthuse: kør Dezky under jeres brand. Eget domæne, eget logo, egen prissætning. Vi leverer platformen — I leverer relationen.',
|
||||
bullets: [
|
||||
'Fuldt whitelabel-tema · CSS og logo',
|
||||
'Multi-tenant administration',
|
||||
'Marginer på 30–45 % afhængigt af volumen',
|
||||
'Co-marketing og kundeleads via partnernetværk',
|
||||
],
|
||||
cta: 'Se partnerprogrammet',
|
||||
},
|
||||
stack: {
|
||||
label: '07 — under motorhjelmen',
|
||||
heading: 'Bygget på open source. Verificerbart.',
|
||||
lede: 'Vi skjuler det ikke. Hver komponent er licensren open source — du kan inspicere koden, kompilere den selv, eller flytte din installation et andet sted hen.',
|
||||
rows: [
|
||||
['Mail', 'Stalwart Mail', 'AGPL-3.0', 'stalw.art'],
|
||||
['Filer & drev', 'ownCloud Infinite Scale', 'Apache 2.0', 'owncloud.dev'],
|
||||
['Videomøder', 'Jitsi', 'Apache 2.0', 'jitsi.org'],
|
||||
['Team chat', 'Zulip', 'Apache 2.0', 'zulip.com'],
|
||||
['Identitet & SSO', 'Authentik', 'MIT', 'goauthentik.io'],
|
||||
],
|
||||
},
|
||||
pricing: {
|
||||
label: '08 — priser',
|
||||
heading: 'Forudsigelig pris. Ingen overraskelser.',
|
||||
lede: 'Vi er i et lukket beta-program indtil sommeren 2026. Prisen sættes sammen med vores første kunder — ikke imod dem.',
|
||||
teaser: 'Starter fra',
|
||||
price: '69',
|
||||
unit: 'DKK / bruger / md.',
|
||||
note: 'Endelig prissætning bekræftes ved demo. Volumenrabat fra 25 brugere.',
|
||||
cta: 'Book en demo for priser',
|
||||
},
|
||||
faq: {
|
||||
label: '09 — ofte stillede spørgsmål',
|
||||
heading: 'Det vi bliver spurgt om.',
|
||||
items: [
|
||||
['Hvordan virker migration fra Microsoft 365?', 'Vi flytter mail, kalender, kontakter og OneDrive-filer i baggrunden, mens jeres team arbejder videre. Skifte-dagen er en DNS-opdatering. Typisk forløb er 2–4 uger for 50 brugere.'],
|
||||
['Kan jeg stadig bruge Outlook og Office?', 'Ja. Mail, kalender og kontakter virker via IMAP, CalDAV og CardDAV. Drev-filer åbnes med Office desktop via WebDAV. Vi anbefaler vores web- og mobil-apps som primært valg, men kravet er ikke at I skifter vaner.'],
|
||||
['Hvor er data hosted?', 'Hos Hetzner i Tyskland. Tier III-certificerede datacentre, redundant strøm og netværk, ISO 27001-certificeret operatør. Ingen data forlader EU på noget tidspunkt — ikke for analytics, logs eller support.'],
|
||||
['Hvad sker der hvis Dezky lukker?', 'Hele stakken er open source. I kan eksportere alt og flytte til en anden Dezky-partner. Vores forretningsmodel er drift, ikke gidseltagning.'],
|
||||
['Hvad er jeres SLA?', '99,9 % uptime garanteret på alle planer. 99,95 % på Enterprise. Status-side med real-time data offentligt tilgængelig på status.dezky.com.'],
|
||||
['Hvordan leveres support?', 'Dansk og engelsk. E-mail og chat på alle planer. Telefon-support på Business og Enterprise. Dedikeret onboarding-konsulent ved 50+ brugere.'],
|
||||
],
|
||||
},
|
||||
finalCta: {
|
||||
heading: ['Klar til at få', { hl: 'dine data hjem' }, '?'] as HeadlinePart[],
|
||||
sub: '30 minutters demo. Ingen salgspres. Ingen slides.',
|
||||
cta: 'Book en demo',
|
||||
},
|
||||
footer: {
|
||||
tagline: 'Suveræn produktivitet til danske virksomheder.',
|
||||
legal: { name: 'Dezky ApS', cvr: 'CVR 44 12 89 03', addr: 'Refshalevej 153A · 1432 København K' },
|
||||
cols: [
|
||||
['Produkt', [['Funktioner', '#suite'], ['Sikkerhed', '#sovereignty'], ['Roadmap', '#'], ['Status', '#'], ['Changelog', '#']]],
|
||||
['Selskab', [['Om os', '#'], ['Kunder', '#'], ['Karriere', '#'], ['Presse', '#'], ['Kontakt', '#']]],
|
||||
['Ressourcer', [['Docs', '#'], ['Migrationsguide', '#'], ['Partnere', '#whitelabel'], ['Blog', '#'], ['Brand', '#']]],
|
||||
['Juridisk', [['Privatlivspolitik', '#'], ['Databehandler', '#'], ['Vilkår', '#'], ['SLA', '#'], ['Cookies', '#']]],
|
||||
] as [string, [string, string][]][],
|
||||
copyright: '© 2026 Dezky ApS. Alle rettigheder forbeholdes.',
|
||||
status: 'status · alle systemer kører',
|
||||
},
|
||||
},
|
||||
en: {
|
||||
nav: { product: 'product', security: 'security', whitelabel: 'whitelabel', pricing: 'pricing', docs: 'docs', login: 'log in', cta: 'book a demo' },
|
||||
hero: {
|
||||
eyebrow: '// sovereign productivity · v1.0',
|
||||
headlineA: ['The productivity suite', { hl: 'your data stays in Denmark with.' }] as HeadlinePart[],
|
||||
headlineB: ['Tools you already know.', { hl: 'Sovereignty you actually need.' }] as HeadlinePart[],
|
||||
sub: 'Mail, files, video, chat and SSO — fully integrated, EU-hosted, no lock-in. Built on permissively licensed open source.',
|
||||
cta: 'Book a demo',
|
||||
sub_cta: 'See how it works',
|
||||
trust: ['Powered by open source', 'Hosted in the EU', 'Built in Denmark'],
|
||||
},
|
||||
problem: {
|
||||
label: '01 — the problem',
|
||||
heading: 'It got legally hard to pretend American cloud is neutral.',
|
||||
p1: 'Schrems II made transatlantic data transfers legally fragile. The CLOUD Act lets US providers be compelled to disclose data regardless of where it sits physically.',
|
||||
p2: 'M365 and Workspace change pricing and license terms without notice. Lock-in is a strategic risk most SMBs only discover when they try to leave.',
|
||||
},
|
||||
suite: {
|
||||
label: '02 — the suite',
|
||||
heading: 'Everything you expect. Nothing you don\'t want.',
|
||||
lede: 'Five modules. One login. Built to work together — not just live in the same browser.',
|
||||
cards: [
|
||||
{ name: 'Mail', tag: 'mail · calendar · contacts', desc: 'Domain mail, calendar and contacts with full Outlook and Apple Mail compatibility via IMAP, CalDAV and CardDAV.' },
|
||||
{ name: 'Drive', tag: 'files · sharing · versions', desc: 'Cloud files with sharing, versioning and built-in Office-format editing. Sync clients for Mac, Windows and Linux.' },
|
||||
{ name: 'Meet', tag: 'video · screen share', desc: 'Video meetings in the browser. No download. Screen share, recording and background blur out of the box.' },
|
||||
{ name: 'Chat', tag: 'channels · threads · search', desc: 'Team chat with threads, channels and full history search. Designed to be read async — not to interrupt.' },
|
||||
{ name: 'Identity', tag: 'sso · mfa · lifecycle', desc: 'Single sign-on, multi-factor and user lifecycle in one panel. Add a user once — they get mail, drive, meet and chat instantly.' },
|
||||
],
|
||||
},
|
||||
how: {
|
||||
label: '03 — setup',
|
||||
heading: 'Three steps. No migration consultant.',
|
||||
steps: [
|
||||
{ n: '01', title: 'Bring your domain.', body: 'We help with DNS. Your users keep their @yourcompany.dk addresses.' },
|
||||
{ n: '02', title: 'Provision your team.', body: 'Add users once — they get mail, drive, chat and video behind single sign-on.' },
|
||||
{ n: '03', title: 'Use it like you always have.', body: 'Familiar web and mobile apps. Migration from M365 and Google Workspace is included.' },
|
||||
],
|
||||
},
|
||||
sovereignty: {
|
||||
label: '04 — sovereignty',
|
||||
heading: 'Your data lives under Danish law. Full stop.',
|
||||
body: ['Dezky runs in EU data centers, Tier III certified, encrypted at rest and in transit. We have no US parent, no US subsidiary, and no commercial reason to roll over for a foreign judge.', 'For regulated industries, we offer customer-controlled encryption keys (BYOK) — so even we can\'t read your data.'],
|
||||
checks: [
|
||||
['Data jurisdiction', 'EU · Germany'],
|
||||
['Data centers', 'Hetzner · Germany'],
|
||||
['Encryption', 'AES-256 at rest · TLS 1.3 in transit'],
|
||||
['BYOK', 'Available on Enterprise'],
|
||||
['Audit log', 'Built-in · 13-month retention'],
|
||||
['Compliance', 'GDPR · NIS2-ready · ISO 27001 (in progress)'],
|
||||
],
|
||||
},
|
||||
compare: {
|
||||
label: '05 — comparison',
|
||||
heading: 'Dezky vs. the American default.',
|
||||
lede: 'We\'re not cheaper because we\'re worse. We just have fewer obligations to American equity analysts.',
|
||||
cols: ['Dezky', 'US hyperscaler'],
|
||||
rows: [
|
||||
['Data jurisdiction', 'EU · Germany', 'US (CLOUD Act applies)'],
|
||||
['License basis', 'Apache 2.0 / MIT', 'Proprietary'],
|
||||
['Whitelabel', 'Included', 'Not available'],
|
||||
['Pricing predictability', 'Locked for contract term', 'Subject to unilateral change'],
|
||||
['Danish support', 'Built-in', 'Limited · often English'],
|
||||
['Migration help', 'Included', 'DIY or partner'],
|
||||
],
|
||||
},
|
||||
whitelabel: {
|
||||
label: '06 — for partners',
|
||||
heading: 'Sell it as your own.',
|
||||
lede: 'MSPs and IT consultancies: run Dezky under your brand. Your domain, your logo, your pricing. We provide the platform — you own the relationship.',
|
||||
bullets: [
|
||||
'Full whitelabel theme · CSS and logo',
|
||||
'Multi-tenant administration',
|
||||
'Margins of 30–45% by volume',
|
||||
'Co-marketing and leads via partner network',
|
||||
],
|
||||
cta: 'See the partner program',
|
||||
},
|
||||
stack: {
|
||||
label: '07 — under the hood',
|
||||
heading: 'Built on open source. Verifiable.',
|
||||
lede: 'We don\'t hide it. Every component is permissively licensed — you can inspect the code, build it yourself, or move your installation elsewhere.',
|
||||
rows: [
|
||||
['Mail', 'Stalwart Mail', 'AGPL-3.0', 'stalw.art'],
|
||||
['Files & drive', 'ownCloud Infinite Scale', 'Apache 2.0', 'owncloud.dev'],
|
||||
['Video meetings', 'Jitsi', 'Apache 2.0', 'jitsi.org'],
|
||||
['Team chat', 'Zulip', 'Apache 2.0', 'zulip.com'],
|
||||
['Identity & SSO', 'Authentik', 'MIT', 'goauthentik.io'],
|
||||
],
|
||||
},
|
||||
pricing: {
|
||||
label: '08 — pricing',
|
||||
heading: 'Predictable pricing. No surprises.',
|
||||
lede: 'We\'re in a closed beta until summer 2026. Pricing is set with our first customers — not against them.',
|
||||
teaser: 'Starting at',
|
||||
price: '69',
|
||||
unit: 'DKK / user / mo.',
|
||||
note: 'Final pricing confirmed at demo. Volume discount from 25 users.',
|
||||
cta: 'Book a demo for pricing',
|
||||
},
|
||||
faq: {
|
||||
label: '09 — questions',
|
||||
heading: 'What we get asked.',
|
||||
items: [
|
||||
['How does migration from Microsoft 365 work?', 'We move mail, calendar, contacts and OneDrive files in the background while your team keeps working. Cutover day is a DNS update. Typical timeline is 2–4 weeks for 50 users.'],
|
||||
['Can I still use Outlook and Office?', 'Yes. Mail, calendar and contacts work via IMAP, CalDAV and CardDAV. Drive files open with Office desktop via WebDAV. We recommend our web and mobile apps, but we don\'t require you to change habits.'],
|
||||
['Where is data hosted?', 'With Hetzner in Germany. Tier III certified data centers, redundant power and network, ISO 27001 certified operator. No data leaves the EU at any time — not for analytics, logs or support.'],
|
||||
['What happens if Dezky shuts down?', 'The whole stack is open source. You can export everything and move to another Dezky partner. Our business model is operations — not hostage-taking.'],
|
||||
['What\'s your SLA?', '99.9% uptime guaranteed on all plans. 99.95% on Enterprise. Public real-time status page at status.dezky.com.'],
|
||||
['How is support delivered?', 'Danish and English. Email and chat on all plans. Phone support on Business and Enterprise. Dedicated onboarding consultant from 50 users up.'],
|
||||
],
|
||||
},
|
||||
finalCta: {
|
||||
heading: ['Ready to bring', { hl: 'your data home' }, '?'] as HeadlinePart[],
|
||||
sub: '30-minute demo. No sales pressure. No slides.',
|
||||
cta: 'Book a demo',
|
||||
},
|
||||
footer: {
|
||||
tagline: 'Sovereign productivity for Danish business.',
|
||||
legal: { name: 'Dezky ApS', cvr: 'CVR 44 12 89 03', addr: 'Refshalevej 153A · 1432 Copenhagen K' },
|
||||
cols: [
|
||||
['Product', [['Features', '#suite'], ['Security', '#sovereignty'], ['Roadmap', '#'], ['Status', '#'], ['Changelog', '#']]],
|
||||
['Company', [['About', '#'], ['Customers', '#'], ['Careers', '#'], ['Press', '#'], ['Contact', '#']]],
|
||||
['Resources', [['Docs', '#'], ['Migration guide', '#'], ['Partners', '#whitelabel'], ['Blog', '#'], ['Brand', '#']]],
|
||||
['Legal', [['Privacy', '#'], ['DPA', '#'], ['Terms', '#'], ['SLA', '#'], ['Cookies', '#']]],
|
||||
] as [string, [string, string][]][],
|
||||
copyright: '© 2026 Dezky ApS. All rights reserved.',
|
||||
status: 'status · all systems operational',
|
||||
},
|
||||
},
|
||||
} as const
|
||||
|
||||
export type Lang = keyof typeof COPY
|
||||
@@ -0,0 +1,79 @@
|
||||
// Dezky landing — brand palette, locked logo geometry, and theme helper.
|
||||
// Ported from the Claude Design handoff (landing-sections.jsx). The marketing
|
||||
// site is self-contained colour-wise: it does NOT consume the app's tokens.css
|
||||
// dark/light variables — it threads a `theme` object through components exactly
|
||||
// like the prototype did, so the handoff stays pixel-faithful.
|
||||
|
||||
export const C = {
|
||||
carbon: '#0A0A0A',
|
||||
signal: '#D4FF3A',
|
||||
bone: '#F4F3EE',
|
||||
slate: '#3D3D38',
|
||||
fog: '#E6E4DC',
|
||||
paper: '#FAFAF7',
|
||||
ok: '#1F8A5B',
|
||||
warn: '#E89A1F',
|
||||
bad: '#E23030',
|
||||
} as const
|
||||
|
||||
// Locked Node-mark geometry — the values the user dialled in via Tweaks and
|
||||
// then froze (chat1). Every NodeMark usage in the design passed exactly these.
|
||||
export const LOCKED = {
|
||||
bowlR: 14,
|
||||
stemW: 7,
|
||||
contR: 22,
|
||||
dStyle: 'donut',
|
||||
dotPos: 'corner',
|
||||
dotR: 4,
|
||||
} as const
|
||||
|
||||
export interface DezkyTheme {
|
||||
bg: string
|
||||
bgAlt: string
|
||||
surface: string
|
||||
surfaceAlt: string
|
||||
border: string
|
||||
borderStrong: string
|
||||
fg: string
|
||||
fgMuted: string
|
||||
fgDim: string
|
||||
invert: string
|
||||
invertFg: string
|
||||
signal: string
|
||||
}
|
||||
|
||||
export function makeTheme(dark: boolean): DezkyTheme {
|
||||
return dark
|
||||
? {
|
||||
bg: '#0A0A0A',
|
||||
bgAlt: '#121211',
|
||||
surface: '#171715',
|
||||
surfaceAlt: '#1F1F1C',
|
||||
border: 'rgba(255,255,255,0.08)',
|
||||
borderStrong: 'rgba(255,255,255,0.18)',
|
||||
fg: '#F4F3EE',
|
||||
fgMuted: 'rgba(244,243,238,0.62)',
|
||||
fgDim: 'rgba(244,243,238,0.42)',
|
||||
invert: '#F4F3EE',
|
||||
invertFg: '#0A0A0A',
|
||||
signal: C.signal,
|
||||
}
|
||||
: {
|
||||
bg: C.paper,
|
||||
bgAlt: C.bone,
|
||||
surface: '#FFFFFF',
|
||||
surfaceAlt: C.bone,
|
||||
border: C.fog,
|
||||
borderStrong: 'rgba(10,10,10,0.14)',
|
||||
fg: C.carbon,
|
||||
fgMuted: 'rgba(10,10,10,0.62)',
|
||||
fgDim: 'rgba(10,10,10,0.42)',
|
||||
invert: C.carbon,
|
||||
invertFg: C.bone,
|
||||
signal: C.signal,
|
||||
}
|
||||
}
|
||||
|
||||
// The destination the nav/login CTA points at. Production is app.dezky.com;
|
||||
// locally the portal runs at app.dezky.local.
|
||||
export const APP_URL = 'https://app.dezky.local'
|
||||
@@ -130,6 +130,121 @@ EOF
|
||||
|
||||
Note: Stalwart's OIDC integration is configured in `infrastructure/docker-compose/configs/stalwart/config.toml`. For local dev with internal users, OIDC is optional.
|
||||
|
||||
### 3.5 Operator application (blueprint-managed)
|
||||
|
||||
Unlike the providers above, the **operator** application is **not** created by
|
||||
hand — it's provisioned by an Authentik blueprint that ships in the repo:
|
||||
|
||||
```
|
||||
infrastructure/docker-compose/configs/authentik/blueprints/operator-application.yaml
|
||||
```
|
||||
|
||||
The `authentik-worker` mounts the blueprints directory and applies the file
|
||||
automatically on boot. It provisions, in one place:
|
||||
|
||||
- the `dezky-operator` OAuth2 provider (mirrors the portal provider's shape:
|
||||
implicit-consent flow, default self-signed signing key, openid/email/profile
|
||||
scope mappings, hashed sub, per-provider issuer)
|
||||
- the `dezky-operator` application (slug `dezky-operator`)
|
||||
- the `dezky-platform-admins` group
|
||||
- an expression policy `operator-require-platform-admin` and its binding to the
|
||||
application — **this is what restricts who can log into the operator portal**
|
||||
(see [Operator portal isolation](#operator-portal-isolation-three-layers)).
|
||||
|
||||
**Create-only semantics.** The provider and application use Authentik's
|
||||
`state: created` — *create if absent, never update if present*. So:
|
||||
|
||||
- On a **fresh** environment, the blueprint builds the whole operator app for
|
||||
you. No manual clicks.
|
||||
- On an **existing** environment where the operator provider was made by hand,
|
||||
the blueprint leaves the live provider (its client secret, its scope
|
||||
mappings) **untouched**, and only adds/reconciles the group, policy, and
|
||||
binding.
|
||||
|
||||
The provider/app are matched by `client_id` / `slug` (globally unique, stable),
|
||||
so create-only reliably recognizes the existing objects regardless of their
|
||||
display name.
|
||||
|
||||
**Env it reads.** On a fresh provision the blueprint sets the provider's
|
||||
client credentials from these vars, which the `authentik-server` and
|
||||
`authentik-worker` containers receive (see `docker-compose.yml`):
|
||||
|
||||
```
|
||||
OPERATOR_OIDC_CLIENT_ID=dezky-operator
|
||||
OPERATOR_OIDC_CLIENT_SECRET=<must match what the operator container uses>
|
||||
```
|
||||
|
||||
**On a fresh environment, set `OPERATOR_OIDC_CLIENT_SECRET` in `.env` BEFORE
|
||||
first boot** (`.env.example` ships a placeholder with an `openssl rand -hex 64`
|
||||
note). This is the inverse of the portal flow: the portal's secret is
|
||||
*generated by Authentik* and copied out into `.env`, whereas the operator
|
||||
secret is *supplied by you* and consumed by the blueprint when it creates the
|
||||
provider. The operator container authenticates with the same
|
||||
`OPERATOR_OIDC_CLIENT_SECRET` (passed in as its `NUXT_OIDC_CLIENT_SECRET`), so
|
||||
the two only agree if the value is present before the blueprint runs — leave it
|
||||
empty and the blueprint provisions the provider with an empty secret and the
|
||||
OIDC handshake fails.
|
||||
|
||||
On your existing environment these are already populated in `.env` (and the
|
||||
blueprint's `state: created` won't touch the live provider anyway), so nothing
|
||||
changes for you.
|
||||
|
||||
The resulting issuer URL is `https://auth.dezky.local/application/o/dezky-operator/`.
|
||||
platform-api accepts both the portal and operator issuers/audiences — see
|
||||
`AUTHENTIK_ISSUER` / `AUTHENTIK_AUDIENCE` in `docker-compose.yml`.
|
||||
|
||||
**Apply / verify:**
|
||||
|
||||
```bash
|
||||
# Worker picks up the mount + env and applies the blueprint
|
||||
docker compose -f infrastructure/docker-compose/docker-compose.yml up -d authentik-worker
|
||||
|
||||
# Watch it apply (look for the blueprint name / any !Find that failed to resolve)
|
||||
docker compose -f infrastructure/docker-compose/docker-compose.yml logs authentik-worker | grep -i blueprint
|
||||
```
|
||||
|
||||
Then in the UI: **Applications → dezky-operator → Bindings** should list
|
||||
`operator-require-platform-admin` (enabled).
|
||||
|
||||
## Operator portal isolation (three layers)
|
||||
|
||||
The operator portal (`operator.dezky.local`) carries elevated, cross-tenant
|
||||
privilege. A partner or a tenant admin must **never** be able to log into it.
|
||||
That guarantee is defense-in-depth across three independent layers — a single
|
||||
misconfiguration in any one of them does not open the door:
|
||||
|
||||
1. **IdP (Authentik) — stops the login itself.** The
|
||||
`operator-require-platform-admin` policy bound to the `dezky-operator`
|
||||
application (§3.5) is evaluated *before* Authentik issues the authorization.
|
||||
A user who is not in `dezky-platform-admins` is denied during the OIDC flow
|
||||
and never reaches the app. This is the primary gate.
|
||||
|
||||
2. **Operator app — refuses to render for a non-admin.** The global middleware
|
||||
`apps/operator/middleware/require-platform-admin.global.ts` resolves the
|
||||
signed-in user's profile and requires `platformAdmin=true`. A non-admin who
|
||||
somehow holds a valid operator session is routed to
|
||||
`apps/operator/pages/not-authorized.vue`, which triggers a **full sign-out**
|
||||
(local nuxt-oidc-auth session **and** the Authentik IdP session) — never
|
||||
leaving a live operator session parked on a shared workstation.
|
||||
|
||||
3. **platform-api — refuses the data.** `OperatorGuard`
|
||||
(`services/platform-api/src/auth/operator.guard.ts`) requires **both** a
|
||||
`dezky-operator` token audience **and** `platformAdmin=true` resolved from
|
||||
the DB on every request. The DB check means revoking someone's admin status
|
||||
takes effect immediately, without waiting for their token to expire.
|
||||
|
||||
`dezky-platform-admins` is the single source of truth tying these together:
|
||||
Authentik gates membership (layer 1), and platform-api maps that group to the
|
||||
`platformAdmin` flag it enforces (layers 2 and 3).
|
||||
|
||||
**Partner ⇄ customer note.** The partner surface lives *inside* the shared
|
||||
`dezky-portal` OAuth client (tenant admins authenticate there legitimately), so
|
||||
it cannot have an IdP-level gate like the operator app. Its isolation is the
|
||||
portal's fail-closed `/partner/*` route middleware plus platform-api's
|
||||
per-endpoint `partnerId` checks. If the partner surface ever needs the same
|
||||
IdP-level isolation as operator, it must become its own OAuth client/application
|
||||
(at which point this same blueprint pattern applies to it).
|
||||
|
||||
## 4. Get the API token for platform-api
|
||||
|
||||
platform-api needs to call Authentik's API to create tenants, users, and applications. `.env` holds a pre-generated value in `AUTHENTIK_BOOTSTRAP_TOKEN`, but Authentik 2025.10 does **not** materialize that env var into a usable API token on first boot. You need to create the token once and bind it to `akadmin`.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user