Compare commits
19 Commits
9c08973e46
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| ed660b9a81 | |||
| 41af70d57b | |||
| bf183fce07 | |||
| 6d82502e7b | |||
| 2e400d86c5 | |||
| 0a35d9deb6 | |||
| 4c57d41350 | |||
| a0f79ab852 | |||
| 4c3c47cc87 | |||
| 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,7 +245,8 @@ const sortedPrices = computed<PriceRow[]>(() =>
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<Card :pad="0">
|
||||
<div class="stage">
|
||||
<Card :pad="0">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -290,6 +353,38 @@ const sortedPrices = computed<PriceRow[]>(() =>
|
||||
</UiButton>
|
||||
</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>
|
||||
|
||||
@@ -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,16 +33,33 @@ export function useMe() {
|
||||
|
||||
async function fetchMe(force = false): Promise<MeResponse | null> {
|
||||
if (state.value && !force) return state.value
|
||||
// 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 {
|
||||
// useRequestFetch on SSR forwards the incoming request's headers
|
||||
// (including the nuxt-oidc-auth session cookie) when calling the
|
||||
// Nitro route. Bare $fetch on SSR has no cookie context, so /api/me
|
||||
// would 401, the middleware would skip the redirect, and the end-user
|
||||
// page would flash before client-side rehydration finally redirects.
|
||||
const fetcher = useRequestFetch()
|
||||
state.value = await fetcher<MeResponse>('/api/me')
|
||||
} catch {
|
||||
state.value = null
|
||||
} 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',
|
||||
|
||||
+212
-238
@@ -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 viewInvoice(id: string) {
|
||||
toast.info('Opening 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 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>
|
||||
</div>
|
||||
<RecordRow v-for="(r, i) in recordsOfKind('spf')" :key="'spf' + i" :rec="r" />
|
||||
</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>
|
||||
</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>
|
||||
<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="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">
|
||||
<Eyebrow>Sessions</Eyebrow>
|
||||
<div class="card-title">Session policy</div>
|
||||
<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">
|
||||
<Eyebrow>Network</Eyebrow>
|
||||
<div class="card-title">Geo-fencing & allow-lists</div>
|
||||
<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">
|
||||
<Eyebrow>SSO</Eyebrow>
|
||||
<div class="card-title">dezky as identity provider</div>
|
||||
</div>
|
||||
<div class="sso-intro">
|
||||
Connect external applications via OIDC or SAML. dezky's Authentik instance is the source of truth for identity.
|
||||
<div 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>
|
||||
<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,52 +62,69 @@ 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>
|
||||
<div class="progress" style="height: 10px;">
|
||||
<span style="width: 64%" />
|
||||
</div>
|
||||
<div class="progress-legend">
|
||||
<span>1.4 TB used</span>
|
||||
<span>820 GB free</span>
|
||||
<div 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>
|
||||
|
||||
<div class="top-block">
|
||||
<Eyebrow>Top users</Eyebrow>
|
||||
<div class="top-list">
|
||||
<div v-for="u in topUsers" :key="u.id" class="top-row">
|
||||
<div class="user-cell">
|
||||
<Avatar :name="u.name" :size="22" />
|
||||
<span>{{ u.name }}</span>
|
||||
<template v-if="available">
|
||||
<div class="progress" style="height: 10px;">
|
||||
<span :style="{ width: usedPct + '%' }" />
|
||||
</div>
|
||||
<div class="progress-legend">
|
||||
<span>{{ formatBytes(storage!.usedBytes) }} used</span>
|
||||
<span>{{ formatBytes(storage!.freeBytes) }} free</span>
|
||||
</div>
|
||||
|
||||
<div class="top-block">
|
||||
<Eyebrow>Top users</Eyebrow>
|
||||
<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.usedBytes / maxUserBytes) * 100) + '%' }" />
|
||||
</div>
|
||||
<Mono>{{ formatBytes(u.usedBytes) }}</Mono>
|
||||
</div>
|
||||
<div class="progress thin"><span :style="{ width: Math.min(100, (u.storage / 50) * 100) + '%' }" /></div>
|
||||
<Mono>{{ u.storage }} GB</Mono>
|
||||
</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>
|
||||
<div class="progress thinner"><span :style="{ width: p + '%', background: c }" /></div>
|
||||
<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>
|
||||
<div v-else class="empty">
|
||||
<Mono dim>No data available.</Mono>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</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>
|
||||
|
||||
+422
-458
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,3 @@
|
||||
# This app uses pnpm (pnpm-lock.yaml). Ignore stray npm lockfiles so an
|
||||
# accidental `npm install` doesn't get committed.
|
||||
package-lock.json
|
||||
@@ -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,19 @@
|
||||
<script setup lang="ts">
|
||||
// Placeholder body for not-yet-built sub-pages. Shows the page title under a
|
||||
// "coming soon" eyebrow, an explanatory line, and a demo CTA. Legal pages pass
|
||||
// the legal-specific body instead of the generic one.
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useCopy, goToSection } from '~/composables/useLanding'
|
||||
|
||||
defineProps<{ title: string, body: string }>()
|
||||
|
||||
const copy = useCopy()
|
||||
const route = useRoute()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<LandingPageHeader :label="copy.pages.comingSoonKicker" :title="title" :intro="body" />
|
||||
<LandingContainer pad="48px 64px 160px">
|
||||
<LandingBtn variant="primary" size="lg" @click="goToSection('#final-cta', route.path)">{{ copy.pages.ctaDemo }} →</LandingBtn>
|
||||
</LandingContainer>
|
||||
</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,64 @@
|
||||
<script setup lang="ts">
|
||||
// Footer — lockup + tagline + legal, four link columns, status row.
|
||||
// Ported from landing-sections.jsx Footer (light mode). Links are real routes
|
||||
// now; "/#suite"-style section links smooth-scroll on the homepage and route
|
||||
// home + scroll from a sub-page.
|
||||
import { useRoute } from 'vue-router'
|
||||
import { C } from '~/utils/landingTokens'
|
||||
import { useTheme, useCopy, scrollToAnchor } from '~/composables/useLanding'
|
||||
const t = useTheme()
|
||||
const copy = useCopy()
|
||||
const route = useRoute()
|
||||
|
||||
function onLink(e: MouseEvent, href: string) {
|
||||
// In-page section link ("/#suite"): smooth-scroll in place when already on
|
||||
// the homepage. Off-page, let NuxtLink route to "/#suite" — index.vue scrolls
|
||||
// to the hash on mount.
|
||||
if (href.includes('#') && route.path === '/') {
|
||||
e.preventDefault()
|
||||
scrollToAnchor(href.slice(href.indexOf('#')))
|
||||
}
|
||||
}
|
||||
</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' }">
|
||||
<NuxtLink
|
||||
v-for="(link, j) in col[1]" :key="j" :to="link[1]"
|
||||
:style="{ fontFamily: '\'Inter\', sans-serif', fontSize: '14px', color: 'rgba(10,10,10,0.78)' }"
|
||||
@click="onLink($event, link[1])"
|
||||
>{{ link[0] }}</NuxtLink>
|
||||
</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,77 @@
|
||||
<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 { useRoute } from 'vue-router'
|
||||
import { APP_URL } from '~/utils/landingTokens'
|
||||
import { useTheme, useCopy, useLang, toggleLang, scrollToAnchor, goToSection } from '~/composables/useLanding'
|
||||
|
||||
const t = useTheme()
|
||||
const copy = useCopy()
|
||||
const lang = useLang()
|
||||
const route = useRoute()
|
||||
|
||||
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: '/docs' },
|
||||
])
|
||||
|
||||
function onLogo(e: MouseEvent) {
|
||||
// On the homepage, scroll to top in place; from a sub-page let NuxtLink route home.
|
||||
if (route.path === '/') {
|
||||
e.preventDefault()
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
}
|
||||
}
|
||||
|
||||
function onNav(e: MouseEvent, href: string) {
|
||||
if (href.includes('#') && route.path === '/') {
|
||||
e.preventDefault()
|
||||
scrollToAnchor(href.slice(href.indexOf('#')))
|
||||
}
|
||||
}
|
||||
</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',
|
||||
}">
|
||||
<NuxtLink to="/" :style="{ display: 'flex', alignItems: 'center', gap: '12px', cursor: 'pointer' }" @click="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>
|
||||
</NuxtLink>
|
||||
|
||||
<nav :style="{ display: 'flex', alignItems: 'center', gap: '36px' }">
|
||||
<NuxtLink
|
||||
v-for="(it, i) in items" :key="i" :to="it.href"
|
||||
:style="{ fontFamily: '\'Inter\', sans-serif', fontSize: '14px', color: t.fgMuted, letterSpacing: '-0.005em' }"
|
||||
@click="onNav($event, it.href)"
|
||||
>{{ it.label }}</NuxtLink>
|
||||
</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="goToSection('#final-cta', route.path)">{{ copy.nav.cta }} →</LandingBtn>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
@@ -0,0 +1,36 @@
|
||||
<script setup lang="ts">
|
||||
// Shared header for sub-pages: back link, mono eyebrow with signal dot, big
|
||||
// title, optional intro. Mirrors the hero/section typography so sub-pages feel
|
||||
// like the same site.
|
||||
import { useTheme, useCopy } from '~/composables/useLanding'
|
||||
|
||||
defineProps<{ label: string, title: string, intro?: string }>()
|
||||
|
||||
const t = useTheme()
|
||||
const copy = useCopy()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<LandingContainer pad="120px 64px 0">
|
||||
<NuxtLink
|
||||
to="/"
|
||||
:style="{ fontFamily: '\'JetBrains Mono\', monospace', fontSize: '12px', color: t.fgMuted, letterSpacing: '0.04em', display: 'inline-flex', alignItems: 'center', gap: '8px' }"
|
||||
>← {{ copy.pages.back }}</NuxtLink>
|
||||
|
||||
<div :style="{ display: 'flex', alignItems: 'center', gap: '12px', marginTop: '48px', marginBottom: '24px' }">
|
||||
<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.08em', textTransform: 'uppercase' }">{{ label }}</span>
|
||||
</div>
|
||||
|
||||
<h1 :style="{
|
||||
fontFamily: '\'Inter Tight\', \'Inter\', sans-serif', fontWeight: 600,
|
||||
fontSize: 'clamp(40px, 5.4vw, 76px)', letterSpacing: '-0.035em', lineHeight: 1.0,
|
||||
margin: 0, textWrap: 'balance', color: t.fg, maxWidth: '900px',
|
||||
}">{{ title }}</h1>
|
||||
|
||||
<p
|
||||
v-if="intro"
|
||||
:style="{ marginTop: '32px', maxWidth: '620px', fontFamily: '\'Inter\', sans-serif', fontSize: '20px', lineHeight: 1.5, color: t.fgMuted, textWrap: 'pretty' }"
|
||||
>{{ intro }}</p>
|
||||
</LandingContainer>
|
||||
</template>
|
||||
@@ -0,0 +1,85 @@
|
||||
<script setup lang="ts">
|
||||
// Interactive reseller margin calculator. Margin is PROGRESSIVE (like tax
|
||||
// brackets): the first 500 users earn 15%, users 501–1000 earn 30%, and every
|
||||
// user beyond 1000 earns 40% — all off the 49 kr/user/mo list price. The
|
||||
// per-bracket rates come from the tier copy so they never drift.
|
||||
import { ref, computed } from 'vue'
|
||||
import { useTheme, useCopy, useLang } from '~/composables/useLanding'
|
||||
|
||||
const t = useTheme()
|
||||
const copy = useCopy()
|
||||
const lang = useLang()
|
||||
const c = computed(() => copy.value.pages.partners.calc)
|
||||
|
||||
const LIST_PRICE = 49 // DKK per user per month
|
||||
|
||||
const seats = ref(600)
|
||||
|
||||
const nf = computed(() => new Intl.NumberFormat(lang.value === 'en' ? 'en-US' : 'da-DK'))
|
||||
|
||||
// Progressive brackets. `lo`/`hi` are the user-count bounds; pct is read from
|
||||
// the matching tier so the calculator and tier cards stay in sync.
|
||||
const brackets = computed(() => {
|
||||
const items = copy.value.pages.partners.tiers.items
|
||||
const f = nf.value
|
||||
return [
|
||||
{ lo: 0, hi: 500, pct: parseInt(String(items[0][2]), 10) || 0, label: `0–${f.format(500)}` },
|
||||
{ lo: 500, hi: 1000, pct: parseInt(String(items[1][2]), 10) || 0, label: `${f.format(501)}–${f.format(1000)}` },
|
||||
{ lo: 1000, hi: Infinity, pct: parseInt(String(items[2][2]), 10) || 0, label: `${f.format(1001)}+` },
|
||||
]
|
||||
})
|
||||
|
||||
function usersIn(b: { lo: number, hi: number }) {
|
||||
return Math.max(0, Math.min(seats.value, b.hi) - b.lo)
|
||||
}
|
||||
|
||||
const monthly = computed(() =>
|
||||
Math.round(brackets.value.reduce((sum, b) => sum + usersIn(b) * LIST_PRICE * (b.pct / 100), 0)),
|
||||
)
|
||||
const annual = computed(() => monthly.value * 12)
|
||||
|
||||
const dkk = new Intl.NumberFormat('da-DK', { style: 'currency', currency: 'DKK', maximumFractionDigits: 0 })
|
||||
const fmtMonthly = computed(() => dkk.format(monthly.value))
|
||||
const fmtAnnual = computed(() => dkk.format(annual.value))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :style="{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0', border: `1px solid ${t.border}`, borderRadius: '4px', overflow: 'hidden' }">
|
||||
<!-- Controls -->
|
||||
<div :style="{ padding: '36px', background: t.surface, display: 'flex', flexDirection: 'column', gap: '28px', justifyContent: 'center' }">
|
||||
<div>
|
||||
<div :style="{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: '12px' }">
|
||||
<span :style="{ fontFamily: '\'JetBrains Mono\', monospace', fontSize: '11px', color: t.fgMuted, letterSpacing: '0.06em', textTransform: 'uppercase' }">{{ c.seatsLabel }}</span>
|
||||
<span :style="{ fontFamily: '\'Inter Tight\', sans-serif', fontWeight: 600, fontSize: '20px', color: t.fg }">{{ nf.format(seats) }}</span>
|
||||
</div>
|
||||
<input v-model.number="seats" type="range" min="10" max="2000" step="10" :style="{ width: '100%', accentColor: t.signal, cursor: 'pointer' }" >
|
||||
</div>
|
||||
|
||||
<!-- Progressive bracket breakdown -->
|
||||
<div>
|
||||
<div :style="{ fontFamily: '\'JetBrains Mono\', monospace', fontSize: '11px', color: t.fgMuted, letterSpacing: '0.06em', textTransform: 'uppercase', marginBottom: '14px' }">{{ c.marginLabel }}</div>
|
||||
<div :style="{ display: 'flex', flexDirection: 'column', gap: '10px' }">
|
||||
<div
|
||||
v-for="(b, i) in brackets" :key="i"
|
||||
:style="{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', opacity: usersIn(b) > 0 ? 1 : 0.4 }"
|
||||
>
|
||||
<span :style="{ display: 'flex', alignItems: 'center', gap: '9px', fontFamily: '\'JetBrains Mono\', monospace', fontSize: '12px', color: t.fg }">
|
||||
<span :style="{ width: '5px', height: '5px', borderRadius: '999px', background: usersIn(b) > 0 ? t.signal : t.fgDim }" />
|
||||
{{ b.label }}
|
||||
</span>
|
||||
<span :style="{ fontFamily: '\'Inter Tight\', sans-serif', fontWeight: 600, fontSize: '15px', color: t.fg }">{{ b.pct }} %</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Output -->
|
||||
<div :style="{ padding: '36px', background: t.bgAlt, borderLeft: `1px solid ${t.border}`, display: 'flex', flexDirection: 'column', justifyContent: 'center' }">
|
||||
<div :style="{ fontFamily: '\'JetBrains Mono\', monospace', fontSize: '11px', color: t.fgMuted, letterSpacing: '0.08em', textTransform: 'uppercase' }">{{ c.monthlyLabel }}</div>
|
||||
<div :style="{ fontFamily: '\'Inter Tight\', sans-serif', fontWeight: 600, fontSize: 'clamp(40px, 5vw, 60px)', letterSpacing: '-0.03em', lineHeight: 1.0, color: t.fg, marginTop: '8px' }">{{ fmtMonthly }}</div>
|
||||
<div :style="{ marginTop: '20px', paddingTop: '20px', borderTop: `1px solid ${t.border}`, fontFamily: '\'Inter\', sans-serif', fontSize: '15px', color: t.fgMuted }">
|
||||
{{ c.annualLabel }} <span :style="{ color: t.fg, fontWeight: 600 }">{{ fmtAnnual }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</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.eu / 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,35 @@
|
||||
<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: '1.1fr 1.6fr 1.3fr',
|
||||
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: '\'Inter Tight\', sans-serif', fontSize: '20px', fontWeight: 600, color: t.fg, letterSpacing: '-0.015em' }">{{ row[0] }}</div>
|
||||
<div :style="{ fontFamily: '\'JetBrains Mono\', monospace', fontSize: '12.5px', color: t.fg, letterSpacing: '0.02em' }">{{ row[1] }}</div>
|
||||
<div :style="{ color: t.fgMuted }">{{ row[2] }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</LandingContainer>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,51 @@
|
||||
<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',
|
||||
}"
|
||||
>
|
||||
<div :style="{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: '12px' }">
|
||||
<span :style="{ opacity: card.soon ? 0.5 : 1 }"><LandingModuleGlyph :name="card.name" /></span>
|
||||
<span
|
||||
v-if="card.soon"
|
||||
:style="{
|
||||
display: 'inline-flex', alignItems: 'center', gap: '6px', flexShrink: 0,
|
||||
fontFamily: '\'JetBrains Mono\', monospace', fontSize: '9px', letterSpacing: '0.1em', textTransform: 'uppercase',
|
||||
color: t.fgMuted, border: `1px solid ${t.border}`, borderRadius: '999px', padding: '4px 9px', whiteSpace: 'nowrap',
|
||||
}"
|
||||
>
|
||||
<span :style="{ width: '5px', height: '5px', borderRadius: '999px', background: t.signal }" />
|
||||
{{ copy.suite.soonLabel }}
|
||||
</span>
|
||||
</div>
|
||||
<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,67 @@
|
||||
<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'))
|
||||
|
||||
// Per-card presentation (accent / placeholder). Name + subtitle come from copy
|
||||
// so the demo tenants translate with the rest of the page; the visual style is
|
||||
// matched to each card by position.
|
||||
const cardStyles = [
|
||||
{ accent: '#D6502A', placeholder: false },
|
||||
{ accent: '#3956C8', placeholder: false },
|
||||
] as const
|
||||
|
||||
const partnerCards = computed(() =>
|
||||
copy.value.whitelabel.partners.map((p, i) => ({
|
||||
...p,
|
||||
accent: cardStyles[i]?.accent ?? t.value.signal,
|
||||
placeholder: cardStyles[i]?.placeholder ?? true,
|
||||
})),
|
||||
)
|
||||
</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" @click="navigateTo('/partners')">{{ copy.whitelabel.cta }} →</LandingBtn>
|
||||
</div>
|
||||
</div>
|
||||
<div :style="{ display: 'flex', flexDirection: 'column', gap: '16px' }">
|
||||
<LandingPartnerCard
|
||||
v-for="(p, i) in partnerCards"
|
||||
:key="i"
|
||||
:fg="t.fg"
|
||||
:bg="cardBg"
|
||||
:border="t.border"
|
||||
:accent="p.accent"
|
||||
:name="p.name"
|
||||
:subtitle="p.subtitle"
|
||||
:placeholder="p.placeholder"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</LandingContainer>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,51 @@
|
||||
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)
|
||||
}
|
||||
|
||||
// Navigate to a homepage section from anywhere. Footer/Nav links use the form
|
||||
// "/#suite": when already on the homepage we smooth-scroll in place; from a
|
||||
// sub-page we route home and index.vue scrolls to the hash on mount. Accepts
|
||||
// either "/#suite" or "#suite". Returns true if it handled the click (so the
|
||||
// caller can preventDefault), false to let normal navigation proceed.
|
||||
export function goToSection(href: string, currentPath: string): boolean {
|
||||
const hash = href.slice(href.indexOf('#'))
|
||||
if (currentPath === '/') {
|
||||
scrollToAnchor(hash)
|
||||
return true
|
||||
}
|
||||
navigateTo(`/${hash}`)
|
||||
return true
|
||||
}
|
||||
@@ -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,21 @@
|
||||
<script setup lang="ts">
|
||||
// Sub-page shell: same Nav + Footer chrome as the landing page, with a content
|
||||
// slot in between. Used by every footer-linked page so they share the header,
|
||||
// footer, theme and language toggle.
|
||||
import { useTheme, useLang } from '~/composables/useLanding'
|
||||
|
||||
const t = useTheme()
|
||||
const lang = useLang()
|
||||
|
||||
useHead({ htmlAttrs: { lang } })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :style="{ background: t.bg, color: t.fg, minHeight: '100vh', display: 'flex', flexDirection: 'column' }">
|
||||
<LandingNav />
|
||||
<main :style="{ flex: 1 }">
|
||||
<slot />
|
||||
</main>
|
||||
<LandingFooter />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,53 @@
|
||||
// Nuxt 4 configuration for the Dezky public marketing site (dezky.eu).
|
||||
//
|
||||
// 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.eu landing pages (Nuxt 4)",
|
||||
"scripts": {
|
||||
"dev": "TMPDIR=/tmp 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.7",
|
||||
"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,46 @@
|
||||
<script setup lang="ts">
|
||||
// Catch-all for the footer's not-yet-built pages. Each known slug renders a
|
||||
// shared "coming soon" body with its localized title; legal slugs get the
|
||||
// legal-specific body. Unknown slugs 404 (explicit pages like /about win over
|
||||
// this dynamic route in Nuxt's resolver).
|
||||
import { computed } from 'vue'
|
||||
import { useRoute, useCopy } from '#imports'
|
||||
|
||||
definePageMeta({ layout: 'page' })
|
||||
|
||||
// Known stub slugs and whether they're legal pages. Keys must match the
|
||||
// `pages.stubs` keys in landingCopy.ts.
|
||||
const STUBS: Record<string, { legal: boolean }> = {
|
||||
customers: { legal: false },
|
||||
careers: { legal: false },
|
||||
press: { legal: false },
|
||||
status: { legal: false },
|
||||
docs: { legal: false },
|
||||
blog: { legal: false },
|
||||
privacy: { legal: true },
|
||||
dpa: { legal: true },
|
||||
terms: { legal: true },
|
||||
sla: { legal: true },
|
||||
cookies: { legal: true },
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
const copy = useCopy()
|
||||
|
||||
const slug = computed(() => String(route.params.slug))
|
||||
|
||||
const stub = computed(() => STUBS[slug.value])
|
||||
if (!stub.value) {
|
||||
throw createError({ statusCode: 404, statusMessage: 'Page not found', fatal: true })
|
||||
}
|
||||
|
||||
type StubKey = keyof typeof copy.value.pages.stubs
|
||||
const title = computed(() => copy.value.pages.stubs[slug.value as StubKey])
|
||||
const body = computed(() => (stub.value!.legal ? copy.value.pages.legalBody : copy.value.pages.comingSoonBody))
|
||||
|
||||
useHead({ title: () => `${title.value} · dezky` })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<LandingComingSoon :title="title" :body="body" />
|
||||
</template>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user