feat: portal redesign, pricing catalog, partner-staff invites
- portal: new admin/ and partner/ surfaces with full component library (AppLauncher, Avatar, Badge, Card, Modal, Tabs, etc.), composables, layouts, partner-routing middleware, and supporting server APIs - pricing: Price schema/module with operator CRUD, pricing.vue catalog UI, Subscription extended with cycle/currency/perSeatAmount/seats snapshots for stable MRR aggregation - partner staff: User.partnerId, invite-partner-user DTO and flow, /partners/:slug/users endpoints, InvitePartnerUserModal, shared dezky-partner-staff Authentik group - /me: partner-aware endpoint returning user + partner context so portal can route between end-user and partner-admin surfaces - tenant: seats field for portfolio displays and future MRR calculations - operator: pricing page, signed-out page, useMe/useToast composables, ToastStack
This commit is contained in:
@@ -0,0 +1,569 @@
|
||||
<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.
|
||||
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
const paymentOpen = ref(false)
|
||||
const detailsOpen = ref(false)
|
||||
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
|
||||
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 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' },
|
||||
]
|
||||
|
||||
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 confirmPause() {
|
||||
pauseOpen.value = false
|
||||
toast.ok('Subscription paused', 'Resumes automatically on 28 Aug 2026')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<PageHeader
|
||||
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">
|
||||
<!-- Hero plan card -->
|
||||
<Card :pad="0">
|
||||
<div class="hero">
|
||||
<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>
|
||||
<Badge tone="accent">Renews 28 Aug 2026</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>
|
||||
<div class="hero-actions">
|
||||
<UiButton variant="primary" @click="planOpen = true">Change plan</UiButton>
|
||||
<UiButton variant="secondary" @click="seatsOpen = true">Add seats</UiButton>
|
||||
<div class="spacer" />
|
||||
<UiButton variant="ghost" @click="pauseOpen = true">Pause subscription</UiButton>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<!-- Payment + business details -->
|
||||
<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>
|
||||
</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>
|
||||
</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>
|
||||
</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>
|
||||
</dl>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- Invoices table -->
|
||||
<Card :pad="0">
|
||||
<div class="invoices-head">
|
||||
<div>
|
||||
<Eyebrow>History</Eyebrow>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
<table class="inv-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Invoice</th><th>Date</th><th>Amount</th><th>Status</th><th></th>
|
||||
</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>
|
||||
<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>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</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"
|
||||
eyebrow="Billing · subscription"
|
||||
title="Pause subscription?"
|
||||
confirm-label="Pause subscription"
|
||||
tone="danger"
|
||||
@close="pauseOpen = false"
|
||||
@confirm="confirmPause"
|
||||
>
|
||||
Members keep access until the end of the current billing cycle (28 Aug 2026), after
|
||||
which sign-ins are blocked and data is held in cold storage. You can resume any time
|
||||
to restore full access.
|
||||
</ConfirmDialog>
|
||||
|
||||
<!-- Plan-change flow stub -->
|
||||
<Modal :open="planOpen" eyebrow="Billing · plan" title="Change plan" size="md" @close="planOpen = false">
|
||||
<div class="plan-stack">
|
||||
<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' },
|
||||
{ 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' },
|
||||
]" :key="p.id" :class="['plan-card', { active: p.current }]">
|
||||
<div class="plan-name">{{ p.name }}</div>
|
||||
<Mono dim>{{ p.price }}</Mono>
|
||||
<div class="plan-d">{{ p.d }}</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<UiButton variant="ghost" @click="planOpen = false">Cancel</UiButton>
|
||||
<UiButton variant="primary" @click="planOpen = false; toast.ok('Plan change scheduled', 'Takes effect on next invoice')">
|
||||
<template #leading><UiIcon name="check" :size="13" /></template>
|
||||
Schedule change
|
||||
</UiButton>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
<!-- Add seats modal -->
|
||||
<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>After change</Eyebrow><div class="big ok">{{ totalSeats }}</div></div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Eyebrow>How many seats to add</Eyebrow>
|
||||
<div class="stepper">
|
||||
<button class="step-btn" @click="extra = Math.max(1, extra - 1)"><span class="minus" /></button>
|
||||
<input type="number" :value="extra" @input="(e) => (extra = Math.max(1, Math.min(500, parseInt((e.target as HTMLInputElement).value || '0') || 1)))" />
|
||||
<button class="step-btn" @click="extra = Math.min(500, extra + 1)"><UiIcon name="plus" :size="14" /></button>
|
||||
</div>
|
||||
<div class="presets">
|
||||
<button v-for="n in [5, 10, 25, 50]" :key="n" :class="{ active: extra === n }" @click="quickSet(n)">+{{ n }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
</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`)">
|
||||
<template #leading><UiIcon name="plus" :size="13" /></template>
|
||||
Add {{ extra }} seat{{ extra === 1 ? '' : 's' }}
|
||||
</UiButton>
|
||||
</template>
|
||||
</Modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.content { padding: 24px 40px 64px 40px; max-width: 1200px; }
|
||||
.top-row { display: grid; grid-template-columns: 1.4fr 1fr; gap: 16px; margin-bottom: 16px; }
|
||||
|
||||
/* Hero plan card */
|
||||
.hero { padding: 28px; background: var(--text); color: var(--bg); border-radius: 8px 8px 0 0; }
|
||||
.hero-head { display: flex; justify-content: space-between; align-items: flex-start; }
|
||||
.kicker { font-family: var(--font-mono); font-size: 11px; color: var(--accent); letter-spacing: 0.1em; }
|
||||
.hero-title {
|
||||
font-family: var(--font-display);
|
||||
font-size: 38px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.025em;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.hero-sub { font-size: 13px; opacity: 0.6; margin-top: 4px; }
|
||||
.hero-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
margin-top: 28px;
|
||||
padding-top: 24px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
.hero-label { font-family: var(--font-mono); font-size: 10px; opacity: 0.5; letter-spacing: 0.12em; text-transform: uppercase; }
|
||||
.hero-num { font-family: var(--font-display); font-size: 28px; font-weight: 600; margin-top: 6px; }
|
||||
.hero-actions { padding: 24px; display: flex; gap: 8px; align-items: center; }
|
||||
.spacer { flex: 1; }
|
||||
|
||||
/* Payment + business sub-cards inside one Card */
|
||||
.card-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; margin-bottom: 12px; }
|
||||
.card-head + .card-head { margin-top: 16px; }
|
||||
.card-title {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 600;
|
||||
font-size: 18px;
|
||||
letter-spacing: -0.01em;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.visa-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 14px;
|
||||
background: var(--bg);
|
||||
border-radius: 6px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.visa {
|
||||
width: 40px;
|
||||
height: 28px;
|
||||
border-radius: 4px;
|
||||
background: var(--text);
|
||||
color: var(--bg);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 9px;
|
||||
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; }
|
||||
|
||||
.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); }
|
||||
|
||||
/* Invoices table */
|
||||
.invoices-head {
|
||||
padding: 20px 24px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.invoices-actions { display: flex; gap: 8px; }
|
||||
.inv-table { width: 100%; border-collapse: collapse; }
|
||||
.inv-table th {
|
||||
text-align: left;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-mute);
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-weight: 500;
|
||||
}
|
||||
.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; }
|
||||
.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; }
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
/* Add seats */
|
||||
.seats { display: flex; flex-direction: column; gap: 18px; }
|
||||
.seats-3col {
|
||||
padding: 16px;
|
||||
background: var(--bg);
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border);
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
.seats-3col > div { padding: 0 12px; border-right: 1px solid var(--border); }
|
||||
.seats-3col > div:first-child { padding-left: 0; }
|
||||
.seats-3col > div:last-child { padding-right: 0; border-right: none; }
|
||||
.big { font-family: var(--font-display); font-weight: 600; font-size: 24px; margin-top: 4px; }
|
||||
.big.ok { color: var(--ok); }
|
||||
.stepper { display: flex; align-items: center; gap: 12px; margin: 12px 0 10px; }
|
||||
.step-btn { width: 36px; height: 36px; border-radius: 6px; background: var(--surface); border: 1px solid var(--border); cursor: pointer; display: inline-flex; align-items: center; justify-content: center; }
|
||||
.step-btn .minus { width: 12px; height: 2px; background: var(--text); display: block; }
|
||||
.stepper input {
|
||||
flex: 1;
|
||||
height: 56px;
|
||||
padding: 0 16px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
font-family: var(--font-display);
|
||||
font-size: 32px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
text-align: center;
|
||||
outline: none;
|
||||
}
|
||||
.presets { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
.presets button { padding: 4px 10px; border-radius: 4px; cursor: pointer; background: var(--surface); color: var(--text); border: 1px solid var(--border); font-family: var(--font-mono); font-size: 11px; }
|
||||
.presets button.active { background: var(--text); color: var(--bg); border-color: var(--text); }
|
||||
.bill-box { padding: 16px; background: var(--surface); border-radius: 8px; border: 1px solid var(--border); display: flex; flex-direction: column; gap: 8px; }
|
||||
.bb-row { display: flex; justify-content: space-between; font-size: 13px; align-items: baseline; }
|
||||
.bb-row.sep { padding-bottom: 8px; border-bottom: 1px solid var(--border); }
|
||||
.bb-row.total { font-weight: 600; }
|
||||
.bb-row .dim { color: var(--text-mute); }
|
||||
.hero-amount { font-family: var(--font-display); font-size: 18px; letter-spacing: -0.01em; }
|
||||
|
||||
/* Plan-change modal */
|
||||
.plan-stack { display: flex; flex-direction: column; gap: 14px; }
|
||||
.lead { font-size: 13px; color: var(--text-mute); line-height: 1.55; }
|
||||
.plan-options { display: flex; flex-direction: column; gap: 8px; }
|
||||
.plan-card {
|
||||
padding: 14px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
text-align: left;
|
||||
font-family: inherit;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
}
|
||||
.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; }
|
||||
</style>
|
||||
@@ -0,0 +1,784 @@
|
||||
<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.
|
||||
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
const color = ref('#D4FF3A')
|
||||
const name = ref('Acme Workspace')
|
||||
|
||||
const uploadAsset = ref<typeof ASSETS[number] | null>(null)
|
||||
const uploaded = ref(false)
|
||||
const dragOver = ref(false)
|
||||
|
||||
const editTemplate = ref<typeof TEMPLATES[number] | null>(null)
|
||||
const subject = ref('')
|
||||
const body = ref('')
|
||||
const testSent = ref(false)
|
||||
|
||||
const publishOpen = ref(false)
|
||||
const publishState = ref<'confirm' | 'publishing' | 'done'>('confirm')
|
||||
const resetOpen = ref(false)
|
||||
|
||||
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
|
||||
|
||||
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' },
|
||||
] as const
|
||||
|
||||
const TEMPLATE_BODIES: Record<string, string> = {
|
||||
invitation: `Hi {{user.first_name}},
|
||||
|
||||
{{inviter.name}} has invited you to join {{workspace.name}} on dezky.
|
||||
|
||||
Click below to set up your account — the link expires in 7 days.
|
||||
|
||||
→ {{invite.url}}
|
||||
|
||||
If you have any questions, reply to this email and we'll help out.
|
||||
|
||||
— The {{workspace.name}} team`,
|
||||
reset: `Hi {{user.first_name}},
|
||||
|
||||
Someone (hopefully you) asked to reset your {{workspace.name}} password.
|
||||
|
||||
Click the link below within the next 60 minutes to choose a new one:
|
||||
|
||||
→ {{reset.url}}
|
||||
|
||||
If you didn't request this, you can safely ignore this email.
|
||||
|
||||
— {{workspace.name}} security`,
|
||||
digest: `Hi {{user.first_name}},
|
||||
|
||||
Here's what happened in {{workspace.name}} this week:
|
||||
|
||||
· {{stats.messages}} new messages across your channels
|
||||
· {{stats.files}} files shared
|
||||
· {{stats.meetings}} meetings recorded
|
||||
|
||||
→ Open dashboard: {{workspace.url}}
|
||||
|
||||
Manage how often you receive these from your profile.`,
|
||||
trial: `Hi {{user.first_name}},
|
||||
|
||||
Your {{workspace.name}} trial ends in {{trial.days_left}} days.
|
||||
|
||||
You've added {{stats.users}} users and uploaded {{stats.gb}} GB of files. To keep everything running smoothly, upgrade to Business or Enterprise.
|
||||
|
||||
→ Choose a plan: {{billing.url}}
|
||||
|
||||
— {{workspace.name}}`,
|
||||
}
|
||||
|
||||
const TEMPLATE_MERGE_TAGS: Record<string, string[]> = {
|
||||
invitation: ['user.first_name', 'user.email', 'inviter.name', 'workspace.name', 'invite.url', 'invite.expires_at'],
|
||||
reset: ['user.first_name', 'workspace.name', 'reset.url', 'reset.expires_at', 'security.ip'],
|
||||
digest: ['user.first_name', 'workspace.name', 'workspace.url', 'stats.messages', 'stats.files', 'stats.meetings'],
|
||||
trial: ['user.first_name', 'workspace.name', 'trial.days_left', 'stats.users', 'stats.gb', 'billing.url'],
|
||||
}
|
||||
|
||||
const colorPalette = ['#D4FF3A', '#3F6BFF', '#FF6B4A', '#5B8C5A', '#9B59B6']
|
||||
|
||||
function openTemplate(t: typeof TEMPLATES[number]) {
|
||||
editTemplate.value = t
|
||||
subject.value = t.subject
|
||||
body.value = 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).
|
||||
function wrapTag(tag: string) {
|
||||
return '{' + '{' + tag + '}' + '}'
|
||||
}
|
||||
|
||||
function startPublish() {
|
||||
publishState.value = 'publishing'
|
||||
setTimeout(() => { publishState.value = 'done' }, 1800)
|
||||
}
|
||||
|
||||
function openPublish() {
|
||||
publishOpen.value = true
|
||||
publishState.value = 'confirm'
|
||||
}
|
||||
|
||||
const renderedSubject = computed(() =>
|
||||
subject.value
|
||||
.replace(/\{\{workspace\.name\}\}/g, name.value)
|
||||
.replace(/\{\{user\.first_name\}\}/g, 'Anne')
|
||||
.replace(/\{\{trial\.days_left\}\}/g, '3'),
|
||||
)
|
||||
const renderedBody = computed(() =>
|
||||
body.value
|
||||
.replace(/\{\{workspace\.name\}\}/g, name.value)
|
||||
.replace(/\{\{workspace\.url\}\}/g, 'workspace.acme.dk')
|
||||
.replace(/\{\{user\.first_name\}\}/g, 'Anne')
|
||||
.replace(/\{\{user\.email\}\}/g, 'anne@acme.dk')
|
||||
.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(/\{\{trial\.days_left\}\}/g, '3')
|
||||
.replace(/\{\{stats\.messages\}\}/g, '1.840')
|
||||
.replace(/\{\{stats\.files\}\}/g, '24')
|
||||
.replace(/\{\{stats\.meetings\}\}/g, '6')
|
||||
.replace(/\{\{stats\.users\}\}/g, '8')
|
||||
.replace(/\{\{stats\.gb\}\}/g, '14'),
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<PageHeader
|
||||
eyebrow="Whitelabel"
|
||||
title="Branding"
|
||||
subtitle="Replace the dezky shell with your own logo, color, and product name. Changes propagate everywhere."
|
||||
>
|
||||
<template #actions>
|
||||
<UiButton variant="ghost" @click="resetOpen = true">Reset</UiButton>
|
||||
<UiButton variant="primary" @click="openPublish">Publish</UiButton>
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<div class="content">
|
||||
<!-- Controls -->
|
||||
<div class="controls">
|
||||
<Card>
|
||||
<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>
|
||||
</label>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<div class="card-head">
|
||||
<Eyebrow>Color</Eyebrow>
|
||||
<div class="card-title">Primary accent</div>
|
||||
<div class="card-sub">Propagates to buttons, links, focus rings, and active states.</div>
|
||||
</div>
|
||||
<div class="swatches">
|
||||
<button v-for="c in colorPalette" :key="c" :style="{ background: c, borderColor: color === c ? 'var(--text)' : 'var(--border)', borderWidth: color === c ? '2px' : '1px' }" @click="color = c" />
|
||||
</div>
|
||||
<div class="input-row">
|
||||
<input v-model="color" />
|
||||
<div class="color-preview" :style="{ background: color }" />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<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>
|
||||
</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)">
|
||||
<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>
|
||||
</div>
|
||||
<Mono dim>edited {{ t.edited }}</Mono>
|
||||
</div>
|
||||
<UiIcon name="chevRight" :size="14" stroke="var(--text-mute)" />
|
||||
</button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- Preview -->
|
||||
<div class="preview-col">
|
||||
<div class="preview-head">
|
||||
<Eyebrow>Live preview</Eyebrow>
|
||||
<Mono dim>workspace.acme.dk</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-brand">{{ name.toLowerCase() }}</div>
|
||||
<div class="frame-spacer" />
|
||||
<div class="frame-user">anne@acme.dk</div>
|
||||
</div>
|
||||
<div class="frame-hero">
|
||||
<div class="frame-eyebrow">Dashboard</div>
|
||||
<div class="frame-title">Good morning, Anne.</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>
|
||||
<div class="frame-tile-name">{{ n }}</div>
|
||||
</div>
|
||||
</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>
|
||||
<button class="frame-cta-btn">Get started</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="frame-foot">
|
||||
<span>powered by dezky</span>
|
||||
<span>v1.0 · light</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Upload asset modal -->
|
||||
<Modal :open="!!uploadAsset" :eyebrow="uploadAsset ? `Branding · ${uploadAsset.l.toLowerCase()}` : ''" :title="uploadAsset ? `Upload ${uploadAsset.l.toLowerCase()}` : ''" size="md" @close="uploadAsset = null">
|
||||
<div v-if="uploadAsset" class="upload">
|
||||
<button v-if="!uploaded" class="dropzone" :class="{ over: dragOver }"
|
||||
@dragover.prevent="dragOver = true"
|
||||
@dragleave="dragOver = false"
|
||||
@drop.prevent="dragOver = false; uploaded = true"
|
||||
@click="uploaded = true">
|
||||
<UiIcon name="upload" :size="28" stroke="var(--text-mute)" />
|
||||
<div class="drop-text">
|
||||
<div class="drop-title">Drop {{ uploadAsset.l.toLowerCase() }} here, or click to browse</div>
|
||||
<Mono dim>{{ uploadAsset.formats }} · {{ uploadAsset.ratio }} ratio · up to {{ uploadAsset.maxKb }} KB</Mono>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<template v-if="uploaded">
|
||||
<div class="upload-preview">
|
||||
<div class="upload-mark" :style="{ width: uploadAsset.id === 'full' ? '96px' : '56px' }">
|
||||
{{ uploadAsset.id === 'full' ? 'acme' : 'a' }}
|
||||
</div>
|
||||
<div class="upload-meta">
|
||||
<div class="upload-name">{{ uploadAsset.id === 'favicon' ? 'favicon-new.png' : uploadAsset.id === 'mark' ? 'acme-mark-v2.svg' : 'acme-logo.svg' }}</div>
|
||||
<Mono dim>{{ uploadAsset.id === 'favicon' ? '6 KB' : uploadAsset.id === 'mark' ? '14 KB' : '38 KB' }} · {{ uploadAsset.id === 'favicon' ? '32×32' : uploadAsset.id === 'mark' ? '512×512' : '1200×300' }} · clean alpha</Mono>
|
||||
</div>
|
||||
<UiButton size="sm" variant="ghost" @click="uploaded = false">Replace</UiButton>
|
||||
</div>
|
||||
<Eyebrow>Looks good</Eyebrow>
|
||||
<div class="check-list">
|
||||
<div class="check-row">
|
||||
<UiIcon name="check" :size="12" stroke="var(--ok)" :stroke-width="2.5" />
|
||||
<Mono dim>Format</Mono>
|
||||
<span>{{ uploadAsset.formats.split(' · ')[0] }} ✓</span>
|
||||
</div>
|
||||
<div class="check-row">
|
||||
<UiIcon name="check" :size="12" stroke="var(--ok)" :stroke-width="2.5" />
|
||||
<Mono dim>Dimensions</Mono>
|
||||
<span>{{ uploadAsset.id === 'favicon' ? '32×32 ✓' : uploadAsset.ratio + ' ✓' }}</span>
|
||||
</div>
|
||||
<div class="check-row">
|
||||
<UiIcon name="check" :size="12" stroke="var(--ok)" :stroke-width="2.5" />
|
||||
<Mono dim>Size</Mono>
|
||||
<span>{{ uploadAsset.id === 'favicon' ? '6 KB' : uploadAsset.id === 'mark' ? '14 KB' : '38 KB' }} (under {{ uploadAsset.maxKb }} KB)</span>
|
||||
</div>
|
||||
<div class="check-row">
|
||||
<UiIcon name="check" :size="12" stroke="var(--ok)" :stroke-width="2.5" />
|
||||
<Mono dim>Transparency</Mono>
|
||||
<span>{{ uploadAsset.id === 'favicon' ? 'opaque background OK' : 'transparent background ✓' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ld-preview">
|
||||
<Eyebrow>Preview · on light + dark</Eyebrow>
|
||||
<div class="ld-grid">
|
||||
<div class="ld-light">
|
||||
<div class="ld-mark dark" :style="{ width: uploadAsset.id === 'full' ? '80px' : '32px' }">{{ uploadAsset.id === 'full' ? 'acme' : 'a' }}</div>
|
||||
</div>
|
||||
<div class="ld-dark">
|
||||
<div class="ld-mark light" :style="{ width: uploadAsset.id === 'full' ? '80px' : '32px' }">{{ uploadAsset.id === 'full' ? 'acme' : 'a' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="req-box">
|
||||
<Mono dim>// requirements</Mono>
|
||||
<div class="req-body">
|
||||
<template v-if="uploadAsset.id === 'full'">Used in the top navigation bar, login screen, and email headers. Roughly 200×50 displayed — supply at 2× minimum.</template>
|
||||
<template v-else-if="uploadAsset.id === 'mark'">Used as the app icon, favicon fallback, and any compact context (PWA install, notifications). Must read at 24×24.</template>
|
||||
<template v-else>Browser tab icon and bookmark badge. 32×32 is the standard size — modern browsers use the same file at 16×16.</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<UiButton variant="ghost" @click="uploadAsset = null">Cancel</UiButton>
|
||||
<UiButton variant="primary" :disabled="!uploaded" @click="uploadAsset = null">
|
||||
<template #leading><UiIcon name="check" :size="13" /></template>
|
||||
{{ uploaded ? 'Use this asset' : 'Select a file to continue' }}
|
||||
</UiButton>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
<!-- Edit email template side panel -->
|
||||
<SidePanel :open="!!editTemplate" :eyebrow="'Email template'" :title="editTemplate?.name || ''" width="lg" @close="editTemplate = null">
|
||||
<div v-if="editTemplate" class="tmpl-edit">
|
||||
<div class="tmpl-col">
|
||||
<label class="field"><Eyebrow>Subject</Eyebrow><input class="input" v-model="subject" /></label>
|
||||
<div>
|
||||
<Eyebrow>Body</Eyebrow>
|
||||
<textarea v-model="body" class="body-area" />
|
||||
</div>
|
||||
<div>
|
||||
<Eyebrow>Merge tags · click to insert</Eyebrow>
|
||||
<div class="merge-tags">
|
||||
<button v-for="tag in (TEMPLATE_MERGE_TAGS[editTemplate.id] || [])" :key="tag" @click="insertTag(tag)">{{ wrapTag(tag) }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tmpl-prev">
|
||||
<Eyebrow>Preview</Eyebrow>
|
||||
<div class="email-frame">
|
||||
<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>
|
||||
</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>
|
||||
<Mono dim style="text-align: center; display: block;">preview substitutes sample data · real send uses recipient's data</Mono>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<UiButton variant="ghost" @click="resetTemplate">
|
||||
<template #leading><UiIcon name="refresh" :size="13" /></template>
|
||||
Reset to default
|
||||
</UiButton>
|
||||
<div style="flex: 1" />
|
||||
<UiButton variant="secondary" @click="testSent = true; setTimeout(() => testSent = false, 2500)">
|
||||
<template #leading><UiIcon name="mail" :size="13" /></template>
|
||||
{{ testSent ? 'Sent to anne@dezky.com ✓' : 'Send test to me' }}
|
||||
</UiButton>
|
||||
<UiButton variant="primary" @click="editTemplate = null">
|
||||
<template #leading><UiIcon name="check" :size="13" /></template>
|
||||
Save template
|
||||
</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>
|
||||
|
||||
<style scoped>
|
||||
.content { padding: 24px 40px 64px 40px; display: grid; grid-template-columns: 420px 1fr; gap: 24px; }
|
||||
.controls { display: flex; flex-direction: column; gap: 16px; }
|
||||
|
||||
.card-head { margin-bottom: 14px; }
|
||||
.card-title { font-family: var(--font-display); font-weight: 600; font-size: 18px; letter-spacing: -0.01em; margin-top: 4px; }
|
||||
.card-sub { font-size: 13px; color: var(--text-mute); margin-top: 4px; }
|
||||
|
||||
.field { display: flex; flex-direction: column; gap: 6px; margin-bottom: 14px; }
|
||||
.field:last-child { margin-bottom: 0; }
|
||||
.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;
|
||||
height: 36px;
|
||||
padding: 0 12px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
}
|
||||
.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; }
|
||||
|
||||
.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; }
|
||||
.tmpl-meta { flex: 1; min-width: 0; }
|
||||
.tmpl-name-row { display: flex; align-items: center; gap: 8px; }
|
||||
.tmpl-name { font-weight: 500; }
|
||||
|
||||
.preview-col { min-width: 0; }
|
||||
.preview-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
|
||||
.preview-frame {
|
||||
background: #FAFAF7;
|
||||
color: #0A0A0A;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border);
|
||||
overflow: hidden;
|
||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.08);
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
.frame-topbar {
|
||||
height: 52px;
|
||||
background: #0A0A0A;
|
||||
color: #F4F3EE;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 20px;
|
||||
gap: 16px;
|
||||
}
|
||||
.frame-mark {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 5px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: var(--font-mono);
|
||||
font-weight: 700;
|
||||
font-size: 13px;
|
||||
color: #0A0A0A;
|
||||
}
|
||||
.frame-brand { font-family: var(--font-mono); font-size: 13px; font-weight: 600; }
|
||||
.frame-spacer { flex: 1; }
|
||||
.frame-user { font-size: 11px; font-family: var(--font-mono); opacity: 0.6; }
|
||||
.frame-hero { padding: 36px 32px 24px 32px; }
|
||||
.frame-eyebrow { font-family: var(--font-mono); font-size: 10px; color: #5A5A55; letter-spacing: 0.12em; text-transform: uppercase; }
|
||||
.frame-title { font-family: var(--font-display); font-size: 28px; font-weight: 600; letter-spacing: -0.02em; margin-top: 8px; }
|
||||
.frame-tiles { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; margin-top: 20px; }
|
||||
.frame-tile { background: #fff; border: 1px solid #E6E4DC; border-radius: 6px; padding: 14px; }
|
||||
.frame-tile-icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 5px;
|
||||
background: #0A0A0A;
|
||||
color: #F4F3EE;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
}
|
||||
.frame-tile-name { font-family: var(--font-display); font-weight: 600; font-size: 14px; margin-top: 12px; }
|
||||
.frame-cta { margin-top: 24px; padding: 18px 20px; border-radius: 6px; display: flex; align-items: center; justify-content: space-between; }
|
||||
.frame-cta-title { font-family: var(--font-display); font-weight: 600; font-size: 15px; color: #0A0A0A; }
|
||||
.frame-cta-sub { font-size: 12px; color: rgba(10, 10, 10, 0.7); margin-top: 4px; }
|
||||
.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; }
|
||||
.tmpl-prev { padding: 24px; background: var(--bg); display: flex; flex-direction: column; gap: 12px; }
|
||||
.body-area {
|
||||
width: 100%;
|
||||
min-height: 320px;
|
||||
padding: 14px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
color: var(--text);
|
||||
font-family: var(--font-mono);
|
||||
line-height: 1.6;
|
||||
resize: vertical;
|
||||
outline: none;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.merge-tags { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
|
||||
.merge-tags button {
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
}
|
||||
.email-frame {
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
border: 1px solid #E6E4DC;
|
||||
color: #0A0A0A;
|
||||
font-family: 'Inter', sans-serif;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.06);
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.email-head { padding: 16px 20px; border-bottom: 1px solid #E6E4DC; background: #FAFAF7; }
|
||||
.from-row { display: flex; align-items: center; gap: 8px; }
|
||||
.from-mark {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 4px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: var(--font-mono);
|
||||
font-weight: 700;
|
||||
font-size: 11px;
|
||||
}
|
||||
.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>
|
||||
@@ -0,0 +1,403 @@
|
||||
<script setup lang="ts">
|
||||
// Strict port of project/platform-collab.jsx `ChatScreen` (lines 261-435).
|
||||
// 3 tabs: Workspaces / Channels / Retention, mirroring the source's data and
|
||||
// per-tab structure.
|
||||
|
||||
|
||||
import { chatWorkspaces, chatChannels } from '~/data/workspace'
|
||||
|
||||
const tab = ref<'workspaces' | 'channels' | 'retention'>('workspaces')
|
||||
const newWsOpen = ref(false)
|
||||
const openWs = ref<typeof chatWorkspaces[number] | null>(null)
|
||||
const newExportOpen = ref(false)
|
||||
const addOverrideOpen = ref(false)
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
// Per-workspace and per-channel kebab actions — mirror source intent.
|
||||
function wsAction(ws: typeof chatWorkspaces[number], id: string) {
|
||||
if (id === 'manage') openWs.value = ws
|
||||
else if (id === 'open') toast.info(`Opening ${ws.url}`)
|
||||
else if (id === 'invite') toast.info(`Invite link copied for ${ws.name}`)
|
||||
else if (id === 'archive') toast.warn(`${ws.name} archived`)
|
||||
}
|
||||
const wsItems = [
|
||||
{ id: 'manage', label: 'Manage workspace', icon: 'brush' as const },
|
||||
{ id: 'open', label: 'Open in browser', icon: 'external' as const },
|
||||
{ id: 'invite', label: 'Copy invite link', icon: 'copy' as const },
|
||||
{ id: 'sep1', separator: true },
|
||||
{ id: 'archive', label: 'Archive workspace', icon: 'trash' as const, danger: true },
|
||||
]
|
||||
|
||||
function channelAction(name: string, id: string) {
|
||||
if (id === 'open') toast.info(`Opening #${name}`)
|
||||
else if (id === 'rename') toast.info(`Rename #${name}`)
|
||||
else if (id === 'archive') toast.warn(`#${name} archived`)
|
||||
else if (id === 'delete') toast.bad(`#${name} deleted`)
|
||||
}
|
||||
const channelItems = [
|
||||
{ id: 'open', label: 'Open channel', icon: 'external' as const },
|
||||
{ id: 'rename', label: 'Rename', icon: 'brush' as const },
|
||||
{ id: 'archive', label: 'Archive', icon: 'folder' as const },
|
||||
{ id: 'sep1', separator: true },
|
||||
{ id: 'delete', label: 'Delete channel', icon: 'trash' as const, danger: true },
|
||||
]
|
||||
|
||||
function removeOverride(name: string) {
|
||||
toast.info(`${name} override removed`)
|
||||
}
|
||||
|
||||
const retention = ref<'30d' | '365d' | '3year' | 'forever'>('365d')
|
||||
const retentionOptions = [
|
||||
{ v: '30d' as const, label: '30 days', d: 'Short retention. Casual workspaces or strict privacy posture.' },
|
||||
{ v: '365d' as const, label: '365 days · recommended', d: 'Useful for most teams. Channel history is searchable for a year.' },
|
||||
{ v: '3year' as const, label: '3 years · Danish bookkeeping', d: 'Compliant with Danish accounting retention requirements.' },
|
||||
{ v: 'forever' as const, label: 'Forever', d: 'No automatic deletion. Required for some legal/regulated industries.' },
|
||||
]
|
||||
|
||||
const overrides = [
|
||||
{ name: '#incidents', t: 'invert', r: 'forever', reason: 'Post-mortem evidence' },
|
||||
{ name: '#dezky-roadmap', t: 'info', r: '3 years', reason: 'Product decisions log' },
|
||||
{ name: '#random', t: 'neutral', r: '90 days', reason: 'Reduce noise' },
|
||||
] as const
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<PageHeader
|
||||
eyebrow="Chat · Zulip"
|
||||
title="Chat settings"
|
||||
subtitle="Zulip workspaces, public channel visibility, and message retention policies."
|
||||
/>
|
||||
<div class="tab-wrap">
|
||||
<Tabs
|
||||
v-model="tab"
|
||||
:items="[
|
||||
{ value: 'workspaces', label: 'Workspaces', count: chatWorkspaces.length },
|
||||
{ value: 'channels', label: 'Channels', count: chatChannels.length },
|
||||
{ value: 'retention', label: 'Retention' },
|
||||
]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<template v-if="tab === 'workspaces'">
|
||||
<div class="row">
|
||||
<div class="lead">Workspaces let you separate communication scopes (e.g. company-wide vs. engineering-only). Members and channels are workspace-scoped.</div>
|
||||
<UiButton variant="primary" @click="newWsOpen = true">
|
||||
<template #leading><UiIcon name="plus" :size="14" /></template>
|
||||
New workspace
|
||||
</UiButton>
|
||||
</div>
|
||||
<div class="ws-grid">
|
||||
<Card v-for="w in chatWorkspaces" :key="w.id">
|
||||
<div class="ws-head">
|
||||
<div class="ws-title">
|
||||
<div class="ws-mark"><UiIcon name="chat" :size="18" /></div>
|
||||
<div>
|
||||
<div class="ws-name">
|
||||
<span>{{ w.name }}</span>
|
||||
<Badge v-if="w.primary" tone="invert">primary</Badge>
|
||||
</div>
|
||||
<Mono dim>{{ w.url }}</Mono>
|
||||
</div>
|
||||
</div>
|
||||
<Badge tone="ok" dot>{{ w.status }}</Badge>
|
||||
</div>
|
||||
<div class="ws-stats">
|
||||
<div><Eyebrow>Members</Eyebrow><div class="ws-num">{{ w.members }}</div></div>
|
||||
<div><Eyebrow>Channels</Eyebrow><div class="ws-num">{{ w.channels }}</div></div>
|
||||
<div><Eyebrow>30d msgs</Eyebrow><div class="ws-num">{{ w.messages30d.toLocaleString('da-DK') }}</div></div>
|
||||
</div>
|
||||
<div class="ws-actions">
|
||||
<UiButton size="sm" variant="secondary" @click="toast.info(`Opening ${w.url}`)">
|
||||
<template #leading><UiIcon name="external" :size="13" /></template>
|
||||
Open
|
||||
</UiButton>
|
||||
<UiButton size="sm" variant="ghost" @click="openWs = w">Manage</UiButton>
|
||||
<div class="spacer" />
|
||||
<AdminKebabMenu :items="wsItems" @select="(id) => wsAction(w, id)" />
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="tab === 'channels'">
|
||||
<div class="ch-toolbar">
|
||||
<div class="input-search">
|
||||
<UiIcon name="search" :size="14" stroke="var(--text-mute)" />
|
||||
<input placeholder="Search channels…" />
|
||||
</div>
|
||||
<button class="chip"><Eyebrow>Type:</Eyebrow><span>All</span><UiIcon name="chevDown" :size="12" stroke="var(--text-mute)" /></button>
|
||||
<button class="chip"><Eyebrow>Workspace:</Eyebrow><span>All</span><UiIcon name="chevDown" :size="12" stroke="var(--text-mute)" /></button>
|
||||
<div class="spacer" />
|
||||
<Mono dim>{{ chatChannels.length }} channels</Mono>
|
||||
</div>
|
||||
<Card :pad="0">
|
||||
<table class="tbl">
|
||||
<thead>
|
||||
<tr><th>Channel</th><th>Topic</th><th>Type</th><th>Members</th><th class="right">30d msgs</th><th>Owner</th><th /></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="c in chatChannels" :key="c.name">
|
||||
<td>
|
||||
<span class="ch-name" :class="{ priv: c.type === 'private' }">{{ c.type === 'private' ? '🔒' : '#' }} {{ c.name }}</span>
|
||||
</td>
|
||||
<td class="topic">{{ c.topic }}</td>
|
||||
<td><Badge :tone="c.type === 'public' ? 'ok' : 'warn'">{{ c.type }}</Badge></td>
|
||||
<td><Mono>{{ c.members }}</Mono></td>
|
||||
<td class="right"><Mono>{{ c.messages30d.toLocaleString('da-DK') }}</Mono></td>
|
||||
<td>
|
||||
<div class="owner-cell">
|
||||
<Avatar :name="c.owner" :size="18" />
|
||||
<span>{{ c.owner }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="right"><AdminKebabMenu :items="channelItems" @select="(id) => channelAction(c.name, id)" /></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div class="retention">
|
||||
<Card>
|
||||
<div class="card-head">
|
||||
<Eyebrow>Retention</Eyebrow>
|
||||
<div class="card-title">Message retention</div>
|
||||
<div class="card-sub">Applied org-wide. Compliance overrides user-level deletion.</div>
|
||||
</div>
|
||||
<div class="radio-big">
|
||||
<label v-for="o in retentionOptions" :key="o.v" :class="{ active: retention === o.v }">
|
||||
<span class="radio-dot"><span v-if="retention === o.v" /></span>
|
||||
<input type="radio" :value="o.v" v-model="retention" />
|
||||
<div>
|
||||
<div class="radio-label">{{ o.label }}</div>
|
||||
<div class="radio-d">{{ o.d }}</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<div class="card-head card-head-inline">
|
||||
<div>
|
||||
<Eyebrow>Per-channel</Eyebrow>
|
||||
<div class="card-title">Channel-level overrides</div>
|
||||
</div>
|
||||
<UiButton size="sm" variant="ghost" @click="addOverrideOpen = true">
|
||||
<template #leading><UiIcon name="plus" :size="13" /></template>
|
||||
Add override
|
||||
</UiButton>
|
||||
</div>
|
||||
<Card :pad="0" surface="bg" style="margin-top: 12px">
|
||||
<table class="tbl">
|
||||
<thead>
|
||||
<tr><th>Channel</th><th>Retention</th><th>Reason</th><th /></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="o in overrides" :key="o.name">
|
||||
<td><Mono style="font-weight: 500">{{ o.name }}</Mono></td>
|
||||
<td><Badge :tone="o.t as any" dot>{{ o.r }}</Badge></td>
|
||||
<td class="topic">{{ o.reason }}</td>
|
||||
<td class="right"><UiButton size="sm" variant="ghost" @click="removeOverride(o.name)"><UiIcon name="x" :size="12" /></UiButton></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</Card>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<div class="card-head card-head-inline">
|
||||
<div>
|
||||
<Eyebrow>Export & e-discovery</Eyebrow>
|
||||
<div class="card-title">Message export</div>
|
||||
</div>
|
||||
<UiButton size="sm" variant="secondary" @click="newExportOpen = true">New export</UiButton>
|
||||
</div>
|
||||
<div class="muted">Generate a signed ZIP of selected channels and date ranges for legal review or GDPR fulfillment. Available on Business and Enterprise plans.</div>
|
||||
</Card>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<Modal :open="newWsOpen" eyebrow="Chat · workspaces" title="New workspace" size="md" @close="newWsOpen = false">
|
||||
<div class="form-stack">
|
||||
<label class="field"><Eyebrow>Name</Eyebrow><input class="input" placeholder="engineering" /></label>
|
||||
<label class="field"><Eyebrow>URL</Eyebrow><input class="input" placeholder="eng.chat.dezky.com" /></label>
|
||||
</div>
|
||||
<template #footer>
|
||||
<UiButton variant="ghost" @click="newWsOpen = false">Cancel</UiButton>
|
||||
<UiButton variant="primary" @click="newWsOpen = false">Create workspace</UiButton>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
<!-- Per-channel retention override -->
|
||||
<Modal :open="addOverrideOpen" eyebrow="Chat · retention" title="Channel retention override" size="md" @close="addOverrideOpen = false">
|
||||
<div class="form-stack">
|
||||
<label class="field"><Eyebrow>Channel</Eyebrow><input class="input" placeholder="#incidents" /></label>
|
||||
<label class="field"><Eyebrow>Retention</Eyebrow>
|
||||
<select class="input">
|
||||
<option>30 days</option><option>90 days</option><option>365 days</option><option>3 years</option><option>Forever</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="field"><Eyebrow>Reason (audit)</Eyebrow><input class="input" placeholder="Post-mortem evidence" /></label>
|
||||
</div>
|
||||
<template #footer>
|
||||
<UiButton variant="ghost" @click="addOverrideOpen = false">Cancel</UiButton>
|
||||
<UiButton variant="primary" @click="addOverrideOpen = false; toast.ok('Override added')">Add override</UiButton>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
<!-- New e-discovery export -->
|
||||
<Modal :open="newExportOpen" eyebrow="Chat · export" title="New message export" size="md" @close="newExportOpen = false">
|
||||
<div class="form-stack">
|
||||
<label class="field"><Eyebrow>Channels</Eyebrow><input class="input" placeholder="#engineering, #incidents" /></label>
|
||||
<label class="field"><Eyebrow>From</Eyebrow><input class="input" placeholder="2026-01-01" /></label>
|
||||
<label class="field"><Eyebrow>To</Eyebrow><input class="input" placeholder="2026-12-31" /></label>
|
||||
<label class="field"><Eyebrow>Format</Eyebrow>
|
||||
<select class="input"><option>Signed ZIP · JSONL</option><option>Signed ZIP · HTML</option></select>
|
||||
</label>
|
||||
</div>
|
||||
<template #footer>
|
||||
<UiButton variant="ghost" @click="newExportOpen = false">Cancel</UiButton>
|
||||
<UiButton variant="primary" @click="newExportOpen = false; toast.info('Export queued · you will be emailed when ready')">Create export</UiButton>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
<SidePanel :open="!!openWs" eyebrow="Workspace" :title="openWs?.name || ''" width="lg" @close="openWs = null">
|
||||
<div v-if="openWs" class="manage">
|
||||
<div class="ws-head">
|
||||
<div class="ws-title">
|
||||
<div class="ws-mark big"><UiIcon name="chat" :size="22" /></div>
|
||||
<div>
|
||||
<div class="ws-name big">{{ openWs.name }}</div>
|
||||
<Mono dim>{{ openWs.url }}</Mono>
|
||||
</div>
|
||||
</div>
|
||||
<Badge tone="ok" dot>{{ openWs.status }}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<UiButton variant="ghost" @click="openWs = null">Close</UiButton>
|
||||
<div style="flex: 1" />
|
||||
<UiButton variant="primary" @click="openWs = null">Save changes</UiButton>
|
||||
</template>
|
||||
</SidePanel>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.tab-wrap { padding: 16px 40px 0 40px; }
|
||||
.content { padding: 20px 40px 64px 40px; }
|
||||
|
||||
.row { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; }
|
||||
.lead { font-size: 13px; color: var(--text-mute); max-width: 540px; line-height: 1.5; }
|
||||
.spacer { flex: 1; }
|
||||
|
||||
.ws-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 12px; }
|
||||
.ws-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
|
||||
.ws-title { display: flex; align-items: center; gap: 12px; }
|
||||
.ws-mark {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 8px;
|
||||
background: var(--text);
|
||||
color: var(--bg);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.ws-mark.big { width: 48px; height: 48px; border-radius: 10px; }
|
||||
.ws-name { display: flex; align-items: center; gap: 6px; font-family: var(--font-display); font-weight: 600; font-size: 18px; }
|
||||
.ws-name.big { font-size: 22px; }
|
||||
.ws-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 12px;
|
||||
margin-top: 18px;
|
||||
padding-top: 14px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.ws-num {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 600;
|
||||
font-size: 22px;
|
||||
margin-top: 4px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.ws-actions { display: flex; gap: 8px; margin-top: 16px; align-items: center; }
|
||||
|
||||
.ch-toolbar { display: flex; gap: 12px; align-items: center; margin-bottom: 12px; flex-wrap: wrap; }
|
||||
.input-search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 0 12px;
|
||||
height: 36px;
|
||||
width: 280px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
}
|
||||
.input-search input { flex: 1; border: none; outline: none; background: transparent; font-family: inherit; font-size: 13px; color: var(--text); }
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 36px;
|
||||
padding: 0 12px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
}
|
||||
.chip span { font-weight: 500; }
|
||||
|
||||
.tbl { width: 100%; border-collapse: collapse; }
|
||||
.tbl th {
|
||||
text-align: left;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-mute);
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-weight: 500;
|
||||
}
|
||||
.tbl td { padding: 12px 16px; border-bottom: 1px solid var(--border); font-size: 13px; vertical-align: middle; }
|
||||
.tbl tr:last-child td { border-bottom: none; }
|
||||
.tbl .right { text-align: right; }
|
||||
.topic { font-size: 12px; color: var(--text-mute); }
|
||||
.ch-name { font-family: var(--font-mono); font-size: 13px; font-weight: 600; }
|
||||
.ch-name.priv { color: var(--text-dim); }
|
||||
.owner-cell { display: flex; align-items: center; gap: 6px; font-size: 12px; }
|
||||
|
||||
.retention { display: flex; flex-direction: column; gap: 16px; max-width: 900px; }
|
||||
.card-head { margin-bottom: 14px; }
|
||||
.card-head-inline { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
|
||||
.card-title { font-family: var(--font-display); font-weight: 600; font-size: 18px; letter-spacing: -0.01em; margin-top: 4px; }
|
||||
.card-sub { font-size: 13px; color: var(--text-mute); margin-top: 4px; }
|
||||
.muted { font-size: 13px; color: var(--text-mute); line-height: 1.6; }
|
||||
|
||||
.radio-big { display: flex; flex-direction: column; gap: 8px; }
|
||||
.radio-big label { display: flex; gap: 12px; padding: 14px; border: 1px solid var(--border); border-radius: 6px; cursor: pointer; }
|
||||
.radio-big label.active { border-color: var(--text); background: var(--bg); }
|
||||
.radio-big input { display: none; }
|
||||
.radio-dot { width: 18px; height: 18px; border-radius: 999px; border: 2px solid var(--border); display: inline-flex; align-items: center; justify-content: center; flex-shrink: 0; margin-top: 2px; }
|
||||
.radio-big label.active .radio-dot { border-color: var(--text); }
|
||||
.radio-dot span { width: 8px; height: 8px; border-radius: 999px; background: var(--text); }
|
||||
.radio-label { font-size: 14px; font-weight: 500; }
|
||||
.radio-d { font-size: 12px; color: var(--text-mute); margin-top: 4px; }
|
||||
|
||||
.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; }
|
||||
|
||||
.manage { padding-bottom: 24px; }
|
||||
</style>
|
||||
@@ -0,0 +1,319 @@
|
||||
<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>
|
||||
@@ -0,0 +1,430 @@
|
||||
<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.
|
||||
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const step = ref(1)
|
||||
const domain = ref('lyngby-biler.dk')
|
||||
const policy = ref<'none' | 'quarantine' | 'reject'>('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 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.' },
|
||||
{ v: 'quarantine' as const, l: 'quarantine · recommended', d: 'Suspicious mail goes to spam. Catches almost all spoofing without breaking legitimate edge cases.' },
|
||||
{ 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 done() {
|
||||
router.push('/admin/domains')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="wizard">
|
||||
<div class="flow-head">
|
||||
<div class="row top">
|
||||
<div class="left">
|
||||
<button v-if="step > 1 && step < 6" class="back" @click="step--">
|
||||
<UiIcon name="chevLeft" :size="12" /> back
|
||||
</button>
|
||||
<Eyebrow>Add domain</Eyebrow>
|
||||
</div>
|
||||
<button class="cancel" @click="cancel">
|
||||
<UiIcon name="x" :size="14" /> cancel
|
||||
</button>
|
||||
</div>
|
||||
<div class="row title-row">
|
||||
<h1>{{ step < 6 ? 'Verify and configure your domain' : `${domain} 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="rail-label">
|
||||
<Mono dim>0{{ i + 1 }}</Mono>
|
||||
<span :class="i + 1 === step ? 'is-active' : i + 1 < step ? 'is-done' : 'is-todo'">{{ s }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="body">
|
||||
<!-- Step 1: Domain -->
|
||||
<div v-if="step === 1" class="step1">
|
||||
<p class="lead">
|
||||
Enter the domain you'll use for mail and identity. You'll need to add a few DNS records to prove you own it and route mail correctly.
|
||||
</p>
|
||||
<label class="field">
|
||||
<Eyebrow>Domain</Eyebrow>
|
||||
<div class="input-wrap">
|
||||
<UiIcon name="globe" :size="14" stroke="var(--text-mute)" />
|
||||
<input v-model="domain" placeholder="acme.dk" />
|
||||
</div>
|
||||
</label>
|
||||
<div class="info-box">
|
||||
<Eyebrow>Need to know</Eyebrow>
|
||||
<div class="info-body">
|
||||
• DNS changes typically propagate in 5–30 minutes<br />
|
||||
• You'll need access to your domain's DNS provider (Cloudflare, GoDaddy, etc.)<br />
|
||||
• For Danish .dk domains, you'll work with <Mono>DK-Hostmaster</Mono> or your registrar
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 2: Verify -->
|
||||
<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.
|
||||
</p>
|
||||
<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">_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 class="dns-right">
|
||||
<Badge tone="warn" dot>pending</Badge>
|
||||
<button class="copy-btn"><UiIcon name="copy" :size="11" stroke="var(--text-mute)" /> COPY</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="banner warn">
|
||||
<UiIcon name="refresh" :size="14" stroke="var(--warn)" />
|
||||
<div class="banner-body">
|
||||
<div class="banner-title">Last check · 14:42:08 · still waiting</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.
|
||||
</div>
|
||||
</div>
|
||||
<UiButton size="sm" variant="primary">Verify now</UiButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 3: Mail -->
|
||||
<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.
|
||||
</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>
|
||||
</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>
|
||||
</div>
|
||||
<div class="banner ok">
|
||||
<UiIcon name="check" :size="14" stroke="var(--ok)" :stroke-width="2.5" />
|
||||
<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>
|
||||
</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.
|
||||
</p>
|
||||
<Eyebrow style="display: block; margin-top: 20px; margin-bottom: 10px">DKIM · selector 1</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>
|
||||
</div>
|
||||
<div class="banner ok">
|
||||
<UiIcon name="check" :size="14" stroke="var(--ok)" :stroke-width="2.5" />
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 5: DMARC -->
|
||||
<div v-else-if="step === 5" class="step5">
|
||||
<p class="lead">
|
||||
DMARC tells receiving servers what to do with email that fails authentication. We strongly recommend at least <Mono>quarantine</Mono>.
|
||||
</p>
|
||||
<Eyebrow style="display: block; margin-top: 20px; margin-bottom: 10px">Choose policy</Eyebrow>
|
||||
<div class="policy-list">
|
||||
<label v-for="p in policyOptions" :key="p.v" :class="{ active: policy === p.v }">
|
||||
<span class="radio-dot"><span v-if="policy === p.v" /></span>
|
||||
<input type="radio" :value="p.v" v-model="policy" />
|
||||
<div>
|
||||
<div class="policy-label">{{ p.l }}</div>
|
||||
<div class="policy-d">{{ p.d }}</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
<Eyebrow style="display: block; margin-top: 24px; margin-bottom: 10px">Add this record</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">_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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 6: Done -->
|
||||
<div v-else class="step6">
|
||||
<div class="check-badge">
|
||||
<UiIcon name="check" :size="36" :stroke-width="2.5" />
|
||||
</div>
|
||||
<h2>{{ domain }} 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.
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<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>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="spacer" />
|
||||
<UiButton variant="secondary" @click="done">
|
||||
<template #leading><UiIcon name="users" :size="13" /></template>
|
||||
Invite users on this domain
|
||||
</UiButton>
|
||||
<UiButton variant="primary" @click="done">Back to domains</UiButton>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.wizard { display: flex; flex-direction: column; min-height: 100%; }
|
||||
|
||||
.flow-head { border-bottom: 1px solid var(--border); }
|
||||
.row { display: flex; align-items: center; justify-content: space-between; gap: 24px; }
|
||||
.row.top { padding: 14px 32px; }
|
||||
.row.title-row { padding: 0 32px 18px 32px; align-items: flex-end; }
|
||||
.left { display: flex; align-items: center; gap: 14px; }
|
||||
.back, .cancel {
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--text-mute);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
.cancel { padding: 6px; font-family: inherit; }
|
||||
.row.title-row h1 {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 600;
|
||||
font-size: 28px;
|
||||
letter-spacing: -0.025em;
|
||||
margin: 0;
|
||||
line-height: 1.05;
|
||||
}
|
||||
.rail { padding: 0 32px 18px 32px; display: flex; gap: 6px; }
|
||||
.rail-cell { flex: 1; display: flex; flex-direction: column; gap: 6px; }
|
||||
.bar { height: 3px; border-radius: 2px; }
|
||||
.bar.done { background: var(--text); }
|
||||
.bar.active { background: var(--accent); }
|
||||
.bar.todo { background: var(--border); }
|
||||
.rail-label { display: flex; align-items: center; gap: 6px; font-size: 12px; }
|
||||
.is-active { font-weight: 600; color: var(--text); }
|
||||
.is-done { color: var(--text); }
|
||||
.is-todo { color: var(--text-mute); }
|
||||
|
||||
.body { flex: 1; padding: 24px 32px; max-width: 920px; margin: 0 auto; width: 100%; }
|
||||
.lead { color: var(--text-dim); font-size: 14px; line-height: 1.6; margin-top: 0; }
|
||||
.lead-center { color: var(--text-dim); font-size: 15px; line-height: 1.6; margin-top: 12px; max-width: 500px; margin-inline: auto; }
|
||||
|
||||
.field { display: flex; flex-direction: column; gap: 6px; max-width: 520px; }
|
||||
.input-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 0 12px;
|
||||
height: 36px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
}
|
||||
.input-wrap input { flex: 1; border: none; outline: none; background: transparent; font-family: inherit; font-size: 13px; color: var(--text); }
|
||||
|
||||
.info-box {
|
||||
margin-top: 18px;
|
||||
padding: 14px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
max-width: 520px;
|
||||
}
|
||||
.info-body { margin-top: 10px; font-size: 13px; color: var(--text-dim); line-height: 1.65; }
|
||||
|
||||
.dns-rows { display: flex; flex-direction: column; gap: 8px; }
|
||||
.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; 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;
|
||||
}
|
||||
|
||||
.banner {
|
||||
margin-top: 16px;
|
||||
padding: 14px;
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
.banner.warn { background: rgba(232, 154, 31, 0.06); border: 1px solid rgba(232, 154, 31, 0.24); border-left: 3px solid var(--warn); }
|
||||
.banner.ok { background: rgba(31, 138, 91, 0.06); border: 1px solid rgba(31, 138, 91, 0.24); border-left: 3px solid var(--ok); }
|
||||
.banner-body { flex: 1; font-size: 13px; }
|
||||
.banner-title { font-weight: 600; }
|
||||
.banner-text { color: var(--text-dim); margin-top: 4px; line-height: 1.5; }
|
||||
|
||||
.policy-list { display: flex; flex-direction: column; gap: 8px; }
|
||||
.policy-list label {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
padding: 14px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.policy-list label.active { background: var(--bg); border-color: var(--text); }
|
||||
.policy-list input { display: none; }
|
||||
.radio-dot {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 999px;
|
||||
border: 2px solid var(--border-hi, var(--border));
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.policy-list label.active .radio-dot { border-color: var(--text); }
|
||||
.radio-dot span { width: 7px; height: 7px; border-radius: 999px; background: var(--text); }
|
||||
.policy-label { font-size: 13px; font-weight: 600; }
|
||||
.policy-d { font-size: 12px; color: var(--text-mute); margin-top: 4px; line-height: 1.5; }
|
||||
|
||||
.step6 { max-width: 680px; text-align: center; padding: 60px 0; margin: 0 auto; }
|
||||
.check-badge {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
border-radius: 16px;
|
||||
background: var(--accent);
|
||||
color: var(--accent-fg);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.step6 h2 {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 600;
|
||||
font-size: 36px;
|
||||
letter-spacing: -0.025em;
|
||||
margin: 0;
|
||||
line-height: 1.05;
|
||||
}
|
||||
.summary-grid {
|
||||
display: inline-grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 12px;
|
||||
margin-top: 36px;
|
||||
padding: 16px 24px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
text-align: left;
|
||||
}
|
||||
.summary-cell { display: flex; align-items: center; gap: 6px; }
|
||||
|
||||
.footer {
|
||||
border-top: 1px solid var(--border);
|
||||
padding: 14px 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: var(--surface);
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
}
|
||||
.spacer { flex: 1; }
|
||||
</style>
|
||||
@@ -0,0 +1,461 @@
|
||||
<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.
|
||||
|
||||
|
||||
import type { IconName } from '~/components/UiIcon.vue'
|
||||
import { sampleAudit } from '~/data/workspace'
|
||||
|
||||
const toast = useToast()
|
||||
const router = useRouter()
|
||||
|
||||
const inviteOpen = ref(false)
|
||||
const inviteStep = ref(1)
|
||||
const seatsOpen = ref(false)
|
||||
const seatsExtra = ref(5)
|
||||
|
||||
const stats = [
|
||||
{ label: 'Seats used', value: '11 / 25', delta: '+2 this week', deltaTone: 'up' as const, 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' },
|
||||
] as const
|
||||
|
||||
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') },
|
||||
{ icon: 'card', label: 'Upgrade plan', onClick: () => router.push('/admin/billing') },
|
||||
{ icon: 'shield', label: 'Enforce MFA', onClick: () => router.push('/admin/security') },
|
||||
{ icon: 'brush', label: 'Edit branding', onClick: () => router.push('/admin/branding') },
|
||||
{ icon: 'download', label: 'Export audit log', onClick: () => toast.ok('Audit log export queued · we’ll email you when ready') },
|
||||
]
|
||||
|
||||
function sendInvite() {
|
||||
inviteOpen.value = false
|
||||
inviteStep.value = 1
|
||||
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)))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<PageHeader
|
||||
eyebrow="Acme Workspace · dezky.com"
|
||||
title="Dashboard"
|
||||
subtitle="Health, activity, and quick actions across your workspace."
|
||||
>
|
||||
<template #actions>
|
||||
<UiButton variant="secondary" @click="inviteOpen = true">
|
||||
<template #leading><UiIcon name="users" :size="14" /></template>
|
||||
Invite user
|
||||
</UiButton>
|
||||
<UiButton variant="primary" @click="router.push('/admin/domains')">
|
||||
<template #leading><UiIcon name="plus" :size="14" /></template>
|
||||
Add domain
|
||||
</UiButton>
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<div class="content">
|
||||
<!-- Stat strip — single Card pad=0 with 4-col grid + inner right borders -->
|
||||
<Card :pad="0" class="strip">
|
||||
<div class="strip-grid">
|
||||
<div v-for="(s, i) in stats" :key="s.label" class="strip-cell" :class="{ noborder: i === stats.length - 1 }">
|
||||
<Stat
|
||||
:label="s.label"
|
||||
:value="s.value"
|
||||
:delta="s.delta"
|
||||
:delta-tone="s.deltaTone"
|
||||
:hint="s.hint"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<!-- License usage + Recent admin events -->
|
||||
<div class="row two-col-14">
|
||||
<Card>
|
||||
<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>
|
||||
<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="progress-legend">
|
||||
<span>11 active</span>
|
||||
<span>14 available</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="seats-cta">
|
||||
<div class="seats-cta-text">
|
||||
Approaching limit? You can add seats in single increments — billed prorated.
|
||||
</div>
|
||||
<UiButton size="sm" variant="dark" @click="seatsOpen = true">
|
||||
<template #leading><UiIcon name="plus" :size="13" /></template>
|
||||
Add seats
|
||||
</UiButton>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card :pad="0">
|
||||
<div class="card-block-head">
|
||||
<Eyebrow>Activity</Eyebrow>
|
||||
<div class="card-title">Recent admin events</div>
|
||||
</div>
|
||||
<div class="audit-list">
|
||||
<div v-for="a in recent" :key="a.id" class="audit-row">
|
||||
<StatusDot :color="`var(--${a.tone})`" :size="7" :glow="false" />
|
||||
<div class="audit-content">
|
||||
<div class="audit-line">
|
||||
<span class="audit-actor">{{ a.actor }}</span>
|
||||
<Mono dim>{{ a.action }}</Mono>
|
||||
</div>
|
||||
<Mono dim>{{ a.target }}</Mono>
|
||||
</div>
|
||||
<Mono dim>{{ a.when }}</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>
|
||||
|
||||
<Card>
|
||||
<div class="card-head card-head-inline">
|
||||
<div>
|
||||
<Eyebrow>Quick actions</Eyebrow>
|
||||
<div class="card-title">Common tasks</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="qa-grid">
|
||||
<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 }}
|
||||
</button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Invite user · 3-step modal (stubbed: step 1 fields only, but with stepper text) -->
|
||||
<Modal :open="inviteOpen" :title="'Invite user'" :eyebrow="`Step ${inviteStep} of 3`" size="md" @close="inviteOpen = false; inviteStep = 1">
|
||||
<div v-if="inviteStep === 1" class="form-stack">
|
||||
<label class="field"><Eyebrow>Full name</Eyebrow><input class="input" value="Magnus Eriksen" /></label>
|
||||
<label class="field"><Eyebrow>Email</Eyebrow><input class="input" value="magnus@dezky.com" /></label>
|
||||
<label class="field"><Eyebrow>Role</Eyebrow>
|
||||
<div class="radio-row">
|
||||
<button class="active">Member</button><button>Admin</button>
|
||||
</div>
|
||||
</label>
|
||||
<label class="field"><Eyebrow>License tier</Eyebrow>
|
||||
<div class="radio-row">
|
||||
<button>Basic</button><button class="active">Business</button>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
<div v-else-if="inviteStep === 2" class="form-stack">
|
||||
<div>
|
||||
<Eyebrow>Group memberships</Eyebrow>
|
||||
<div class="check-stack">
|
||||
<label v-for="(g, i) in ['Engineering', 'Design', 'Operations', 'Finance', 'Sales']" :key="g">
|
||||
<input type="checkbox" :checked="i === 0" /> {{ g }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Eyebrow>Apps</Eyebrow>
|
||||
<div class="check-stack">
|
||||
<label v-for="a in ['Mail', 'Drev', 'Møder', 'Chat']" :key="a">
|
||||
<input type="checkbox" checked /> {{ a }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else>
|
||||
<div class="review-box">
|
||||
<dl class="def">
|
||||
<div><dt>Name</dt><dd>Magnus Eriksen</dd></div>
|
||||
<div><dt>Email</dt><dd>magnus@dezky.com</dd></div>
|
||||
<div><dt>Role</dt><dd>Member · Business</dd></div>
|
||||
<div><dt>Groups</dt><dd>Engineering</dd></div>
|
||||
<div><dt>Apps</dt><dd>Mail · Drev · Møder · Chat</dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
<div class="muted">
|
||||
We'll provision the account across Authentik, Stalwart, OCIS, Jitsi and Zulip, then email Magnus an activation link valid for 7 days.
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<UiButton variant="ghost" @click="inviteOpen = false; inviteStep = 1">Cancel</UiButton>
|
||||
<UiButton v-if="inviteStep > 1" variant="secondary" @click="inviteStep--">Back</UiButton>
|
||||
<UiButton v-if="inviteStep < 3" variant="primary" @click="inviteStep++">Continue</UiButton>
|
||||
<UiButton v-else variant="primary" @click="sendInvite">Send invitation</UiButton>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
<!-- Add seats — strict port of AddSeatsModal -->
|
||||
<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>
|
||||
<div>
|
||||
<Eyebrow>How many seats to add</Eyebrow>
|
||||
<div class="stepper-row">
|
||||
<button class="step-btn" @click="seatsExtra = Math.max(1, seatsExtra - 1)">−</button>
|
||||
<input type="number" :value="seatsExtra" @input="(e) => (seatsExtra = Math.max(1, Math.min(500, parseInt((e.target as HTMLInputElement).value || '0') || 1)))" class="step-num" />
|
||||
<button class="step-btn" @click="seatsExtra = Math.min(500, seatsExtra + 1)">+</button>
|
||||
</div>
|
||||
<div class="quick-amounts">
|
||||
<button v-for="n in [5, 10, 25, 50]" :key="n" :class="{ active: seatsExtra === n }" @click="seatsExtra = n">+{{ n }}</button>
|
||||
</div>
|
||||
</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>
|
||||
<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>
|
||||
</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`) }">
|
||||
<template #leading><UiIcon name="plus" :size="13" /></template>
|
||||
Add {{ seatsExtra }} seat{{ seatsExtra === 1 ? '' : 's' }}
|
||||
</UiButton>
|
||||
</template>
|
||||
</Modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.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); }
|
||||
.strip-cell { padding: 24px; border-right: 1px solid var(--border); }
|
||||
.strip-cell.noborder { border-right: none; }
|
||||
|
||||
.card-head {
|
||||
padding: 20px 24px 16px 24px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.card-head-inline { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
|
||||
.card-block-head { padding: 20px 24px 12px 24px; }
|
||||
.card-title {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 600;
|
||||
font-size: 18px;
|
||||
letter-spacing: -0.01em;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.card-sub { font-size: 13px; color: var(--text-mute); margin-top: 4px; }
|
||||
|
||||
/* License progress */
|
||||
.progress-block { margin-bottom: 16px; }
|
||||
.progress-bar {
|
||||
height: 8px;
|
||||
background: var(--bg);
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.progress-bar span { display: block; height: 100%; background: var(--text); }
|
||||
.progress-legend {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-top: 8px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
color: var(--text-mute);
|
||||
}
|
||||
|
||||
/* Add-seats CTA box (dashed) */
|
||||
.seats-cta {
|
||||
padding: 16px;
|
||||
background: var(--bg);
|
||||
border-radius: 6px;
|
||||
border: 1px dashed var(--border-hi, var(--border));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
.seats-cta-text { font-size: 13px; color: var(--text-dim); }
|
||||
|
||||
/* Audit list */
|
||||
.audit-list { padding: 0 8px 8px 8px; }
|
||||
.audit-row {
|
||||
padding: 10px 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.audit-content { flex: 1; min-width: 0; }
|
||||
.audit-line { display: flex; gap: 6px; align-items: baseline; flex-wrap: wrap; }
|
||||
.audit-actor { font-weight: 500; }
|
||||
|
||||
/* 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" */
|
||||
.qa-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
|
||||
.qa {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
padding: 14px 16px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text);
|
||||
font-family: inherit;
|
||||
text-align: left;
|
||||
}
|
||||
.qa:hover { background: var(--elevated, var(--row-hover, var(--surface))); }
|
||||
|
||||
/* Invite modal helpers */
|
||||
.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); }
|
||||
.radio-row { display: inline-flex; border: 1px solid var(--border); border-radius: 6px; padding: 2px; width: fit-content; }
|
||||
.radio-row button { padding: 6px 14px; border: none; border-radius: 4px; background: transparent; color: var(--text); font-size: 12px; font-weight: 500; font-family: inherit; cursor: pointer; }
|
||||
.radio-row button.active { background: var(--text); color: var(--bg); }
|
||||
.check-stack { display: flex; flex-direction: column; gap: 6px; margin-top: 6px; font-size: 13px; }
|
||||
.check-stack label { display: flex; align-items: center; gap: 8px; }
|
||||
.review-box { padding: 16px; background: var(--bg); border-radius: 6px; margin-bottom: 16px; }
|
||||
.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); }
|
||||
.muted { font-size: 12px; color: var(--text-mute); line-height: 1.55; }
|
||||
|
||||
/* Add seats modal */
|
||||
.seats { display: flex; flex-direction: column; gap: 18px; }
|
||||
.seats-grid {
|
||||
padding: 16px;
|
||||
background: var(--bg);
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border);
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
.seats-cell { padding: 0 12px; border-right: 1px solid var(--border); }
|
||||
.seats-cell:first-child { padding-left: 0; }
|
||||
.seats-cell:last-child { border-right: none; padding-right: 0; }
|
||||
.seats-big { font-family: var(--font-display); font-weight: 600; font-size: 24px; margin-top: 4px; }
|
||||
.seats-big.ok { color: var(--ok); }
|
||||
.stepper-row { display: flex; align-items: center; gap: 12px; margin: 12px 0 10px; }
|
||||
.step-btn { width: 36px; height: 36px; border-radius: 6px; background: var(--surface); border: 1px solid var(--border); cursor: pointer; font-family: inherit; font-size: 16px; color: var(--text); }
|
||||
.step-num { flex: 1; height: 56px; padding: 0 16px; background: var(--surface); border: 1px solid var(--border); border-radius: 8px; font-family: var(--font-display); font-size: 32px; font-weight: 600; color: var(--text); text-align: center; outline: none; }
|
||||
.quick-amounts { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
.quick-amounts button { padding: 4px 10px; border-radius: 4px; cursor: pointer; background: var(--surface); color: var(--text); border: 1px solid var(--border); font-family: var(--font-mono); font-size: 11px; }
|
||||
.quick-amounts button.active { background: var(--text); color: var(--bg); border-color: var(--text); }
|
||||
.charge-summary { padding: 16px; background: var(--surface); border-radius: 8px; border: 1px solid var(--border); display: flex; flex-direction: column; gap: 8px; }
|
||||
.charge-row { display: flex; justify-content: space-between; font-size: 13px; align-items: baseline; }
|
||||
.charge-row.sep { padding-bottom: 8px; border-bottom: 1px solid var(--border); }
|
||||
.charge-row.total { font-weight: 600; }
|
||||
.charge-row .big { font-family: var(--font-display); font-size: 18px; letter-spacing: -0.01em; }
|
||||
.charge-row .muted { color: var(--text-mute); font-weight: 400; }
|
||||
.info-strip { 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>
|
||||
@@ -0,0 +1,506 @@
|
||||
<script setup lang="ts">
|
||||
// Strict port of project/platform-collab.jsx `IntegrationsScreen` (lines
|
||||
// 440-575) with IntegrationTile (589) and IntegrationDetail (622).
|
||||
// 4 tabs: Marketplace · Connected · Webhooks · API tokens.
|
||||
|
||||
|
||||
import { integrations, integrationCategories, type Integration } from '~/data/workspace'
|
||||
|
||||
const tab = ref<'marketplace' | 'connected' | 'webhooks' | 'api'>('marketplace')
|
||||
const cat = ref<typeof integrationCategories[number]>('All')
|
||||
const open = ref<Integration | null>(null)
|
||||
const buildCustomOpen = ref(false)
|
||||
const newWebhookOpen = ref(false)
|
||||
const newTokenOpen = ref(false)
|
||||
const disconnectOpen = ref(false)
|
||||
const revokeToken = ref<{ name: string; suffix: string } | null>(null)
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
function connectedAction(i: Integration, id: string) {
|
||||
if (id === 'configure') open.value = i
|
||||
else if (id === 'logs') toast.info(`Logs for ${i.name}`)
|
||||
else if (id === 'sync') toast.info(`Syncing ${i.name} now`)
|
||||
else if (id === 'disconnect') { open.value = i; disconnectOpen.value = true }
|
||||
}
|
||||
const connectedItems = [
|
||||
{ id: 'configure', label: 'Configure', icon: 'brush' as const },
|
||||
{ id: 'logs', label: 'View logs', icon: 'file' as const },
|
||||
{ id: 'sync', label: 'Sync now', icon: 'refresh' as const },
|
||||
{ id: 'sep1', separator: true },
|
||||
{ id: 'disconnect', label: 'Disconnect', icon: 'plug' as const, danger: true },
|
||||
]
|
||||
|
||||
function confirmDisconnect() {
|
||||
const name = open.value?.name
|
||||
disconnectOpen.value = false
|
||||
open.value = null
|
||||
toast.warn(`${name} disconnected`)
|
||||
}
|
||||
function confirmRevoke() {
|
||||
const name = revokeToken.value?.name
|
||||
revokeToken.value = null
|
||||
toast.bad(`${name} revoked`)
|
||||
}
|
||||
|
||||
const filtered = computed(() => {
|
||||
if (tab.value === 'connected') return integrations.filter((i) => i.connected)
|
||||
if (cat.value === 'All') return integrations
|
||||
return integrations.filter((i) => i.cat === cat.value)
|
||||
})
|
||||
|
||||
const connectedCount = computed(() => integrations.filter((i) => i.connected).length)
|
||||
|
||||
const apiTokens = [
|
||||
{ name: 'CI deploy token', prefix: 'dz_live_', suffix: 'a91f', scope: 'users:read · billing:read', created: '14 Feb 2026', lastUsed: '2 min ago' },
|
||||
{ name: 'Monitoring scrape', prefix: 'dz_live_', suffix: '88ce', scope: 'metrics:read', created: '02 Mar 2026', lastUsed: '14 sec ago' },
|
||||
{ name: 'Old migration · revoke', prefix: 'dz_live_', suffix: '441b', scope: 'admin:*', created: '11 Jan 2026', lastUsed: '24 d ago' },
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<PageHeader
|
||||
eyebrow="Marketplace"
|
||||
title="Integrations"
|
||||
subtitle="Connect dezky to the tools your team already uses."
|
||||
>
|
||||
<template #actions>
|
||||
<UiButton variant="secondary" @click="buildCustomOpen = true">
|
||||
<template #leading><UiIcon name="plug" :size="14" /></template>
|
||||
Build custom · API
|
||||
</UiButton>
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<div class="tab-wrap">
|
||||
<Tabs
|
||||
v-model="tab"
|
||||
:items="[
|
||||
{ value: 'marketplace', label: 'Marketplace', count: integrations.length },
|
||||
{ value: 'connected', label: 'Connected', count: connectedCount },
|
||||
{ value: 'webhooks', label: 'Webhooks' },
|
||||
{ value: 'api', label: 'API tokens' },
|
||||
]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Marketplace -->
|
||||
<div v-if="tab === 'marketplace'" class="content">
|
||||
<div class="cat-row">
|
||||
<button
|
||||
v-for="c in integrationCategories"
|
||||
:key="c"
|
||||
class="pill"
|
||||
:class="{ active: cat === c }"
|
||||
@click="cat = c"
|
||||
>
|
||||
{{ c }}
|
||||
<span v-if="c === 'Accounting'" class="dk-flag" :class="{ active: cat === c }">DK</span>
|
||||
</button>
|
||||
<div class="spacer" />
|
||||
<div class="input-search">
|
||||
<UiIcon name="search" :size="14" stroke="var(--text-mute)" />
|
||||
<input placeholder="Search integrations…" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="tile-grid">
|
||||
<button v-for="i in filtered" :key="i.id" class="tile" @click="open = i">
|
||||
<div class="tile-head">
|
||||
<div class="i-icon" :style="{ background: i.color, color: i.accent }">{{ i.icon }}</div>
|
||||
<Badge v-if="i.connected" tone="ok" dot>connected</Badge>
|
||||
<Badge v-else-if="i.danish" tone="info">DK</Badge>
|
||||
</div>
|
||||
<div class="tile-body">
|
||||
<div class="tile-name-row">
|
||||
<span class="tile-name">{{ i.name }}</span>
|
||||
<Mono dim>· {{ i.cat }}</Mono>
|
||||
</div>
|
||||
<div class="tile-desc">{{ i.desc }}</div>
|
||||
</div>
|
||||
<div class="tile-foot">
|
||||
<Mono dim>{{ i.kind }}</Mono>
|
||||
<span v-if="i.connected" class="users">{{ i.users }} users</span>
|
||||
<span v-else class="connect">Connect</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Connected -->
|
||||
<div v-else-if="tab === 'connected'" class="content">
|
||||
<Card :pad="0">
|
||||
<table class="tbl">
|
||||
<thead>
|
||||
<tr><th>Integration</th><th>Type</th><th>Users</th><th>Status</th><th /></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="i in filtered" :key="i.id">
|
||||
<td>
|
||||
<div class="conn-cell">
|
||||
<div class="i-icon small" :style="{ background: i.color, color: i.accent }">{{ i.icon }}</div>
|
||||
<div>
|
||||
<div class="conn-name">{{ i.name }}</div>
|
||||
<Mono dim>{{ i.cat }}</Mono>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td><Mono>{{ i.kind }}</Mono></td>
|
||||
<td><Mono>{{ i.users || 0 }}</Mono></td>
|
||||
<td><Badge tone="ok" dot>connected</Badge></td>
|
||||
<td class="right">
|
||||
<UiButton size="sm" variant="ghost" @click="open = i">Configure</UiButton>
|
||||
<AdminKebabMenu :items="connectedItems" :icon-size="13" @select="(id) => connectedAction(i, id)" />
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- Webhooks -->
|
||||
<div v-else-if="tab === 'webhooks'" class="content">
|
||||
<div class="empty-card">
|
||||
<UiIcon name="plug" :size="28" stroke="var(--text-mute)" />
|
||||
<div class="empty-title">No webhooks yet</div>
|
||||
<div class="empty-body">Webhooks let external services react to events in dezky (user.created, file.shared, billing.charged, etc.).</div>
|
||||
<UiButton variant="primary" @click="newWebhookOpen = true">
|
||||
<template #leading><UiIcon name="plus" :size="14" /></template>
|
||||
New webhook
|
||||
</UiButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- API tokens -->
|
||||
<div v-else class="content api">
|
||||
<div class="row">
|
||||
<div class="lead">API tokens authenticate scripts and external services to your workspace. Treat them like passwords.</div>
|
||||
<UiButton variant="primary" @click="newTokenOpen = true">
|
||||
<template #leading><UiIcon name="plus" :size="14" /></template>
|
||||
Generate token
|
||||
</UiButton>
|
||||
</div>
|
||||
<Card :pad="0">
|
||||
<table class="tbl">
|
||||
<thead>
|
||||
<tr><th>Token</th><th>Scope</th><th>Created</th><th>Last used</th><th /></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="t in apiTokens" :key="t.name">
|
||||
<td>
|
||||
<div class="tok-name">{{ t.name }}</div>
|
||||
<Mono dim>{{ t.prefix }}····{{ t.suffix }}</Mono>
|
||||
</td>
|
||||
<td><Mono dim>{{ t.scope }}</Mono></td>
|
||||
<td><Mono dim>{{ t.created }}</Mono></td>
|
||||
<td><Mono dim>{{ t.lastUsed }}</Mono></td>
|
||||
<td class="right">
|
||||
<UiButton size="sm" variant="danger" @click="revokeToken = { name: t.name, suffix: t.suffix }">
|
||||
<template #leading><UiIcon name="trash" :size="13" /></template>
|
||||
Revoke
|
||||
</UiButton>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- Detail side panel -->
|
||||
<SidePanel :open="!!open" :eyebrow="open?.cat || ''" :title="open?.name || ''" width="lg" @close="open = null">
|
||||
<div v-if="open" class="detail">
|
||||
<div class="detail-head">
|
||||
<div class="i-icon big" :style="{ background: open.color, color: open.accent }">{{ open.icon }}</div>
|
||||
<div class="detail-meta">
|
||||
<div class="detail-name">{{ open.name }}</div>
|
||||
<Mono dim>{{ open.cat }} · {{ open.kind }}</Mono>
|
||||
<div style="margin-top: 8px">
|
||||
<Badge v-if="open.connected" tone="ok" dot>connected · {{ open.users }} users</Badge>
|
||||
<Badge v-else tone="neutral">not connected</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="detail-desc">{{ open.desc }}</div>
|
||||
|
||||
<template v-if="!open.connected">
|
||||
<Eyebrow>What this integration does</Eyebrow>
|
||||
<div class="bullets">
|
||||
<div v-for="b in [
|
||||
`Provisions users from dezky into ${open.name} on invite`,
|
||||
`Single sign-on via Authentik · removes ${open.name} passwords`,
|
||||
`Group sync · dezky groups become ${open.name} teams`,
|
||||
'Audit trail · sign-ins logged in your global audit log',
|
||||
]" :key="b" class="bullet">
|
||||
<UiIcon name="check" :size="12" stroke="var(--ok)" :stroke-width="2.5" />
|
||||
<span>{{ b }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<Eyebrow>Configuration</Eyebrow>
|
||||
<div class="cfg">
|
||||
<div class="cfg-row">
|
||||
<Mono dim>SSO endpoint</Mono>
|
||||
<Mono>https://sso.dezky.com/{{ open.id }}</Mono>
|
||||
</div>
|
||||
<div class="cfg-row">
|
||||
<Mono dim>Last sign-in</Mono>
|
||||
<span>2 minutes ago · anne@dezky.com</span>
|
||||
</div>
|
||||
<div class="cfg-row">
|
||||
<Mono dim>Last sync</Mono>
|
||||
<span>5 minutes ago · 11 users</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<template #footer>
|
||||
<template v-if="open?.connected">
|
||||
<UiButton variant="danger" @click="disconnectOpen = true">
|
||||
<template #leading><UiIcon name="plug" :size="13" /></template>
|
||||
Disconnect
|
||||
</UiButton>
|
||||
<div style="flex: 1" />
|
||||
<UiButton variant="secondary" @click="toast.info(`Logs for ${open?.name}`)">View logs</UiButton>
|
||||
<UiButton variant="primary" @click="open = null; toast.ok('Settings saved')">Save changes</UiButton>
|
||||
</template>
|
||||
<template v-else>
|
||||
<UiButton variant="ghost" @click="open = null">Cancel</UiButton>
|
||||
<div style="flex: 1" />
|
||||
<UiButton variant="primary" @click="toast.ok(`${open?.name} connected`); open = null">
|
||||
<template #leading><UiIcon name="plug" :size="13" /></template>
|
||||
Connect {{ open?.name }}
|
||||
</UiButton>
|
||||
</template>
|
||||
</template>
|
||||
</SidePanel>
|
||||
|
||||
<!-- Build custom API modal stub -->
|
||||
<Modal :open="buildCustomOpen" eyebrow="Integrations · custom" title="Build a custom integration" size="md" @close="buildCustomOpen = false">
|
||||
<div class="form-stack">
|
||||
<div class="lead">
|
||||
Use dezky's REST API + webhooks to wire any system into your workspace. Token-scoped,
|
||||
rate-limited, and audit-logged.
|
||||
</div>
|
||||
<label class="field"><Eyebrow>Integration name</Eyebrow><input class="input" placeholder="Acme finance bridge" /></label>
|
||||
<label class="field"><Eyebrow>Description</Eyebrow><input class="input" placeholder="Posts invoice events to /accounts" /></label>
|
||||
</div>
|
||||
<template #footer>
|
||||
<UiButton variant="ghost" @click="buildCustomOpen = false">Cancel</UiButton>
|
||||
<UiButton variant="primary" @click="buildCustomOpen = false; tab = 'api'; toast.info('Generate a token to start')">
|
||||
<template #leading><UiIcon name="check" :size="13" /></template>
|
||||
Continue
|
||||
</UiButton>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
<!-- New webhook modal -->
|
||||
<Modal :open="newWebhookOpen" eyebrow="Integrations · webhooks" title="New webhook" size="md" @close="newWebhookOpen = false">
|
||||
<div class="form-stack">
|
||||
<label class="field"><Eyebrow>Endpoint URL</Eyebrow><input class="input" placeholder="https://example.com/dezky" /></label>
|
||||
<label class="field"><Eyebrow>Events</Eyebrow><input class="input" placeholder="user.created, file.shared" /></label>
|
||||
<label class="field"><Eyebrow>Signing secret</Eyebrow><input class="input" value="auto-generated · copy after save" /></label>
|
||||
</div>
|
||||
<template #footer>
|
||||
<UiButton variant="ghost" @click="newWebhookOpen = false">Cancel</UiButton>
|
||||
<UiButton variant="primary" @click="newWebhookOpen = false; toast.ok('Webhook created')">Create webhook</UiButton>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
<!-- New API token modal -->
|
||||
<Modal :open="newTokenOpen" eyebrow="Integrations · API tokens" title="New API token" size="md" @close="newTokenOpen = false">
|
||||
<div class="form-stack">
|
||||
<label class="field"><Eyebrow>Token name</Eyebrow><input class="input" placeholder="CI deploy token" /></label>
|
||||
<label class="field"><Eyebrow>Scopes</Eyebrow><input class="input" placeholder="users:read · billing:read" /></label>
|
||||
<label class="field"><Eyebrow>Expires</Eyebrow>
|
||||
<select class="input"><option>30 days</option><option>90 days</option><option>1 year</option><option>Never</option></select>
|
||||
</label>
|
||||
</div>
|
||||
<template #footer>
|
||||
<UiButton variant="ghost" @click="newTokenOpen = false">Cancel</UiButton>
|
||||
<UiButton variant="primary" @click="newTokenOpen = false; toast.ok('Token created — copy now, it will not be shown again')">
|
||||
<template #leading><UiIcon name="key" :size="13" /></template>
|
||||
Generate token
|
||||
</UiButton>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
<!-- Confirm disconnect -->
|
||||
<ConfirmDialog
|
||||
:open="disconnectOpen"
|
||||
eyebrow="Integration"
|
||||
:title="`Disconnect ${open?.name || ''}?`"
|
||||
confirm-label="Disconnect"
|
||||
tone="danger"
|
||||
@close="disconnectOpen = false"
|
||||
@confirm="confirmDisconnect"
|
||||
>
|
||||
Existing user sessions in {{ open?.name }} will keep working until they expire, but new
|
||||
sign-ins and provisioning will stop immediately.
|
||||
</ConfirmDialog>
|
||||
|
||||
<!-- Confirm revoke API token -->
|
||||
<ConfirmDialog
|
||||
:open="!!revokeToken"
|
||||
eyebrow="API token"
|
||||
:title="`Revoke ${revokeToken?.name || ''}?`"
|
||||
confirm-label="Revoke token"
|
||||
tone="danger"
|
||||
@close="revokeToken = null"
|
||||
@confirm="confirmRevoke"
|
||||
>
|
||||
Any script using <Mono>dz_live_····{{ revokeToken?.suffix }}</Mono> will fail
|
||||
authentication immediately. This cannot be undone.
|
||||
</ConfirmDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.tab-wrap { padding: 16px 40px 0 40px; }
|
||||
.content { padding: 20px 40px 64px 40px; }
|
||||
|
||||
.cat-row { display: flex; align-items: center; gap: 8px; margin-bottom: 20px; flex-wrap: wrap; }
|
||||
.pill {
|
||||
padding: 6px 12px;
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
color: var(--text-dim);
|
||||
border: 1px solid var(--border);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.pill.active { background: var(--text); color: var(--bg); border-color: var(--text); }
|
||||
.dk-flag {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 9px;
|
||||
padding: 1px 5px;
|
||||
background: var(--bg);
|
||||
color: var(--text-mute);
|
||||
border-radius: 2px;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
.dk-flag.active { background: var(--accent); color: var(--accent-fg); }
|
||||
.spacer { flex: 1; }
|
||||
.input-search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 0 12px;
|
||||
height: 36px;
|
||||
width: 240px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
}
|
||||
.input-search input { flex: 1; border: none; outline: none; background: transparent; font-family: inherit; font-size: 13px; color: var(--text); }
|
||||
|
||||
.tile-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 12px; }
|
||||
.tile {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 18px;
|
||||
text-align: left;
|
||||
font-family: inherit;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
min-height: 168px;
|
||||
transition: border-color 120ms;
|
||||
}
|
||||
.tile:hover { border-color: var(--text); }
|
||||
.tile-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; }
|
||||
.tile-body { flex: 1; }
|
||||
.tile-name-row { display: flex; align-items: center; gap: 6px; }
|
||||
.tile-name { font-family: var(--font-display); font-weight: 600; font-size: 16px; letter-spacing: -0.015em; }
|
||||
.tile-desc { font-size: 12px; color: var(--text-mute); margin-top: 6px; line-height: 1.5; }
|
||||
.tile-foot { display: flex; align-items: center; justify-content: space-between; }
|
||||
.users { font-size: 12px; color: var(--text-dim); }
|
||||
.connect {
|
||||
font-size: 12px;
|
||||
color: var(--accent-fg);
|
||||
background: var(--accent);
|
||||
padding: 3px 10px;
|
||||
border-radius: 4px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.i-icon {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 10px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: var(--font-display);
|
||||
font-weight: 700;
|
||||
font-size: 22px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.i-icon.small { width: 32px; height: 32px; font-size: 16px; }
|
||||
.i-icon.big { width: 56px; height: 56px; font-size: 28px; }
|
||||
|
||||
.tbl { width: 100%; border-collapse: collapse; }
|
||||
.tbl th {
|
||||
text-align: left;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-mute);
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-weight: 500;
|
||||
}
|
||||
.tbl td { padding: 12px 16px; border-bottom: 1px solid var(--border); font-size: 13px; vertical-align: middle; }
|
||||
.tbl tr:last-child td { border-bottom: none; }
|
||||
.tbl .right { text-align: right; display: flex; gap: 4px; justify-content: flex-end; }
|
||||
tr td.right { display: table-cell; text-align: right; }
|
||||
|
||||
.conn-cell { display: flex; align-items: center; gap: 12px; }
|
||||
.conn-name { font-size: 13px; font-weight: 500; }
|
||||
|
||||
.empty-card {
|
||||
padding: 60px 24px;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
}
|
||||
.empty-title { font-family: var(--font-display); font-weight: 600; font-size: 17px; }
|
||||
.empty-body { font-size: 13px; color: var(--text-mute); max-width: 420px; line-height: 1.5; }
|
||||
|
||||
.content.api { max-width: 900px; }
|
||||
.row { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; }
|
||||
.lead { font-size: 13px; color: var(--text-mute); max-width: 540px; line-height: 1.5; }
|
||||
.tok-name { font-size: 13px; font-weight: 500; }
|
||||
|
||||
.detail { padding-bottom: 24px; }
|
||||
.detail-head { display: flex; align-items: center; gap: 14px; }
|
||||
.detail-meta { flex: 1; }
|
||||
.detail-name { font-family: var(--font-display); font-weight: 600; font-size: 20px; letter-spacing: -0.015em; }
|
||||
.detail-desc { margin-top: 16px; font-size: 13px; color: var(--text-dim); line-height: 1.6; }
|
||||
|
||||
.bullets { display: flex; flex-direction: column; gap: 10px; margin-top: 10px; }
|
||||
.bullet { display: flex; align-items: flex-start; gap: 10px; font-size: 13px; }
|
||||
.cfg { display: flex; flex-direction: column; gap: 10px; margin-top: 10px; }
|
||||
.cfg-row { padding: 12px; background: var(--bg); border-radius: 6px; border: 1px solid var(--border); display: flex; align-items: center; justify-content: space-between; gap: 12px; font-size: 13px; }
|
||||
|
||||
/* Modal form helpers */
|
||||
.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); }
|
||||
</style>
|
||||
@@ -0,0 +1,559 @@
|
||||
<script setup lang="ts">
|
||||
// Strict port of project/platform-admin.jsx `MailSettingsScreen` (lines 76-305).
|
||||
// 5 tabs: Aliases / Forwarding / Filters · anti-spam / Distribution lists /
|
||||
// Compliance · retention. Each uses the source's data and copy verbatim.
|
||||
|
||||
|
||||
import {
|
||||
orgAliases,
|
||||
forwardingRules,
|
||||
antiSpamFilters,
|
||||
distributionLists,
|
||||
} from '~/data/workspace'
|
||||
|
||||
const tab = ref<'aliases' | 'forwarding' | 'filters' | 'lists' | 'compliance'>('aliases')
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
const addAliasOpen = ref(false)
|
||||
const ruleOpen = ref(false)
|
||||
const filterOpen = ref(false)
|
||||
const listOpen = ref(false)
|
||||
const holdOpen = ref(false)
|
||||
const openList = ref<typeof distributionLists[number] | null>(null)
|
||||
const deleteListOpen = ref(false)
|
||||
|
||||
// Track each forwarding rule's enabled flag locally so the toggle visually flips.
|
||||
const ruleEnabled = reactive<Record<string, boolean>>(
|
||||
Object.fromEntries(forwardingRules.map((r) => [r.name, r.enabled])),
|
||||
)
|
||||
const filterEnabled = reactive<Record<string, boolean>>(
|
||||
Object.fromEntries(antiSpamFilters.map((f) => [f.name, f.enabled])),
|
||||
)
|
||||
|
||||
async function copyAlias(alias: string) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(alias)
|
||||
toast.ok('Alias copied', alias)
|
||||
} catch {
|
||||
toast.warn('Copy failed', 'Select and copy manually')
|
||||
}
|
||||
}
|
||||
|
||||
function aliasAction(alias: string, id: string) {
|
||||
if (id === 'edit') addAliasOpen.value = true
|
||||
else if (id === 'copy') copyAlias(alias)
|
||||
else if (id === 'disable') toast.info(`${alias} disabled`)
|
||||
else if (id === 'delete') toast.bad(`${alias} deleted`)
|
||||
}
|
||||
|
||||
const aliasItems = [
|
||||
{ id: 'edit', label: 'Edit alias', icon: 'brush' as const },
|
||||
{ id: 'copy', label: 'Copy address', icon: 'copy' as const },
|
||||
{ id: 'disable', label: 'Disable alias', icon: 'x' as const },
|
||||
{ id: 'sep1', separator: true },
|
||||
{ id: 'delete', label: 'Delete alias', icon: 'trash' as const, danger: true },
|
||||
]
|
||||
|
||||
function ruleAction(name: string, id: string) {
|
||||
if (id === 'edit') ruleOpen.value = true
|
||||
else if (id === 'run') toast.info(`Running "${name}" once`)
|
||||
else if (id === 'duplicate') toast.ok(`"${name}" duplicated`)
|
||||
else if (id === 'delete') toast.bad(`"${name}" deleted`)
|
||||
}
|
||||
const ruleItems = [
|
||||
{ id: 'edit', label: 'Edit rule', icon: 'brush' as const },
|
||||
{ id: 'run', label: 'Run once now', icon: 'refresh' as const },
|
||||
{ id: 'duplicate', label: 'Duplicate', icon: 'copy' as const },
|
||||
{ id: 'sep1', separator: true },
|
||||
{ id: 'delete', label: 'Delete rule', icon: 'trash' as const, danger: true },
|
||||
]
|
||||
|
||||
function filterAction(name: string, id: string) {
|
||||
if (id === 'edit') filterOpen.value = true
|
||||
else if (id === 'duplicate') toast.ok(`"${name}" duplicated`)
|
||||
else if (id === 'delete') toast.bad(`"${name}" deleted`)
|
||||
}
|
||||
const filterItems = [
|
||||
{ id: 'edit', label: 'Edit filter', icon: 'brush' as const },
|
||||
{ id: 'duplicate', label: 'Duplicate', icon: 'copy' as const },
|
||||
{ id: 'sep1', separator: true },
|
||||
{ id: 'delete', label: 'Delete filter', icon: 'trash' as const, danger: true },
|
||||
]
|
||||
|
||||
function confirmDeleteList() {
|
||||
deleteListOpen.value = false
|
||||
const name = openList.value?.name
|
||||
openList.value = null
|
||||
toast.bad(`${name} deleted`)
|
||||
}
|
||||
|
||||
const retention = ref<'30d' | '1year' | '3year' | 'unlimited'>('3year')
|
||||
const retentionOptions = [
|
||||
{ v: '30d' as const, label: '30 days', d: 'Standard retention. Anything older is permanently deleted.' },
|
||||
{ v: '1year' as const, label: '1 year', d: 'Mid-term. Suitable for most non-regulated businesses.' },
|
||||
{ v: '3year' as const, label: '3 years · recommended', d: 'Danish bookkeeping retention compliant (5-year option also available).' },
|
||||
{ v: 'unlimited' as const, label: 'Unlimited', d: 'Required for regulated industries (legal, healthcare, public sector).' },
|
||||
]
|
||||
|
||||
const tabs = [
|
||||
{ value: 'aliases', label: 'Aliases', count: orgAliases.length },
|
||||
{ value: 'forwarding', label: 'Forwarding', count: forwardingRules.length },
|
||||
{ value: 'filters', label: 'Filters · anti-spam', count: antiSpamFilters.length },
|
||||
{ value: 'lists', label: 'Distribution lists', count: distributionLists.length },
|
||||
{ value: 'compliance', label: 'Compliance · retention' },
|
||||
]
|
||||
|
||||
function toneFor(action: string): 'bad' | 'warn' | 'info' {
|
||||
return action === 'reject' ? 'bad' : action === 'quarantine' ? 'warn' : 'info'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<PageHeader
|
||||
eyebrow="Workspace"
|
||||
title="Mail settings"
|
||||
subtitle="Organization-level aliases, forwarding, content filters, and compliance policies."
|
||||
/>
|
||||
<div class="tab-wrap">
|
||||
<Tabs v-model="tab" :items="tabs" />
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<!-- ALIASES -->
|
||||
<template v-if="tab === 'aliases'">
|
||||
<div class="row">
|
||||
<div class="lead">Aliases route mail to existing users or distribution lists. They count against your domain, not your seats.</div>
|
||||
<UiButton variant="primary" @click="addAliasOpen = true">
|
||||
<template #leading><UiIcon name="plus" :size="14" /></template>
|
||||
Add alias
|
||||
</UiButton>
|
||||
</div>
|
||||
<Card :pad="0">
|
||||
<table class="tbl">
|
||||
<thead><tr><th>Alias</th><th></th><th>Destination</th><th>State</th><th>Created</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="r in orgAliases" :key="r.alias">
|
||||
<td><Mono style="font-weight: 500">{{ r.alias }}</Mono></td>
|
||||
<td><UiIcon name="arrowRight" :size="12" stroke="var(--text-mute)" /></td>
|
||||
<td>{{ r.dest }}</td>
|
||||
<td><Badge :tone="r.active ? 'ok' : 'neutral'" dot>{{ r.active ? 'active' : 'paused' }}</Badge></td>
|
||||
<td><Mono dim>{{ r.created }}</Mono></td>
|
||||
<td class="right">
|
||||
<UiButton size="sm" variant="ghost" @click="copyAlias(r.alias)"><UiIcon name="copy" :size="13" /></UiButton>
|
||||
<AdminKebabMenu :items="aliasItems" :icon-size="13" @select="(id) => aliasAction(r.alias, id)" />
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<!-- FORWARDING -->
|
||||
<template v-else-if="tab === 'forwarding'">
|
||||
<div class="row">
|
||||
<div class="lead">Conditional rules applied to all incoming mail. Useful for routing customer inquiries or auto-escalating.</div>
|
||||
<UiButton variant="primary" @click="ruleOpen = true">
|
||||
<template #leading><UiIcon name="plus" :size="14" /></template>
|
||||
New rule
|
||||
</UiButton>
|
||||
</div>
|
||||
<div class="rules">
|
||||
<Card v-for="r in forwardingRules" :key="r.name" :pad="16">
|
||||
<div class="rule-row">
|
||||
<button class="toggle" :class="{ on: ruleEnabled[r.name] }" @click="ruleEnabled[r.name] = !ruleEnabled[r.name]"><span /></button>
|
||||
<div class="rule-meta">
|
||||
<div class="rule-name">{{ r.name }}</div>
|
||||
<div class="rule-line">
|
||||
<Mono dim>WHEN</Mono>
|
||||
<span class="rule-match">{{ r.match }}</span>
|
||||
<UiIcon name="arrowRight" :size="11" stroke="var(--text-mute)" />
|
||||
<Mono dim>FORWARD TO</Mono>
|
||||
<Mono>{{ r.fwd }}</Mono>
|
||||
</div>
|
||||
</div>
|
||||
<UiButton size="sm" variant="ghost" @click="ruleOpen = true">Edit</UiButton>
|
||||
<AdminKebabMenu :items="ruleItems" @select="(id) => ruleAction(r.name, id)" />
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- FILTERS -->
|
||||
<template v-else-if="tab === 'filters'">
|
||||
<div class="row">
|
||||
<div class="lead">Org-wide content filters apply <i>before</i> user-level rules. Stalwart's spam engine handles the rest automatically.</div>
|
||||
<UiButton variant="primary" @click="filterOpen = true">
|
||||
<template #leading><UiIcon name="plus" :size="14" /></template>
|
||||
New filter
|
||||
</UiButton>
|
||||
</div>
|
||||
<Card :pad="0">
|
||||
<table class="tbl">
|
||||
<thead><tr><th>Filter</th><th>Match</th><th>Action</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="f in antiSpamFilters" :key="f.name">
|
||||
<td>
|
||||
<div class="filter-name">
|
||||
<button class="toggle" :class="{ on: filterEnabled[f.name] }" @click="filterEnabled[f.name] = !filterEnabled[f.name]"><span /></button>
|
||||
<span>{{ f.name }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td><Mono dim>{{ f.match }}</Mono></td>
|
||||
<td><Badge :tone="toneFor(f.action)">{{ f.action }}</Badge></td>
|
||||
<td class="right"><AdminKebabMenu :items="filterItems" @select="(id) => filterAction(f.name, id)" /></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</Card>
|
||||
<div class="builtin">
|
||||
<UiIcon name="shield" :size="16" stroke="var(--ok)" />
|
||||
<div>
|
||||
<div class="builtin-title">Built-in spam protection · enabled</div>
|
||||
<div class="builtin-sub">
|
||||
Stalwart's reputation engine and Bayesian filter block ~94% of spam at the edge. <Mono>last 7d: 12,840 blocked · 18 quarantined · 0 false positives reported</Mono>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- LISTS -->
|
||||
<template v-else-if="tab === 'lists'">
|
||||
<div class="row">
|
||||
<div class="lead">Distribution lists send mail to many recipients via a single alias. Members can be internal users, groups, or external addresses.</div>
|
||||
<UiButton variant="primary" @click="listOpen = true">
|
||||
<template #leading><UiIcon name="plus" :size="14" /></template>
|
||||
New list
|
||||
</UiButton>
|
||||
</div>
|
||||
<div class="lists">
|
||||
<Card v-for="l in distributionLists" :key="l.alias">
|
||||
<div class="list-head">
|
||||
<div>
|
||||
<div class="list-title">
|
||||
<UiIcon name="users" :size="16" stroke="var(--text-mute)" />
|
||||
<span class="list-name">{{ l.name }}</span>
|
||||
<Badge v-if="l.external" tone="warn">external members</Badge>
|
||||
</div>
|
||||
<Mono dim style="display: block; margin-top: 4px">{{ l.alias }}</Mono>
|
||||
</div>
|
||||
<Badge :tone="l.moderation === 'open' ? 'ok' : 'neutral'">{{ l.moderation }}</Badge>
|
||||
</div>
|
||||
<div class="list-row">
|
||||
<div>
|
||||
<Eyebrow>Members</Eyebrow>
|
||||
<div class="list-num">{{ l.members }}</div>
|
||||
</div>
|
||||
<div class="list-owner">
|
||||
<Eyebrow>Owner</Eyebrow>
|
||||
<div>{{ l.owner }}</div>
|
||||
</div>
|
||||
<UiButton size="sm" variant="secondary" @click="openList = l">Manage</UiButton>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- COMPLIANCE -->
|
||||
<template v-else>
|
||||
<div class="compliance">
|
||||
<Card>
|
||||
<div class="card-head">
|
||||
<Eyebrow>Retention</Eyebrow>
|
||||
<div class="card-title">Mail retention policy</div>
|
||||
<div class="card-sub">Applied org-wide. Compliance requirements override user-level deletion.</div>
|
||||
</div>
|
||||
<div class="radio-big">
|
||||
<label v-for="o in retentionOptions" :key="o.v" :class="{ active: retention === o.v }">
|
||||
<span class="radio-dot"><span v-if="retention === o.v" /></span>
|
||||
<input type="radio" :value="o.v" v-model="retention" />
|
||||
<div>
|
||||
<div class="radio-label">{{ o.label }}</div>
|
||||
<div class="radio-d">{{ o.d }}</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<div class="card-head card-head-inline">
|
||||
<div>
|
||||
<Eyebrow>Journaling</Eyebrow>
|
||||
<div class="card-title">Mail journaling</div>
|
||||
<div class="card-sub">Copy every inbound and outbound mail to a journal mailbox for e-discovery.</div>
|
||||
</div>
|
||||
<button class="toggle"><span /></button>
|
||||
</div>
|
||||
<div class="muted">
|
||||
When enabled, journals are written to <Mono>journal@dezky.com</Mono>. Storage usage counts against your plan. Available on Business and Enterprise plans.
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<div class="card-head card-head-inline">
|
||||
<div>
|
||||
<Eyebrow>Legal hold</Eyebrow>
|
||||
<div class="card-title">Legal hold cases</div>
|
||||
</div>
|
||||
<UiButton size="sm" variant="secondary" @click="holdOpen = true">
|
||||
<template #leading><UiIcon name="plus" :size="13" /></template>
|
||||
New hold
|
||||
</UiButton>
|
||||
</div>
|
||||
<div class="empty">
|
||||
<UiIcon name="shield" :size="28" stroke="var(--text-mute)" />
|
||||
<div class="empty-title">No active holds</div>
|
||||
<div class="empty-body">When legal review is needed, place a hold on specific users or date ranges to prevent deletion.</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Add alias modal (stub) -->
|
||||
<Modal :open="addAliasOpen" eyebrow="Mail · aliases" title="Add alias" size="md" @close="addAliasOpen = false">
|
||||
<div class="form-stack">
|
||||
<label class="field"><Eyebrow>Alias address</Eyebrow>
|
||||
<div class="alias-row">
|
||||
<input class="input" value="marketing" placeholder="prefix" />
|
||||
<span class="at">@</span>
|
||||
<select class="input"><option>dezky.com</option><option>baslund.dk</option></select>
|
||||
</div>
|
||||
</label>
|
||||
<label class="field"><Eyebrow>Route to user</Eyebrow><input class="input" value="frederik@dezky.com" /></label>
|
||||
</div>
|
||||
<template #footer>
|
||||
<UiButton variant="ghost" @click="addAliasOpen = false">Cancel</UiButton>
|
||||
<UiButton variant="primary" @click="addAliasOpen = false">
|
||||
<template #leading><UiIcon name="check" :size="13" /></template>
|
||||
Create alias
|
||||
</UiButton>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
<!-- Forwarding rule modal -->
|
||||
<Modal :open="ruleOpen" eyebrow="Mail · forwarding" title="New forwarding rule" size="md" @close="ruleOpen = false">
|
||||
<div class="form-stack">
|
||||
<label class="field"><Eyebrow>Rule name</Eyebrow><input class="input" placeholder="Out-of-hours to on-call" /></label>
|
||||
<label class="field"><Eyebrow>When</Eyebrow><input class="input" placeholder="subject: …" /></label>
|
||||
<label class="field"><Eyebrow>Forward to</Eyebrow><input class="input" placeholder="oncall@dezky.com" /></label>
|
||||
</div>
|
||||
<template #footer>
|
||||
<UiButton variant="ghost" @click="ruleOpen = false">Cancel</UiButton>
|
||||
<UiButton variant="primary" @click="ruleOpen = false">
|
||||
<template #leading><UiIcon name="check" :size="13" /></template>
|
||||
Save rule
|
||||
</UiButton>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
<!-- New filter modal -->
|
||||
<Modal :open="filterOpen" eyebrow="Mail · filters" title="New content filter" size="md" @close="filterOpen = false">
|
||||
<div class="form-stack">
|
||||
<label class="field"><Eyebrow>Filter name</Eyebrow><input class="input" placeholder="Block executable attachments" /></label>
|
||||
<label class="field"><Eyebrow>Match expression</Eyebrow><input class="input" placeholder="attachment ext in (.exe, .scr, .bat)" /></label>
|
||||
<label class="field"><Eyebrow>Action</Eyebrow>
|
||||
<select class="input"><option>reject</option><option>quarantine</option><option>add tag</option></select>
|
||||
</label>
|
||||
</div>
|
||||
<template #footer>
|
||||
<UiButton variant="ghost" @click="filterOpen = false">Cancel</UiButton>
|
||||
<UiButton variant="primary" @click="filterOpen = false">Create filter</UiButton>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
<!-- New list modal -->
|
||||
<Modal :open="listOpen" eyebrow="Mail · lists" title="New distribution list" size="md" @close="listOpen = false">
|
||||
<div class="form-stack">
|
||||
<label class="field"><Eyebrow>List name</Eyebrow><input class="input" placeholder="Engineering" /></label>
|
||||
<label class="field"><Eyebrow>Alias</Eyebrow><input class="input" placeholder="eng@dezky.com" /></label>
|
||||
<label class="field"><Eyebrow>Owner</Eyebrow><input class="input" value="Anne Baslund" /></label>
|
||||
</div>
|
||||
<template #footer>
|
||||
<UiButton variant="ghost" @click="listOpen = false">Cancel</UiButton>
|
||||
<UiButton variant="primary" @click="listOpen = false">Create list</UiButton>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
<!-- Manage list side panel -->
|
||||
<SidePanel :open="!!openList" eyebrow="Distribution list" :title="openList?.name || ''" width="lg" @close="openList = null">
|
||||
<div v-if="openList" class="manage">
|
||||
<div class="manage-head">
|
||||
<div class="manage-icon"><UiIcon name="users" :size="20" /></div>
|
||||
<div class="manage-meta">
|
||||
<div class="manage-name">{{ openList.name }}</div>
|
||||
<Mono dim>{{ openList.alias }}</Mono>
|
||||
</div>
|
||||
<Badge :tone="openList.moderation === 'open' ? 'ok' : 'neutral'">{{ openList.moderation }}</Badge>
|
||||
</div>
|
||||
<div class="manage-stats">
|
||||
<div><Eyebrow>Members</Eyebrow><div class="ms-v">{{ openList.members }}</div></div>
|
||||
<div><Eyebrow>Owner</Eyebrow><div class="ms-v">{{ openList.owner }}</div></div>
|
||||
<div><Eyebrow>Posts this week</Eyebrow><div class="ms-v">{{ openList.members > 8 ? '142' : openList.members > 2 ? '38' : '6' }}</div></div>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<UiButton variant="danger" @click="deleteListOpen = true">
|
||||
<template #leading><UiIcon name="trash" :size="13" /></template>
|
||||
Delete list
|
||||
</UiButton>
|
||||
<div style="flex: 1" />
|
||||
<UiButton variant="secondary" @click="openList = null">Discard</UiButton>
|
||||
<UiButton variant="primary" @click="openList = null; toast.ok('List saved')">
|
||||
<template #leading><UiIcon name="check" :size="13" /></template>
|
||||
Save changes
|
||||
</UiButton>
|
||||
</template>
|
||||
</SidePanel>
|
||||
|
||||
<!-- Confirm delete list -->
|
||||
<ConfirmDialog
|
||||
:open="deleteListOpen"
|
||||
eyebrow="Distribution list"
|
||||
:title="`Delete ${openList?.name || ''}?`"
|
||||
confirm-label="Delete list"
|
||||
tone="danger"
|
||||
@close="deleteListOpen = false"
|
||||
@confirm="confirmDeleteList"
|
||||
>
|
||||
Mail sent to this list will start bouncing immediately. Existing replies in members' inboxes are unaffected.
|
||||
</ConfirmDialog>
|
||||
|
||||
<!-- Legal hold modal -->
|
||||
<Modal :open="holdOpen" eyebrow="Compliance · legal hold" title="Place legal hold" size="md" @close="holdOpen = false">
|
||||
<div class="form-stack">
|
||||
<label class="field"><Eyebrow>Case name</Eyebrow><input class="input" placeholder="Case 2026-Q3-DPA-001" /></label>
|
||||
<label class="field"><Eyebrow>Scope · users</Eyebrow><input class="input" placeholder="anne@, mikkel@, frederik@" /></label>
|
||||
<label class="field"><Eyebrow>Date range</Eyebrow><input class="input" placeholder="2026-01-01 → 2026-12-31" /></label>
|
||||
</div>
|
||||
<template #footer>
|
||||
<UiButton variant="ghost" @click="holdOpen = false">Cancel</UiButton>
|
||||
<UiButton variant="primary" @click="holdOpen = false">Place hold</UiButton>
|
||||
</template>
|
||||
</Modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.tab-wrap { padding: 16px 40px 0 40px; }
|
||||
.content { padding: 20px 40px 64px 40px; }
|
||||
|
||||
.row { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; }
|
||||
.lead { font-size: 13px; color: var(--text-mute); max-width: 540px; line-height: 1.5; }
|
||||
|
||||
.tbl { width: 100%; border-collapse: collapse; }
|
||||
.tbl th {
|
||||
text-align: left;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-mute);
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-weight: 500;
|
||||
}
|
||||
.tbl td { padding: 12px 16px; border-bottom: 1px solid var(--border); font-size: 13px; }
|
||||
.tbl tr:last-child td { border-bottom: none; }
|
||||
.tbl .right { text-align: right; }
|
||||
|
||||
.rules { display: flex; flex-direction: column; gap: 10px; }
|
||||
.rule-row { display: flex; align-items: center; gap: 14px; }
|
||||
.rule-meta { flex: 1; }
|
||||
.rule-name { font-size: 14px; font-weight: 500; }
|
||||
.rule-line { display: flex; align-items: center; gap: 8px; margin-top: 6px; font-size: 12px; }
|
||||
.rule-match { font-family: var(--font-mono); color: var(--text-dim); }
|
||||
|
||||
.toggle {
|
||||
width: 32px;
|
||||
height: 18px;
|
||||
border-radius: 999px;
|
||||
background: var(--border);
|
||||
border: none;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.toggle span {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 999px;
|
||||
background: var(--bg);
|
||||
transition: left 120ms;
|
||||
}
|
||||
.toggle.on { background: var(--text); }
|
||||
.toggle.on span { left: 16px; background: var(--accent); }
|
||||
|
||||
.filter-name { display: flex; align-items: center; gap: 10px; }
|
||||
.builtin {
|
||||
margin-top: 16px;
|
||||
padding: 14px;
|
||||
background: var(--bg);
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border);
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
.builtin-title { font-size: 13px; font-weight: 600; }
|
||||
.builtin-sub { color: var(--text-mute); margin-top: 4px; font-size: 13px; }
|
||||
|
||||
.lists { display: grid; grid-template-columns: repeat(2, 1fr); gap: 12px; }
|
||||
.list-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
|
||||
.list-title { display: flex; align-items: center; gap: 8px; }
|
||||
.list-name { font-family: var(--font-display); font-weight: 600; font-size: 16px; }
|
||||
.list-row { display: flex; gap: 18px; margin-top: 18px; padding-top: 14px; border-top: 1px solid var(--border); align-items: flex-end; }
|
||||
.list-num { font-family: var(--font-display); font-weight: 600; font-size: 20px; margin-top: 4px; }
|
||||
.list-owner { flex: 1; font-size: 13px; }
|
||||
|
||||
.compliance { display: flex; flex-direction: column; gap: 16px; max-width: 900px; }
|
||||
.card-head { margin-bottom: 12px; }
|
||||
.card-head-inline { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
|
||||
.card-title { font-family: var(--font-display); font-weight: 600; font-size: 18px; letter-spacing: -0.01em; margin-top: 4px; }
|
||||
.card-sub { font-size: 13px; color: var(--text-mute); margin-top: 4px; }
|
||||
.muted { font-size: 13px; color: var(--text-mute); line-height: 1.6; }
|
||||
.radio-big { display: flex; flex-direction: column; gap: 8px; }
|
||||
.radio-big label { display: flex; gap: 12px; padding: 14px; border: 1px solid var(--border); border-radius: 6px; cursor: pointer; }
|
||||
.radio-big label.active { border-color: var(--text); background: var(--bg); }
|
||||
.radio-big input { display: none; }
|
||||
.radio-dot { width: 18px; height: 18px; border-radius: 999px; border: 2px solid var(--border); display: inline-flex; align-items: center; justify-content: center; flex-shrink: 0; margin-top: 2px; }
|
||||
.radio-big label.active .radio-dot { border-color: var(--text); }
|
||||
.radio-dot span { width: 8px; height: 8px; border-radius: 999px; background: var(--text); }
|
||||
.radio-label { font-size: 14px; font-weight: 500; }
|
||||
.radio-d { font-size: 12px; color: var(--text-mute); margin-top: 4px; }
|
||||
|
||||
.empty { padding: 36px 24px; text-align: center; display: flex; flex-direction: column; align-items: center; gap: 8px; }
|
||||
.empty-title { font-family: var(--font-display); font-weight: 600; font-size: 15px; }
|
||||
.empty-body { font-size: 13px; color: var(--text-mute); max-width: 420px; line-height: 1.5; }
|
||||
|
||||
/* Modal forms */
|
||||
.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); }
|
||||
.alias-row { display: grid; grid-template-columns: 1fr auto 1fr; gap: 8px; align-items: center; }
|
||||
.at { font-family: var(--font-mono); color: var(--text-mute); }
|
||||
|
||||
.manage { padding-bottom: 24px; }
|
||||
.manage-head { display: flex; align-items: center; gap: 14px; }
|
||||
.manage-icon {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 10px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.manage-meta { flex: 1; min-width: 0; }
|
||||
.manage-name { font-family: var(--font-display); font-weight: 600; font-size: 18px; }
|
||||
.manage-stats { display: flex; gap: 24px; margin-top: 16px; }
|
||||
.ms-v { font-size: 13px; margin-top: 4px; font-weight: 500; }
|
||||
</style>
|
||||
@@ -0,0 +1,380 @@
|
||||
<script setup lang="ts">
|
||||
// Strict port of project/platform-collab.jsx `MeetingsScreen` (lines 71-260)
|
||||
// with Rooms / Recordings / Settings tabs and source's sample data.
|
||||
|
||||
|
||||
import { meetingRooms, meetingRecordings } from '~/data/workspace'
|
||||
|
||||
const tab = ref<'rooms' | 'recordings' | 'settings'>('rooms')
|
||||
const newRoomOpen = ref(false)
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
function roomAction(name: string, alias: string, id: string) {
|
||||
if (id === 'start') toast.info(`Joining ${name}…`)
|
||||
else if (id === 'copy') {
|
||||
navigator.clipboard?.writeText(`meet.dezky.com/${alias}`).catch(() => {})
|
||||
toast.ok('Room link copied', `meet.dezky.com/${alias}`)
|
||||
}
|
||||
else if (id === 'edit') toast.info(`Edit ${name}`)
|
||||
else if (id === 'history') toast.info(`Meeting history for ${name}`)
|
||||
else if (id === 'delete') toast.bad(`${name} deleted`)
|
||||
}
|
||||
const roomItems = [
|
||||
{ id: 'start', label: 'Start meeting', icon: 'video' as const },
|
||||
{ id: 'copy', label: 'Copy room link', icon: 'copy' as const },
|
||||
{ id: 'edit', label: 'Edit room…', icon: 'brush' as const },
|
||||
{ id: 'history', label: 'Meeting history', icon: 'file' as const },
|
||||
{ id: 'sep1', separator: true },
|
||||
{ id: 'delete', label: 'Delete room', icon: 'trash' as const, danger: true },
|
||||
]
|
||||
|
||||
function recAction(title: string, id: string) {
|
||||
if (id === 'play') toast.info(`Playing "${title}"`)
|
||||
else if (id === 'download') toast.info(`Downloading "${title}"`)
|
||||
else if (id === 'share') toast.ok('Share link copied')
|
||||
else if (id === 'transcript') toast.info('Opening transcript')
|
||||
else if (id === 'hold') toast.warn(`Legal hold placed on "${title}"`)
|
||||
else if (id === 'delete') toast.bad(`"${title}" deleted`)
|
||||
}
|
||||
const recItems = [
|
||||
{ id: 'play', label: 'Play', icon: 'video' as const },
|
||||
{ id: 'download', label: 'Download MP4', icon: 'download' as const },
|
||||
{ id: 'share', label: 'Copy share link', icon: 'copy' as const },
|
||||
{ id: 'transcript', label: 'Open transcript', icon: 'file' as const },
|
||||
{ id: 'sep1', separator: true },
|
||||
{ id: 'hold', label: 'Place legal hold', icon: 'shield' as const },
|
||||
{ id: 'delete', label: 'Delete recording', icon: 'trash' as const, danger: true },
|
||||
]
|
||||
|
||||
const totalSize = computed(() =>
|
||||
meetingRecordings.reduce((s, r) => s + parseInt(r.size), 0),
|
||||
)
|
||||
|
||||
const defaults: Array<{ l: string; v: boolean; d: string }> = [
|
||||
{ l: 'Require lobby', v: true, d: 'Participants wait until host admits them.' },
|
||||
{ l: 'End-to-end encryption', v: true, d: 'Available 1:1 and small group rooms.' },
|
||||
{ l: 'Allow guest links', v: true, d: 'External participants can join via link.' },
|
||||
{ l: 'Recording on by default', v: false, d: 'Override per room when needed.' },
|
||||
{ l: 'Transcription', v: true, d: 'Auto-generate transcripts in Danish + English.' },
|
||||
]
|
||||
|
||||
const recordingPolicy = ref<'off' | 'auto' | 'manual'>('auto')
|
||||
const recordingOptions = [
|
||||
{ v: 'off' as const, label: 'Disable recording org-wide', d: 'Hosts cannot record. Useful for regulated environments.' },
|
||||
{ v: 'auto' as const, label: 'Allow · keep in Drev · 365 d', d: 'Recordings auto-save to /Recordings folder. Auto-delete after 365 days unless on legal hold.' },
|
||||
{ v: 'manual' as const, label: 'Allow · host downloads only', d: 'Recordings are not stored on the platform. Host gets a download link valid for 24h.' },
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<PageHeader
|
||||
eyebrow="Møder · Jitsi"
|
||||
title="Meeting settings"
|
||||
subtitle="Persistent rooms, recordings, and default meeting policy for your workspace."
|
||||
/>
|
||||
<div class="tab-wrap">
|
||||
<Tabs
|
||||
v-model="tab"
|
||||
:items="[
|
||||
{ value: 'rooms', label: 'Rooms', count: meetingRooms.length },
|
||||
{ value: 'recordings', label: 'Recordings', count: meetingRecordings.length },
|
||||
{ value: 'settings', label: 'Settings' },
|
||||
]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<template v-if="tab === 'rooms'">
|
||||
<div class="row">
|
||||
<div class="lead">Persistent rooms keep a stable URL — meeting recordings and chat history stay tied to the room.</div>
|
||||
<UiButton variant="primary" @click="newRoomOpen = true">
|
||||
<template #leading><UiIcon name="plus" :size="14" /></template>
|
||||
New room
|
||||
</UiButton>
|
||||
</div>
|
||||
<Card :pad="0">
|
||||
<table class="tbl">
|
||||
<thead>
|
||||
<tr><th>Room</th><th>Type</th><th>Schedule</th><th>Owner</th><th>Recording</th><th class="right">Members</th><th /></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="r in meetingRooms" :key="r.id">
|
||||
<td>
|
||||
<div class="room-cell">
|
||||
<div class="room-icon"><UiIcon name="video" :size="14" /></div>
|
||||
<div>
|
||||
<div class="room-name">
|
||||
<span>{{ r.name }}</span>
|
||||
<UiIcon v-if="r.protected" name="shield" :size="11" stroke="var(--text-mute)" />
|
||||
</div>
|
||||
<Mono dim>meet.dezky.com/{{ r.alias }}</Mono>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td><Badge :tone="r.type === 'recurring' ? 'info' : 'neutral'">{{ r.type }}</Badge></td>
|
||||
<td class="meta">{{ r.when }}</td>
|
||||
<td>
|
||||
<div class="owner-cell">
|
||||
<Avatar :name="r.owner" :size="20" />
|
||||
<span>{{ r.owner }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td><Badge :tone="r.recording === 'auto' ? 'ok' : r.recording === 'off' ? 'neutral' : 'warn'">{{ r.recording }}</Badge></td>
|
||||
<td class="right"><Mono>{{ r.members }}</Mono></td>
|
||||
<td class="right"><AdminKebabMenu :items="roomItems" @select="(id) => roomAction(r.name, r.alias, id)" /></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<template v-else-if="tab === 'recordings'">
|
||||
<div class="rec-toolbar">
|
||||
<div class="input-search">
|
||||
<UiIcon name="search" :size="14" stroke="var(--text-mute)" />
|
||||
<input placeholder="Search by title, host, room…" />
|
||||
</div>
|
||||
<button class="chip"><Eyebrow>Retention:</Eyebrow> <span>All</span> <UiIcon name="chevDown" :size="12" stroke="var(--text-mute)" /></button>
|
||||
<button class="chip"><Eyebrow>Host:</Eyebrow> <span>Anyone</span> <UiIcon name="chevDown" :size="12" stroke="var(--text-mute)" /></button>
|
||||
<div class="spacer" />
|
||||
<Mono dim>{{ meetingRecordings.length }} recordings · {{ totalSize }} MB</Mono>
|
||||
</div>
|
||||
<Card :pad="0">
|
||||
<table class="tbl">
|
||||
<thead>
|
||||
<tr><th>Recording</th><th>Recorded</th><th>Host</th><th>Views</th><th>Retention</th><th /></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="r in meetingRecordings" :key="r.id">
|
||||
<td>
|
||||
<div class="rec-cell">
|
||||
<div class="rec-thumb"><UiIcon name="video" :size="13" /></div>
|
||||
<div>
|
||||
<div class="rec-title">{{ r.title }}</div>
|
||||
<Mono dim>{{ r.dur }} · {{ r.size }}</Mono>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td><Mono dim>{{ r.date }}</Mono></td>
|
||||
<td>
|
||||
<div class="owner-cell">
|
||||
<Avatar :name="r.host" :size="20" />
|
||||
<span>{{ r.host }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td><Mono>{{ r.views }}</Mono></td>
|
||||
<td><Badge :tone="r.retention === 'forever' ? 'invert' : r.retention === '365 d' ? 'info' : 'neutral'" dot>{{ r.retention }}{{ r.legal ? ' · hold' : '' }}</Badge></td>
|
||||
<td class="right"><AdminKebabMenu :items="recItems" @select="(id) => recAction(r.title, id)" /></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div class="settings">
|
||||
<Card>
|
||||
<div class="card-head">
|
||||
<Eyebrow>Defaults</Eyebrow>
|
||||
<div class="card-title">New room defaults</div>
|
||||
<div class="card-sub">What every new room inherits unless the creator overrides.</div>
|
||||
</div>
|
||||
<div class="defaults">
|
||||
<div v-for="r in defaults" :key="r.l" class="def-row">
|
||||
<button class="toggle" :class="{ on: r.v }"><span /></button>
|
||||
<div class="def-meta">
|
||||
<div class="def-label">{{ r.l }}</div>
|
||||
<div class="def-d">{{ r.d }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<div class="card-head">
|
||||
<Eyebrow>Recording policy</Eyebrow>
|
||||
<div class="card-title">Where recordings live</div>
|
||||
</div>
|
||||
<div class="radio-big">
|
||||
<label v-for="o in recordingOptions" :key="o.v" :class="{ active: recordingPolicy === o.v }">
|
||||
<span class="radio-dot"><span v-if="recordingPolicy === o.v" /></span>
|
||||
<input type="radio" :value="o.v" v-model="recordingPolicy" />
|
||||
<div>
|
||||
<div class="radio-label">{{ o.label }}</div>
|
||||
<div class="radio-d">{{ o.d }}</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<div class="card-head">
|
||||
<Eyebrow>Limits</Eyebrow>
|
||||
<div class="card-title">Capacity & quality</div>
|
||||
</div>
|
||||
<div class="limits">
|
||||
<div v-for="[k, v] in [
|
||||
['Max participants per room', '50'],
|
||||
['Default video resolution', '720p · adaptive'],
|
||||
['Recording resolution', '1080p'],
|
||||
]" :key="k">
|
||||
<Eyebrow>{{ k }}</Eyebrow>
|
||||
<div class="limit-v">{{ v }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<Modal :open="newRoomOpen" eyebrow="Meetings · rooms" title="New room" size="md" @close="newRoomOpen = false">
|
||||
<div class="form-stack">
|
||||
<label class="field"><Eyebrow>Name</Eyebrow><input class="input" placeholder="Engineering standup" /></label>
|
||||
<label class="field"><Eyebrow>Alias</Eyebrow><input class="input" placeholder="eng-standup" /></label>
|
||||
<label class="field"><Eyebrow>Owner</Eyebrow><input class="input" value="Anne Baslund" /></label>
|
||||
</div>
|
||||
<template #footer>
|
||||
<UiButton variant="ghost" @click="newRoomOpen = false">Cancel</UiButton>
|
||||
<UiButton variant="primary" @click="newRoomOpen = false">Create room</UiButton>
|
||||
</template>
|
||||
</Modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.tab-wrap { padding: 16px 40px 0 40px; }
|
||||
.content { padding: 20px 40px 64px 40px; }
|
||||
|
||||
.row { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; }
|
||||
.lead { font-size: 13px; color: var(--text-mute); max-width: 540px; line-height: 1.5; }
|
||||
|
||||
.tbl { width: 100%; border-collapse: collapse; }
|
||||
.tbl th {
|
||||
text-align: left;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-mute);
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-weight: 500;
|
||||
}
|
||||
.tbl td { padding: 12px 16px; border-bottom: 1px solid var(--border); font-size: 13px; vertical-align: middle; }
|
||||
.tbl tr:last-child td { border-bottom: none; }
|
||||
.tbl .right { text-align: right; }
|
||||
.meta { font-size: 12px; }
|
||||
|
||||
.room-cell { display: flex; align-items: center; gap: 12px; }
|
||||
.room-icon {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 6px;
|
||||
background: var(--bg);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.room-name { display: flex; align-items: center; gap: 6px; font-size: 13px; font-weight: 500; }
|
||||
.owner-cell { display: flex; align-items: center; gap: 8px; font-size: 12px; }
|
||||
|
||||
.rec-toolbar { display: flex; gap: 12px; align-items: center; margin-bottom: 16px; flex-wrap: wrap; }
|
||||
.input-search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 0 12px;
|
||||
height: 36px;
|
||||
width: 320px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
}
|
||||
.input-search input { flex: 1; border: none; outline: none; background: transparent; font-family: inherit; font-size: 13px; color: var(--text); }
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 36px;
|
||||
padding: 0 12px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
}
|
||||
.chip span { font-weight: 500; }
|
||||
.spacer { flex: 1; }
|
||||
|
||||
.rec-cell { display: flex; align-items: center; gap: 12px; }
|
||||
.rec-thumb {
|
||||
width: 64px;
|
||||
height: 36px;
|
||||
border-radius: 5px;
|
||||
background: var(--text);
|
||||
color: var(--bg);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.rec-title { font-size: 13px; font-weight: 500; }
|
||||
|
||||
.settings { display: flex; flex-direction: column; gap: 16px; max-width: 900px; }
|
||||
.card-head { margin-bottom: 14px; }
|
||||
.card-title { font-family: var(--font-display); font-weight: 600; font-size: 18px; letter-spacing: -0.01em; margin-top: 4px; }
|
||||
.card-sub { font-size: 13px; color: var(--text-mute); margin-top: 4px; }
|
||||
|
||||
.defaults { display: flex; flex-direction: column; gap: 14px; }
|
||||
.def-row { display: flex; align-items: center; gap: 16px; padding-bottom: 14px; border-bottom: 1px solid var(--border); }
|
||||
.def-row:last-child { padding-bottom: 0; border-bottom: none; }
|
||||
.def-meta { flex: 1; }
|
||||
.def-label { font-size: 13px; font-weight: 500; }
|
||||
.def-d { font-size: 12px; color: var(--text-mute); margin-top: 4px; }
|
||||
|
||||
.toggle {
|
||||
width: 32px;
|
||||
height: 18px;
|
||||
border-radius: 999px;
|
||||
background: var(--border);
|
||||
border: none;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.toggle span {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 999px;
|
||||
background: var(--bg);
|
||||
transition: left 120ms;
|
||||
}
|
||||
.toggle.on { background: var(--text); }
|
||||
.toggle.on span { left: 16px; background: var(--accent); }
|
||||
|
||||
.radio-big { display: flex; flex-direction: column; gap: 8px; }
|
||||
.radio-big label { display: flex; gap: 12px; padding: 14px; border: 1px solid var(--border); border-radius: 6px; cursor: pointer; }
|
||||
.radio-big label.active { border-color: var(--text); background: var(--bg); }
|
||||
.radio-big input { display: none; }
|
||||
.radio-dot { width: 18px; height: 18px; border-radius: 999px; border: 2px solid var(--border); display: inline-flex; align-items: center; justify-content: center; flex-shrink: 0; margin-top: 2px; }
|
||||
.radio-big label.active .radio-dot { border-color: var(--text); }
|
||||
.radio-dot span { width: 8px; height: 8px; border-radius: 999px; background: var(--text); }
|
||||
.radio-label { font-size: 14px; font-weight: 500; }
|
||||
.radio-d { font-size: 12px; color: var(--text-mute); margin-top: 4px; }
|
||||
|
||||
.limits { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; }
|
||||
.limit-v { font-family: var(--font-display); font-weight: 600; font-size: 18px; margin-top: 6px; font-variant-numeric: tabular-nums; }
|
||||
|
||||
.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; }
|
||||
</style>
|
||||
@@ -0,0 +1,400 @@
|
||||
<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.
|
||||
|
||||
|
||||
import { sampleAudit } from '~/data/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 },
|
||||
]
|
||||
|
||||
function removeCountry(c: string) {
|
||||
toast.info(`${c} removed from allow-list`)
|
||||
}
|
||||
|
||||
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 mfaOptions = [
|
||||
{ v: 'all' as const, label: 'Required for everyone', d: 'All members must enroll TOTP or WebAuthn at next sign-in.' },
|
||||
{ v: 'admins' as const, label: 'Required for admins only', d: 'Members may opt in. Admins are forced to enroll.' },
|
||||
{ v: 'optional' as const, label: 'Optional', d: 'No enforcement. Not recommended for compliance work.' },
|
||||
]
|
||||
|
||||
const countries = ['Denmark', 'Sweden', 'Norway', 'Germany', 'Netherlands']
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<PageHeader
|
||||
eyebrow="Compliance"
|
||||
title="Security & audit"
|
||||
subtitle="Policies, identity controls, and a tamper-evident log of every administrative action."
|
||||
/>
|
||||
|
||||
<div class="tab-wrap">
|
||||
<Tabs
|
||||
v-model="tab"
|
||||
:items="[
|
||||
{ value: 'security', label: 'Security' },
|
||||
{ value: 'audit', label: 'Audit log', count: 4218 },
|
||||
]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="tab === 'security'" class="content security">
|
||||
<Card>
|
||||
<div class="card-head">
|
||||
<Eyebrow>Identity</Eyebrow>
|
||||
<div class="card-title">Multi-factor authentication</div>
|
||||
</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" />
|
||||
<div>
|
||||
<div class="radio-label">{{ o.label }}</div>
|
||||
<div class="radio-d">{{ o.d }}</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<div class="card-head">
|
||||
<Eyebrow>Sessions</Eyebrow>
|
||||
<div class="card-title">Session policy</div>
|
||||
</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>
|
||||
<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>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<div class="card-head">
|
||||
<Eyebrow>Network</Eyebrow>
|
||||
<div class="card-title">Geo-fencing & allow-lists</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<Eyebrow>Allowed countries</Eyebrow>
|
||||
<div class="chip-row">
|
||||
<Badge v-for="c in countries" :key="c" tone="neutral">
|
||||
{{ c }}
|
||||
<button class="badge-x" @click="removeCountry(c)" aria-label="Remove country">
|
||||
<UiIcon name="x" :size="10" />
|
||||
</button>
|
||||
</Badge>
|
||||
<UiButton size="sm" variant="ghost" @click="addCountryOpen = true">
|
||||
<template #leading><UiIcon name="plus" :size="12" /></template>
|
||||
Add country
|
||||
</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>
|
||||
<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 class="sso-meta">
|
||||
<div class="sso-name">{{ a.n }}</div>
|
||||
<Mono dim>{{ a.p }} · provisioned</Mono>
|
||||
</div>
|
||||
<Badge :tone="a.s" dot>{{ a.s === 'ok' ? 'connected' : 'cert expiring' }}</Badge>
|
||||
<AdminKebabMenu :items="ssoItems" @select="(id) => ssoAction(a.n, id)" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div v-else class="content audit">
|
||||
<div class="toolbar">
|
||||
<div class="input-search">
|
||||
<UiIcon name="search" :size="14" stroke="var(--text-mute)" />
|
||||
<input placeholder="action.type, 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>
|
||||
<div class="spacer" />
|
||||
<UiButton variant="secondary" @click="toast.info('Exporting audit log…', 'CSV · last 7 days · ~4,218 events')">
|
||||
<template #leading><UiIcon name="download" :size="14" /></template>
|
||||
Export CSV
|
||||
</UiButton>
|
||||
</div>
|
||||
<Card :pad="0">
|
||||
<table class="audit-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Time</th>
|
||||
<th>Actor</th>
|
||||
<th>Action</th>
|
||||
<th>Target</th>
|
||||
<th>IP</th>
|
||||
<th class="right" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="a in sampleAudit" :key="a.id">
|
||||
<td><Mono>{{ a.when }}</Mono></td>
|
||||
<td>
|
||||
<div class="actor-cell">
|
||||
<Avatar v-if="a.actor !== 'system'" :name="a.actor" :size="22" />
|
||||
<div v-else class="sys">sys</div>
|
||||
<span>{{ a.actor }}</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>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</Card>
|
||||
<div class="retention">
|
||||
<Mono dim>// retention · 365 days · tamper-evident · last verified 14:32:01 today</Mono>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add country modal -->
|
||||
<Modal :open="addCountryOpen" eyebrow="Security · geo-fencing" title="Add country to allow-list" size="sm" @close="addCountryOpen = false">
|
||||
<div class="form-stack">
|
||||
<label class="field"><Eyebrow>Country</Eyebrow>
|
||||
<CountrySelect v-model="newAllowCountry" placeholder="Search countries" />
|
||||
</label>
|
||||
</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 = ''"
|
||||
>
|
||||
Add
|
||||
</UiButton>
|
||||
</template>
|
||||
</Modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.tab-wrap { padding: 16px 40px 0 40px; }
|
||||
.content { padding: 24px 40px 64px 40px; }
|
||||
.content.security { display: flex; flex-direction: column; gap: 16px; max-width: 1100px; }
|
||||
|
||||
.card-head { margin-bottom: 16px; }
|
||||
.card-title {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 600;
|
||||
font-size: 18px;
|
||||
letter-spacing: -0.01em;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* RadioBig */
|
||||
.radio-big { display: flex; flex-direction: column; gap: 8px; }
|
||||
.radio-big label {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 14px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.radio-big label.active { border-color: var(--text); background: var(--bg); }
|
||||
.radio-big input { display: none; }
|
||||
.radio-dot {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 999px;
|
||||
border: 2px solid var(--border-hi, var(--border));
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.radio-big label.active .radio-dot { border-color: var(--text); }
|
||||
.radio-dot span { width: 8px; height: 8px; border-radius: 999px; background: var(--text); }
|
||||
.radio-label { font-size: 14px; font-weight: 500; }
|
||||
.radio-d { font-size: 12px; color: var(--text-mute); margin-top: 4px; }
|
||||
|
||||
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
||||
.field { display: flex; flex-direction: column; gap: 6px; }
|
||||
.input-faux {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 0 12px;
|
||||
height: 36px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
}
|
||||
.input-faux input {
|
||||
flex: 1;
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.chip-row { display: flex; gap: 6px; flex-wrap: wrap; align-items: center; }
|
||||
|
||||
.sso-intro { font-size: 13px; color: var(--text-dim); margin-bottom: 12px; line-height: 1.5; }
|
||||
.sso-list { display: flex; flex-direction: column; gap: 8px; }
|
||||
.sso-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
background: var(--bg);
|
||||
border-radius: 6px;
|
||||
}
|
||||
.sso-icon {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 5px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: var(--font-mono);
|
||||
font-weight: 700;
|
||||
font-size: 12px;
|
||||
}
|
||||
.sso-meta { flex: 1; }
|
||||
.sso-name { font-size: 13px; font-weight: 500; }
|
||||
|
||||
/* Audit toolbar + table */
|
||||
.toolbar { display: flex; gap: 8px; margin-bottom: 12px; flex-wrap: wrap; align-items: center; }
|
||||
.input-search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 0 12px;
|
||||
height: 36px;
|
||||
width: 360px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
}
|
||||
.input-search input { flex: 1; border: none; outline: none; background: transparent; font-family: inherit; font-size: 13px; color: var(--text); }
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 36px;
|
||||
padding: 0 12px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
}
|
||||
.chip span { font-weight: 500; }
|
||||
.spacer { flex: 1; }
|
||||
|
||||
.audit-table { width: 100%; border-collapse: collapse; }
|
||||
.audit-table thead th {
|
||||
text-align: left;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-mute);
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-weight: 500;
|
||||
}
|
||||
.audit-table tbody td {
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 13px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.audit-table tbody tr:last-child td { border-bottom: none; }
|
||||
.audit-table .right { text-align: right; }
|
||||
.target { color: var(--text-dim); }
|
||||
.actor-cell { display: flex; align-items: center; gap: 8px; }
|
||||
.sys {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 5px;
|
||||
background: var(--text);
|
||||
color: var(--bg);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.retention { margin-top: 12px; font-size: 12px; color: var(--text-mute); }
|
||||
|
||||
.badge-x {
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 0;
|
||||
margin-left: 4px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
color: inherit;
|
||||
}
|
||||
.badge-x:hover { color: var(--bad); }
|
||||
|
||||
/* Add country 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); }
|
||||
</style>
|
||||
@@ -0,0 +1,108 @@
|
||||
<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.
|
||||
|
||||
|
||||
import { sampleUsersFlat } from '~/data/workspace'
|
||||
|
||||
const topUsers = computed(() =>
|
||||
[...sampleUsersFlat].slice(0, 5).sort((a, b) => b.storage - a.storage),
|
||||
)
|
||||
|
||||
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)'],
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<PageHeader
|
||||
eyebrow="Drev"
|
||||
title="Storage"
|
||||
subtitle="Aggregate file storage across your workspace, by user and type."
|
||||
/>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
<div class="progress thin"><span :style="{ width: Math.min(100, (u.storage / 50) * 100) + '%' }" /></div>
|
||||
<Mono>{{ u.storage }} GB</Mono>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<div class="card-head">
|
||||
<Eyebrow>By type</Eyebrow>
|
||||
<div class="card-title">What's taking space</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>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.content { padding: 24px 40px 64px 40px; display: grid; grid-template-columns: 1.4fr 1fr; gap: 16px; max-width: 1200px; }
|
||||
|
||||
.card-head { margin-bottom: 16px; }
|
||||
.card-title { font-family: var(--font-display); font-weight: 600; font-size: 18px; letter-spacing: -0.01em; margin-top: 4px; }
|
||||
.card-sub { font-size: 13px; color: var(--text-mute); margin-top: 4px; }
|
||||
|
||||
.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 {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-top: 8px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--text-mute);
|
||||
}
|
||||
|
||||
.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; }
|
||||
.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); }
|
||||
</style>
|
||||
@@ -0,0 +1,856 @@
|
||||
<script setup lang="ts">
|
||||
// Strict port of project/platform-screens.jsx `UsersScreen` (lines 625-768)
|
||||
// with FilterChip (770), UserDetailPanel (816), DefList (948), InviteUserModal
|
||||
// (961), plus GroupsTabRich from platform-admin.jsx (1022), InvitationsTab and
|
||||
// ServiceAccountsTab (platform-screens.jsx 1090, 1123).
|
||||
//
|
||||
// User detail panel tabs follow the source order: Profile · Access · Mail ·
|
||||
// Files · Activity · Audit (no Danger zone in the source).
|
||||
|
||||
|
||||
import { sampleUsersFlat, groupsFull, sampleAudit } from '~/data/workspace'
|
||||
|
||||
type User = (typeof sampleUsersFlat)[number]
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
const tab = ref<'users' | 'groups' | 'invitations' | 'service'>('users')
|
||||
const query = ref('')
|
||||
const statusFilter = ref<'all' | 'active' | 'invited' | 'suspended'>('all')
|
||||
const selected = ref<Set<string>>(new Set())
|
||||
const openUser = ref<User | null>(null)
|
||||
const userTab = ref<'profile' | 'access' | 'mail' | 'files' | 'activity' | 'audit'>('profile')
|
||||
const inviteOpen = ref(false)
|
||||
const inviteStep = ref(1)
|
||||
const importOpen = ref(false)
|
||||
|
||||
const filteredUsers = computed(() =>
|
||||
sampleUsersFlat.filter((u) => {
|
||||
if (statusFilter.value !== 'all' && u.status !== statusFilter.value) return false
|
||||
if (query.value && !`${u.name} ${u.email}`.toLowerCase().includes(query.value.toLowerCase())) return false
|
||||
return true
|
||||
}),
|
||||
)
|
||||
|
||||
const invites = computed(() => sampleUsersFlat.filter((u) => u.status === 'invited'))
|
||||
|
||||
const statusTone = (s: string): 'ok' | 'warn' | 'bad' =>
|
||||
s === 'active' ? 'ok' : s === 'invited' ? 'warn' : 'bad'
|
||||
|
||||
function toggleSelect(id: string) {
|
||||
const s = new Set(selected.value)
|
||||
if (s.has(id)) s.delete(id)
|
||||
else s.add(id)
|
||||
selected.value = s
|
||||
}
|
||||
function clearSelection() { selected.value = new Set() }
|
||||
|
||||
watch(openUser, (u) => { if (u) userTab.value = 'profile' })
|
||||
|
||||
// Filter chip
|
||||
type ChipOption = { value: string; label: string }
|
||||
const statusOptions: ChipOption[] = [
|
||||
{ value: 'all', label: 'All' },
|
||||
{ value: 'active', label: 'Active' },
|
||||
{ value: 'invited', label: 'Invited' },
|
||||
{ value: 'suspended', label: 'Suspended' },
|
||||
]
|
||||
|
||||
// Groups tab
|
||||
const openGroup = ref<typeof groupsFull[number] | null>(null)
|
||||
const createGroupOpen = ref(false)
|
||||
|
||||
// Bulk-action modals + confirm
|
||||
const assignGroupOpen = ref(false)
|
||||
const changeRoleOpen = ref(false)
|
||||
const suspendOpen = ref(false)
|
||||
const groupChoice = ref<Set<string>>(new Set())
|
||||
const roleChoice = ref<'member' | 'admin' | 'owner'>('member')
|
||||
|
||||
function sendInvite() {
|
||||
inviteOpen.value = false
|
||||
inviteStep.value = 1
|
||||
toast.ok('Invitation sent to magnus@dezky.com')
|
||||
}
|
||||
|
||||
function applyBulkGroup() {
|
||||
const n = selected.value.size
|
||||
const gs = [...groupChoice.value].join(', ') || '—'
|
||||
assignGroupOpen.value = false
|
||||
toast.ok(`${n} user${n === 1 ? '' : 's'} added to: ${gs}`)
|
||||
groupChoice.value = new Set()
|
||||
}
|
||||
|
||||
function applyBulkRole() {
|
||||
const n = selected.value.size
|
||||
changeRoleOpen.value = false
|
||||
toast.ok(`${n} user${n === 1 ? '' : 's'} set to ${roleChoice.value}`)
|
||||
}
|
||||
|
||||
function applyBulkSuspend() {
|
||||
const n = selected.value.size
|
||||
suspendOpen.value = false
|
||||
toast.warn(`${n} user${n === 1 ? '' : 's'} suspended`, 'Sign-in blocked · data preserved')
|
||||
selected.value = new Set()
|
||||
}
|
||||
|
||||
function bulkExport() {
|
||||
const n = selected.value.size
|
||||
toast.info(`Exporting ${n} user${n === 1 ? '' : 's'}…`, 'CSV with profile + access columns')
|
||||
}
|
||||
|
||||
function toggleGroup(g: string) {
|
||||
const s = new Set(groupChoice.value)
|
||||
if (s.has(g)) s.delete(g)
|
||||
else s.add(g)
|
||||
groupChoice.value = s
|
||||
}
|
||||
|
||||
// Per-row kebab — open the user detail panel by default.
|
||||
function rowAction(u: User, id: string) {
|
||||
if (id === 'open') openUser.value = u
|
||||
else if (id === 'reset') toast.info(`Password reset link sent to ${u.email}`)
|
||||
else if (id === 'force') toast.info(`Forcing logout for ${u.name}`)
|
||||
else if (id === 'suspend') toast.warn(`${u.name} suspended`)
|
||||
else if (id === 'delete') toast.bad(`${u.name} deletion scheduled`)
|
||||
}
|
||||
|
||||
function groupAction(g: typeof groupsFull[number], id: string) {
|
||||
if (id === 'open') openGroup.value = g
|
||||
else if (id === 'rename') toast.info(`Rename ${g.name}`)
|
||||
else if (id === 'duplicate') toast.info(`Duplicated ${g.name}`)
|
||||
else if (id === 'delete') toast.bad(`${g.name} deletion scheduled`)
|
||||
}
|
||||
|
||||
const userRowItems = [
|
||||
{ id: 'open', label: 'Open profile', icon: 'external' as const },
|
||||
{ id: 'reset', label: 'Send password reset', icon: 'key' as const },
|
||||
{ id: 'force', label: 'Force logout', icon: 'logout' as const },
|
||||
{ id: 'sep1', separator: true },
|
||||
{ id: 'suspend', label: 'Suspend user', icon: 'shield' as const, danger: true },
|
||||
{ id: 'delete', label: 'Delete user', icon: 'trash' as const, danger: true },
|
||||
]
|
||||
|
||||
const groupRowItems = [
|
||||
{ id: 'open', label: 'Open group', icon: 'external' as const },
|
||||
{ id: 'rename', label: 'Rename', icon: 'brush' as const },
|
||||
{ id: 'duplicate', label: 'Duplicate', icon: 'copy' as const },
|
||||
{ id: 'sep1', separator: true },
|
||||
{ id: 'delete', label: 'Delete group',icon: 'trash' as const, danger: true },
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<PageHeader
|
||||
eyebrow="Identity"
|
||||
title="Users & groups"
|
||||
subtitle="Manage workspace members, their access, and group assignments."
|
||||
>
|
||||
<template #actions>
|
||||
<UiButton variant="secondary" @click="importOpen = true">
|
||||
<template #leading><UiIcon name="upload" :size="14" /></template>
|
||||
Import CSV
|
||||
</UiButton>
|
||||
<UiButton variant="secondary">
|
||||
<template #leading><UiIcon name="download" :size="14" /></template>
|
||||
Export
|
||||
</UiButton>
|
||||
<UiButton variant="primary" @click="inviteOpen = true">
|
||||
<template #leading><UiIcon name="plus" :size="14" /></template>
|
||||
Invite user
|
||||
</UiButton>
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<div class="tab-wrap">
|
||||
<Tabs
|
||||
v-model="tab"
|
||||
:items="[
|
||||
{ value: 'users', label: 'Users', count: sampleUsersFlat.length },
|
||||
{ value: 'groups', label: 'Groups', count: 6 },
|
||||
{ value: 'invitations', label: 'Invitations', count: 2 },
|
||||
{ value: 'service', label: 'Service accounts', count: 3 },
|
||||
]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- USERS TAB -->
|
||||
<div v-if="tab === 'users'" class="content">
|
||||
<div class="toolbar">
|
||||
<div class="input-search">
|
||||
<UiIcon name="search" :size="14" stroke="var(--text-mute)" />
|
||||
<input v-model="query" placeholder="Search by name or email…" />
|
||||
</div>
|
||||
<AdminFilterChip label="Status" :options="statusOptions" v-model="statusFilter" />
|
||||
<button class="chip"><Eyebrow>Role:</Eyebrow><span>All</span><UiIcon name="chevDown" :size="12" stroke="var(--text-mute)" /></button>
|
||||
<button class="chip"><Eyebrow>Group:</Eyebrow><span>All</span><UiIcon name="chevDown" :size="12" stroke="var(--text-mute)" /></button>
|
||||
<div class="spacer" />
|
||||
<Mono dim>{{ filteredUsers.length }} of {{ sampleUsersFlat.length }}</Mono>
|
||||
</div>
|
||||
|
||||
<div v-if="selected.size > 0" class="bulk">
|
||||
<Mono style="color: inherit">{{ selected.size }} selected</Mono>
|
||||
<div class="spacer" />
|
||||
<UiButton size="sm" variant="ghost" class="invert" @click="assignGroupOpen = true">Assign group</UiButton>
|
||||
<UiButton size="sm" variant="ghost" class="invert" @click="changeRoleOpen = true">Change role</UiButton>
|
||||
<UiButton size="sm" variant="ghost" class="invert" @click="bulkExport">Export selected</UiButton>
|
||||
<UiButton size="sm" variant="ghost" class="invert" @click="suspendOpen = true">Suspend</UiButton>
|
||||
<UiButton size="sm" variant="ghost" class="invert" @click="clearSelection">Clear</UiButton>
|
||||
</div>
|
||||
|
||||
<Card :pad="0">
|
||||
<table class="users-tbl">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="check">
|
||||
<input type="checkbox" :checked="selected.size === filteredUsers.length && filteredUsers.length > 0" @change="(e) => (e.target as HTMLInputElement).checked ? (selected = new Set(filteredUsers.map(u => u.id))) : clearSelection()" />
|
||||
</th>
|
||||
<th>Name</th><th>Role</th><th>Status</th><th>Group</th><th>Last seen</th><th class="right">Storage</th><th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="u in filteredUsers" :key="u.id" @click="openUser = u">
|
||||
<td class="check" @click.stop>
|
||||
<input type="checkbox" :checked="selected.has(u.id)" @change="toggleSelect(u.id)" />
|
||||
</td>
|
||||
<td>
|
||||
<div class="name-cell">
|
||||
<Avatar :name="u.name" :size="28" />
|
||||
<div>
|
||||
<div class="u-name">{{ u.name }}</div>
|
||||
<Mono dim>{{ u.email }}</Mono>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td><Badge :tone="u.role === 'Owner' ? 'invert' : 'neutral'">{{ u.role }}</Badge></td>
|
||||
<td><Badge :tone="statusTone(u.status)" dot>{{ u.status }}</Badge></td>
|
||||
<td><span class="group-text">{{ u.group }}</span></td>
|
||||
<td><Mono dim>{{ u.last }}</Mono></td>
|
||||
<td class="right"><Mono>{{ u.storage > 0 ? `${u.storage} GB` : '—' }}</Mono></td>
|
||||
<td class="right" @click.stop>
|
||||
<AdminKebabMenu :items="userRowItems" :icon-size="16" @select="(id) => rowAction(u, id)" />
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</Card>
|
||||
|
||||
<div class="pager">
|
||||
<Mono dim>Showing 1–{{ filteredUsers.length }}</Mono>
|
||||
<div class="pager-btns">
|
||||
<UiButton size="sm" variant="secondary">
|
||||
<template #leading><UiIcon name="chevLeft" :size="12" /></template>
|
||||
Prev
|
||||
</UiButton>
|
||||
<UiButton size="sm" variant="secondary">
|
||||
Next
|
||||
<template #trailing><UiIcon name="chevRight" :size="12" /></template>
|
||||
</UiButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- GROUPS TAB (GroupsTabRich) -->
|
||||
<div v-else-if="tab === 'groups'" class="content">
|
||||
<div class="toolbar">
|
||||
<div class="input-search">
|
||||
<UiIcon name="search" :size="14" stroke="var(--text-mute)" />
|
||||
<input placeholder="Search groups…" />
|
||||
</div>
|
||||
<button class="chip"><Eyebrow>Sort:</Eyebrow><span>Name</span><UiIcon name="chevDown" :size="12" stroke="var(--text-mute)" /></button>
|
||||
<div class="spacer" />
|
||||
<Mono dim>{{ groupsFull.length }} groups</Mono>
|
||||
<UiButton variant="primary" @click="createGroupOpen = true">
|
||||
<template #leading><UiIcon name="plus" :size="14" /></template>
|
||||
New group
|
||||
</UiButton>
|
||||
</div>
|
||||
<Card :pad="0">
|
||||
<table class="users-tbl">
|
||||
<thead>
|
||||
<tr><th>Group</th><th>Alias</th><th>Members</th><th>Owner</th><th>Created</th><th /></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="g in groupsFull" :key="g.id" @click="openGroup = g">
|
||||
<td>
|
||||
<div class="name-cell">
|
||||
<div class="g-icon"><UiIcon name="users" :size="14" /></div>
|
||||
<div>
|
||||
<div class="u-name">{{ g.name }}</div>
|
||||
<Mono dim>{{ g.description }}</Mono>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td><Mono>{{ g.alias }}</Mono></td>
|
||||
<td>
|
||||
<div class="member-cell">
|
||||
<UiIcon name="users" :size="12" stroke="var(--text-mute)" />
|
||||
<Mono>{{ g.members }}</Mono>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="name-cell small">
|
||||
<Avatar :name="g.owner" :size="22" />
|
||||
<span>{{ g.owner }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td><Mono dim>{{ g.created }}</Mono></td>
|
||||
<td class="right" @click.stop>
|
||||
<AdminKebabMenu :items="groupRowItems" @select="(id) => groupAction(g, id)" />
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- INVITATIONS TAB -->
|
||||
<div v-else-if="tab === 'invitations'" class="content">
|
||||
<Card :pad="0">
|
||||
<table class="users-tbl">
|
||||
<thead><tr><th>Recipient</th><th>Sent</th><th>Expires</th><th /></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="u in invites" :key="u.id">
|
||||
<td>
|
||||
<div class="name-cell">
|
||||
<Avatar :name="u.name" :size="28" />
|
||||
<div>
|
||||
<div class="u-name">{{ u.name }}</div>
|
||||
<Mono dim>{{ u.email }}</Mono>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td><Mono dim>14 May 2026</Mono></td>
|
||||
<td><Mono dim>21 May 2026</Mono></td>
|
||||
<td class="right">
|
||||
<UiButton size="sm" variant="secondary">
|
||||
<template #leading><UiIcon name="copy" :size="13" /></template>
|
||||
Copy link
|
||||
</UiButton>
|
||||
<UiButton size="sm" variant="secondary">
|
||||
<template #leading><UiIcon name="refresh" :size="13" /></template>
|
||||
Resend
|
||||
</UiButton>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- SERVICE ACCOUNTS TAB -->
|
||||
<div v-else class="content">
|
||||
<div class="empty-card">
|
||||
<UiIcon name="key" :size="28" stroke="var(--text-mute)" />
|
||||
<div class="empty-title">3 service accounts</div>
|
||||
<div class="empty-body">Service accounts let scripts and integrations authenticate to your workspace. Manage their API tokens here.</div>
|
||||
<UiButton variant="primary">
|
||||
<template #leading><UiIcon name="plus" :size="14" /></template>
|
||||
New service account
|
||||
</UiButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- User detail side panel -->
|
||||
<SidePanel :open="!!openUser" :eyebrow="openUser?.id || ''" :title="openUser?.name || ''" width="lg" @close="openUser = null">
|
||||
<div v-if="openUser" class="user-detail">
|
||||
<div class="ud-head">
|
||||
<Avatar :name="openUser.name" :size="56" />
|
||||
<div class="ud-meta">
|
||||
<div class="ud-name">{{ openUser.name }}</div>
|
||||
<Mono dim>{{ openUser.email }}</Mono>
|
||||
<div class="ud-badges">
|
||||
<Badge :tone="statusTone(openUser.status)" dot>{{ openUser.status }}</Badge>
|
||||
<Badge tone="neutral">{{ openUser.role }}</Badge>
|
||||
<Badge tone="neutral">{{ openUser.group }}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Tabs
|
||||
v-model="userTab"
|
||||
:items="[
|
||||
{ value: 'profile', label: 'Profile' },
|
||||
{ value: 'access', label: 'Access' },
|
||||
{ value: 'mail', label: 'Mail' },
|
||||
{ value: 'files', label: 'Files' },
|
||||
{ value: 'activity', label: 'Activity' },
|
||||
{ value: 'audit', label: 'Audit' },
|
||||
]"
|
||||
/>
|
||||
<div class="ud-body">
|
||||
<template v-if="userTab === 'profile'">
|
||||
<dl class="def">
|
||||
<div><dt>Full name</dt><dd>{{ openUser.name }}</dd></div>
|
||||
<div><dt>Email</dt><dd>{{ openUser.email }}</dd></div>
|
||||
<div><dt>Role</dt><dd>{{ openUser.role }}</dd></div>
|
||||
<div><dt>Group</dt><dd>{{ openUser.group }}</dd></div>
|
||||
<div><dt>License</dt><dd>Business · seat 11</dd></div>
|
||||
<div><dt>Joined</dt><dd>14 January 2026</dd></div>
|
||||
<div><dt>Locale</dt><dd>da-DK · Europe/Copenhagen</dd></div>
|
||||
<div><dt>Phone</dt><dd>+45 21 47 88 02</dd></div>
|
||||
</dl>
|
||||
</template>
|
||||
|
||||
<template v-else-if="userTab === 'access'">
|
||||
<dl class="def">
|
||||
<div><dt>MFA</dt><dd><Badge tone="ok" dot>enabled · TOTP</Badge></dd></div>
|
||||
<div><dt>SSO sessions</dt><dd>2 active</dd></div>
|
||||
<div><dt>Last sign-in</dt><dd>{{ openUser.last }} · 92.43.118.4 · Copenhagen</dd></div>
|
||||
<div><dt>Recovery codes</dt><dd>8 of 10 unused</dd></div>
|
||||
</dl>
|
||||
<div class="sub-head">Active devices</div>
|
||||
<div v-for="d in [
|
||||
{ d: 'MacBook Pro · macOS 14', w: 'Chrome 132', loc: 'Copenhagen', active: '2 min ago' },
|
||||
{ d: 'iPhone 15 Pro · iOS 18', w: 'dezky Mail', loc: 'Copenhagen', active: '1 h ago' },
|
||||
]" :key="d.d" class="dev-row">
|
||||
<UiIcon name="device" :size="18" stroke="var(--text-mute)" />
|
||||
<div class="dev-meta">
|
||||
<div class="dev-d">{{ d.d }}</div>
|
||||
<Mono dim>{{ d.w }} · {{ d.loc }} · {{ d.active }}</Mono>
|
||||
</div>
|
||||
<UiButton size="sm" variant="ghost">Revoke</UiButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="userTab === 'mail'">
|
||||
<dl class="def">
|
||||
<div><dt>Primary address</dt><dd>{{ openUser.email }}</dd></div>
|
||||
<div><dt>Quota</dt><dd>12.4 GB of 50 GB · 25%</dd></div>
|
||||
<div><dt>Forwarding</dt><dd>Off</dd></div>
|
||||
<div><dt>Vacation reply</dt><dd>Off</dd></div>
|
||||
</dl>
|
||||
<div class="sub-head">Aliases</div>
|
||||
<div v-for="a in ['anne.b@dezky.com', 'founder@dezky.com']" :key="a" class="alias-row">
|
||||
<Mono>{{ a }}</Mono>
|
||||
<UiButton size="sm" variant="ghost">
|
||||
<template #leading><UiIcon name="trash" :size="12" /></template>
|
||||
Remove
|
||||
</UiButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="userTab === 'files'">
|
||||
<dl class="def">
|
||||
<div><dt>Quota</dt><dd>12.4 GB of 100 GB · 12%</dd></div>
|
||||
<div><dt>Shared by user</dt><dd>14 items</dd></div>
|
||||
<div><dt>Shared with user</dt><dd>23 items</dd></div>
|
||||
</dl>
|
||||
</template>
|
||||
|
||||
<template v-else-if="userTab === 'activity'">
|
||||
<div class="activity-list">
|
||||
<div v-for="a in sampleAudit.slice(0, 6)" :key="a.id" class="activity-row">
|
||||
<Mono dim>{{ a.when }}</Mono>
|
||||
<span class="activity-action">{{ a.action }}</span>
|
||||
<Mono dim>{{ a.ip }}</Mono>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div class="empty-tab">
|
||||
<UiIcon name="shield" :size="28" stroke="var(--text-mute)" />
|
||||
<div class="empty-title">No changes recorded yet</div>
|
||||
<div class="empty-body">Edits to this user's settings will appear here.</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<UiButton variant="danger">
|
||||
<template #leading><UiIcon name="logout" :size="13" /></template>
|
||||
Force logout
|
||||
</UiButton>
|
||||
<UiButton variant="secondary">Reset password</UiButton>
|
||||
<UiButton variant="primary">Save changes</UiButton>
|
||||
</template>
|
||||
</SidePanel>
|
||||
|
||||
<!-- Group detail side panel -->
|
||||
<SidePanel :open="!!openGroup" eyebrow="Group" :title="openGroup?.name || ''" width="lg" @close="openGroup = null">
|
||||
<div v-if="openGroup" class="user-detail">
|
||||
<div class="ud-head">
|
||||
<div class="g-icon big"><UiIcon name="users" :size="22" /></div>
|
||||
<div class="ud-meta">
|
||||
<div class="ud-name">{{ openGroup.name }}</div>
|
||||
<Mono dim>{{ openGroup.alias }}</Mono>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ud-body">
|
||||
<dl class="def">
|
||||
<div><dt>Members</dt><dd>{{ openGroup.members }}</dd></div>
|
||||
<div><dt>Owner</dt><dd>{{ openGroup.owner }}</dd></div>
|
||||
<div><dt>Created</dt><dd>{{ openGroup.created }}</dd></div>
|
||||
<div><dt>Description</dt><dd>{{ openGroup.description }}</dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<UiButton variant="danger">
|
||||
<template #leading><UiIcon name="trash" :size="13" /></template>
|
||||
Delete group
|
||||
</UiButton>
|
||||
<div style="flex: 1" />
|
||||
<UiButton variant="primary" @click="openGroup = null">Save changes</UiButton>
|
||||
</template>
|
||||
</SidePanel>
|
||||
|
||||
<!-- Invite user modal (3 steps) -->
|
||||
<Modal :open="inviteOpen" :title="'Invite user'" :eyebrow="`Step ${inviteStep} of 3`" size="md" @close="inviteOpen = false; inviteStep = 1">
|
||||
<div v-if="inviteStep === 1" class="form-stack">
|
||||
<label class="field"><Eyebrow>Full name</Eyebrow><input class="input" value="Magnus Eriksen" /></label>
|
||||
<label class="field"><Eyebrow>Email</Eyebrow><input class="input" value="magnus@dezky.com" /></label>
|
||||
<label class="field"><Eyebrow>Role</Eyebrow>
|
||||
<div class="radio-row">
|
||||
<button class="active">Member</button><button>Admin</button>
|
||||
</div>
|
||||
</label>
|
||||
<label class="field"><Eyebrow>License tier</Eyebrow>
|
||||
<div class="radio-row">
|
||||
<button>Basic</button><button class="active">Business</button>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
<div v-else-if="inviteStep === 2" class="form-stack">
|
||||
<div>
|
||||
<Eyebrow>Group memberships</Eyebrow>
|
||||
<div class="check-stack">
|
||||
<label v-for="(g, i) in ['Engineering', 'Design', 'Operations', 'Finance', 'Sales']" :key="g">
|
||||
<input type="checkbox" :checked="i === 0" /> {{ g }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Eyebrow>Apps</Eyebrow>
|
||||
<div class="check-stack">
|
||||
<label v-for="a in ['Mail', 'Drev', 'Møder', 'Chat']" :key="a">
|
||||
<input type="checkbox" checked /> {{ a }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else>
|
||||
<div class="review-box">
|
||||
<dl class="def">
|
||||
<div><dt>Name</dt><dd>Magnus Eriksen</dd></div>
|
||||
<div><dt>Email</dt><dd>magnus@dezky.com</dd></div>
|
||||
<div><dt>Role</dt><dd>Member · Business</dd></div>
|
||||
<div><dt>Groups</dt><dd>Engineering</dd></div>
|
||||
<div><dt>Apps</dt><dd>Mail · Drev · Møder · Chat</dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
<div class="muted">
|
||||
We'll provision the account across Authentik, Stalwart, OCIS, Jitsi and Zulip, then email Magnus an activation link valid for 7 days.
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<UiButton variant="ghost" @click="inviteOpen = false; inviteStep = 1">Cancel</UiButton>
|
||||
<UiButton v-if="inviteStep > 1" variant="secondary" @click="inviteStep--">Back</UiButton>
|
||||
<UiButton v-if="inviteStep < 3" variant="primary" @click="inviteStep++">Continue</UiButton>
|
||||
<UiButton v-else variant="primary" @click="sendInvite">Send invitation</UiButton>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
<!-- Bulk import modal -->
|
||||
<Modal :open="importOpen" eyebrow="Users · bulk import" title="Import users from CSV" size="md" @close="importOpen = false">
|
||||
<div class="import">
|
||||
<div class="upload-stage">
|
||||
<UiIcon name="upload" :size="28" stroke="var(--text-mute)" />
|
||||
<div class="upload-text">
|
||||
<div>Drop a CSV here, or click to browse</div>
|
||||
<Mono dim>columns: name, email, role, group, license</Mono>
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-box">
|
||||
<Mono dim>// example</Mono>
|
||||
<pre class="csv-sample">name,email,role,group,license
|
||||
Anne Hansen,anne@baslund.dk,owner,Leadership,business
|
||||
Mikkel Sørensen,mikkel@baslund.dk,admin,Engineering,business</pre>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<UiButton variant="ghost" @click="importOpen = false">Cancel</UiButton>
|
||||
<UiButton variant="primary" @click="importOpen = false; toast.ok('22 users imported · 2 skipped')">
|
||||
<template #leading><UiIcon name="check" :size="13" /></template>
|
||||
Import users
|
||||
</UiButton>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
<!-- Bulk · assign group -->
|
||||
<Modal :open="assignGroupOpen" :eyebrow="`${selected.size} selected`" title="Add to groups" size="md" @close="assignGroupOpen = false">
|
||||
<div class="form-stack">
|
||||
<Eyebrow>Pick one or more groups</Eyebrow>
|
||||
<div class="check-stack">
|
||||
<label v-for="g in ['Engineering', 'Design', 'Operations', 'Finance', 'Sales', 'Leadership']" :key="g">
|
||||
<input type="checkbox" :checked="groupChoice.has(g)" @change="toggleGroup(g)" />
|
||||
{{ g }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<UiButton variant="ghost" @click="assignGroupOpen = false">Cancel</UiButton>
|
||||
<UiButton variant="primary" :disabled="groupChoice.size === 0" @click="applyBulkGroup">
|
||||
<template #leading><UiIcon name="check" :size="13" /></template>
|
||||
Add to groups
|
||||
</UiButton>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
<!-- Bulk · change role -->
|
||||
<Modal :open="changeRoleOpen" :eyebrow="`${selected.size} selected`" title="Change role" size="md" @close="changeRoleOpen = false">
|
||||
<div class="form-stack">
|
||||
<Eyebrow>New role</Eyebrow>
|
||||
<label v-for="r in ['member', 'admin', 'owner'] as const" :key="r" class="role-row" :class="{ active: roleChoice === r }">
|
||||
<input type="radio" :value="r" v-model="roleChoice" />
|
||||
<div>
|
||||
<div class="role-name">{{ r[0].toUpperCase() + r.slice(1) }}</div>
|
||||
<Mono dim>
|
||||
{{ r === 'member' ? 'Standard access to apps' :
|
||||
r === 'admin' ? 'Manage users, billing, and settings' :
|
||||
'Full control — including billing and ownership' }}
|
||||
</Mono>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
<template #footer>
|
||||
<UiButton variant="ghost" @click="changeRoleOpen = false">Cancel</UiButton>
|
||||
<UiButton variant="primary" @click="applyBulkRole">Update role</UiButton>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
<!-- Bulk · suspend -->
|
||||
<ConfirmDialog
|
||||
:open="suspendOpen"
|
||||
:eyebrow="`${selected.size} selected`"
|
||||
:title="`Suspend ${selected.size} user${selected.size === 1 ? '' : 's'}?`"
|
||||
confirm-label="Suspend"
|
||||
tone="danger"
|
||||
@close="suspendOpen = false"
|
||||
@confirm="applyBulkSuspend"
|
||||
>
|
||||
Sign-in will be blocked across mail, files, chat, and meetings. Data is preserved; you can re-enable any time.
|
||||
</ConfirmDialog>
|
||||
|
||||
<!-- Create group modal -->
|
||||
<Modal :open="createGroupOpen" eyebrow="Groups" title="New group" size="md" @close="createGroupOpen = false">
|
||||
<div class="form-stack">
|
||||
<label class="field"><Eyebrow>Group name</Eyebrow><input class="input" placeholder="Engineering" /></label>
|
||||
<label class="field"><Eyebrow>Mail alias</Eyebrow><input class="input" placeholder="eng@dezky.com" /></label>
|
||||
<label class="field"><Eyebrow>Description</Eyebrow><input class="input" placeholder="Product engineering team" /></label>
|
||||
</div>
|
||||
<template #footer>
|
||||
<UiButton variant="ghost" @click="createGroupOpen = false">Cancel</UiButton>
|
||||
<UiButton variant="primary" @click="createGroupOpen = false; toast.ok('Group created')">Create group</UiButton>
|
||||
</template>
|
||||
</Modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.tab-wrap { padding: 16px 40px 0 40px; }
|
||||
.content { padding: 16px 40px 64px 40px; }
|
||||
|
||||
.toolbar { display: flex; align-items: center; gap: 12px; margin-bottom: 16px; flex-wrap: wrap; }
|
||||
.input-search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 0 12px;
|
||||
height: 36px;
|
||||
width: 320px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
}
|
||||
.input-search input { flex: 1; border: none; outline: none; background: transparent; font-family: inherit; font-size: 13px; color: var(--text); }
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 36px;
|
||||
padding: 0 12px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
}
|
||||
.chip span { font-weight: 500; }
|
||||
.spacer { flex: 1; }
|
||||
|
||||
.bulk {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 16px;
|
||||
margin-bottom: 12px;
|
||||
background: var(--text);
|
||||
color: var(--bg);
|
||||
border-radius: 6px;
|
||||
}
|
||||
.bulk .invert :deep(button) { color: var(--bg) !important; }
|
||||
.bulk :deep([data-variant='ghost']) { color: var(--bg); }
|
||||
.bulk :deep([data-variant='ghost']:hover) { background: rgba(255, 255, 255, 0.06); }
|
||||
|
||||
.users-tbl { width: 100%; border-collapse: collapse; }
|
||||
.users-tbl th {
|
||||
text-align: left;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-mute);
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-weight: 500;
|
||||
}
|
||||
.users-tbl td {
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 13px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.users-tbl tr { cursor: pointer; }
|
||||
.users-tbl tr:hover { background: var(--surface); }
|
||||
.users-tbl tr:last-child td { border-bottom: none; }
|
||||
.users-tbl .right { text-align: right; }
|
||||
.users-tbl .check { width: 36px; }
|
||||
|
||||
.name-cell { display: flex; align-items: center; gap: 12px; }
|
||||
.name-cell.small { gap: 8px; }
|
||||
.u-name { font-weight: 500; font-size: 13px; }
|
||||
.group-text { font-family: var(--font-mono); font-size: 12px; color: var(--text-dim); }
|
||||
.member-cell { display: flex; align-items: center; gap: 6px; }
|
||||
.g-icon {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 6px;
|
||||
background: var(--bg);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-mute);
|
||||
}
|
||||
.g-icon.big { width: 44px; height: 44px; border-radius: 10px; color: var(--text-dim); border: 1px solid var(--border); }
|
||||
|
||||
.pager { display: flex; justify-content: space-between; align-items: center; margin-top: 16px; font-size: 12px; color: var(--text-mute); }
|
||||
.pager-btns { display: flex; gap: 4px; }
|
||||
|
||||
.empty-card {
|
||||
padding: 60px 24px;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
}
|
||||
.empty-title { font-family: var(--font-display); font-weight: 600; font-size: 17px; }
|
||||
.empty-body { font-size: 13px; color: var(--text-mute); max-width: 420px; line-height: 1.5; }
|
||||
|
||||
/* User detail */
|
||||
.user-detail { padding-bottom: 24px; margin: -22px -24px; }
|
||||
.ud-head { padding: 24px; border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 16px; }
|
||||
.ud-meta { flex: 1; }
|
||||
.ud-name { font-size: 17px; font-weight: 600; font-family: var(--font-display); }
|
||||
.ud-badges { display: flex; gap: 6px; margin-top: 8px; }
|
||||
.ud-body { padding: 24px; }
|
||||
.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); }
|
||||
|
||||
.sub-head { font-size: 13px; font-weight: 600; margin: 24px 0 8px; }
|
||||
.dev-row {
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
margin-bottom: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.dev-meta { flex: 1; }
|
||||
.dev-d { font-size: 13px; font-weight: 500; }
|
||||
|
||||
.alias-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.activity-list { font-family: var(--font-mono); font-size: 12px; }
|
||||
.activity-row {
|
||||
display: grid;
|
||||
grid-template-columns: 80px 1fr auto;
|
||||
gap: 12px;
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.activity-action { color: var(--text); }
|
||||
|
||||
.empty-tab { text-align: center; padding: 60px 20px; }
|
||||
|
||||
/* Invite modal */
|
||||
.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); }
|
||||
.radio-row { display: inline-flex; border: 1px solid var(--border); border-radius: 6px; padding: 2px; width: fit-content; }
|
||||
.radio-row button { padding: 6px 14px; border: none; border-radius: 4px; background: transparent; color: var(--text); font-size: 12px; font-weight: 500; font-family: inherit; cursor: pointer; }
|
||||
.radio-row button.active { background: var(--text); color: var(--bg); }
|
||||
.check-stack { display: flex; flex-direction: column; gap: 6px; margin-top: 6px; font-size: 13px; }
|
||||
.check-stack label { display: flex; align-items: center; gap: 8px; }
|
||||
.review-box { padding: 16px; background: var(--bg); border-radius: 6px; margin-bottom: 16px; }
|
||||
.muted { font-size: 12px; color: var(--text-mute); line-height: 1.55; }
|
||||
|
||||
.import { display: flex; flex-direction: column; gap: 14px; }
|
||||
.upload-stage {
|
||||
padding: 32px 24px;
|
||||
background: var(--bg);
|
||||
border: 2px dashed var(--border);
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.upload-text { text-align: center; font-size: 13px; }
|
||||
.info-box {
|
||||
padding: 12px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.csv-sample {
|
||||
margin: 8px 0 0 0;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
/* Bulk · role picker */
|
||||
.role-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.role-row.active { border-color: var(--text); background: var(--bg); }
|
||||
.role-name { font-size: 13px; font-weight: 500; }
|
||||
</style>
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
definePageMeta({ auth: false })
|
||||
definePageMeta({ auth: false, layout: 'blank' })
|
||||
|
||||
const route = useRoute()
|
||||
const accountEmail = computed(() => (route.query.email as string) || 'this account')
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
definePageMeta({ auth: false })
|
||||
definePageMeta({ auth: false, layout: 'blank' })
|
||||
|
||||
async function signInAgain() {
|
||||
await navigateTo('/auth/oidc/login', { external: true })
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
// Custom login page (nuxt-oidc-auth customLoginPage=true means it won't auto-bounce).
|
||||
// We intercept the click and kick off the OIDC flow via the module's helper.
|
||||
// Interstitial login page. With customLoginPage:false in nuxt.config the
|
||||
// OIDC module's route rule auto-redirects /auth/login → /auth/oidc/login,
|
||||
// so this content rarely renders — it's a fallback for direct navigation.
|
||||
|
||||
definePageMeta({ auth: false })
|
||||
definePageMeta({ auth: false, layout: 'blank' })
|
||||
|
||||
const email = ref('')
|
||||
|
||||
|
||||
@@ -0,0 +1,316 @@
|
||||
<script setup lang="ts">
|
||||
// Devices & sessions. Faithfully ports project/platform-enduser.jsx
|
||||
// `DevicesScreen` lines 37–233. Two grouped sections: Desktop / Mobile &
|
||||
// tablet. Per-row "..." menu is portaled (see EnduserDeviceActions).
|
||||
|
||||
|
||||
import { devices } from '~/data/enduser'
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
// Source groups desktop (kind === 'desktop') vs mobile + tablet. Our `devices`
|
||||
// fixture uses `laptop` for desktop, plus `phone` / `tablet`.
|
||||
const desktops = computed(() => devices.filter((d) => d.kind === 'laptop'))
|
||||
const mobiles = computed(() => devices.filter((d) => d.kind === 'phone' || d.kind === 'tablet'))
|
||||
|
||||
const signOutOpen = ref(false)
|
||||
const keepCurrent = ref(true)
|
||||
const forceMfa = ref(false)
|
||||
const renameDevice = ref<typeof devices[number] | null>(null)
|
||||
const renameValue = ref('')
|
||||
const revokeDevice = ref<typeof devices[number] | null>(null)
|
||||
|
||||
function onRename(d: typeof devices[number]) {
|
||||
renameDevice.value = d
|
||||
renameValue.value = d.label
|
||||
}
|
||||
function onRevoke(d: typeof devices[number]) {
|
||||
if (d.current) return
|
||||
revokeDevice.value = d
|
||||
}
|
||||
function confirmRevoke() {
|
||||
toast.warn(`Revoked ${revokeDevice.value?.label}`, 'Session ended within 30s')
|
||||
revokeDevice.value = null
|
||||
}
|
||||
function confirmRename() {
|
||||
toast.ok(`Renamed to "${renameValue.value}"`)
|
||||
renameDevice.value = null
|
||||
}
|
||||
function confirmSignOutAll() {
|
||||
signOutOpen.value = false
|
||||
const n = keepCurrent.value ? devices.length - 1 : devices.length
|
||||
toast.warn(`Signed out ${n} sessions`, forceMfa.value ? 'MFA re-enrolment required on next sign-in' : '')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<PageHeader
|
||||
eyebrow="Account"
|
||||
title="Devices & sessions"
|
||||
subtitle="Everywhere you've signed into dezky. Revoke anything you don't recognize."
|
||||
>
|
||||
<template #actions>
|
||||
<UiButton variant="danger" @click="signOutOpen = true">
|
||||
<template #leading><UiIcon name="logout" :size="13" /></template>
|
||||
Sign out everywhere
|
||||
</UiButton>
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<div class="content">
|
||||
<!-- Intro card · "Currently signed in" -->
|
||||
<Card style="margin-bottom: 16px;">
|
||||
<div class="intro">
|
||||
<div>
|
||||
<Eyebrow>Currently signed in</Eyebrow>
|
||||
<h3>{{ devices.length }} sessions across {{ desktops.length }} desktops and {{ mobiles.length }} mobile / tablet</h3>
|
||||
</div>
|
||||
<Mono dim>last refresh · now</Mono>
|
||||
</div>
|
||||
<p class="intro-body">
|
||||
We track every active session. If you sign out everywhere, you'll need to sign in again on each device — including this one.
|
||||
</p>
|
||||
</Card>
|
||||
|
||||
<!-- Desktop -->
|
||||
<div class="section-label">
|
||||
<UiIcon name="device" :size="13" stroke="var(--text-mute)" />
|
||||
<Mono dim>Desktop · {{ desktops.length }}</Mono>
|
||||
</div>
|
||||
<ul class="device-list">
|
||||
<li v-for="d in desktops" :key="d.id">
|
||||
<Card :pad="16">
|
||||
<div class="device">
|
||||
<div class="device-icon">
|
||||
<span class="laptop" />
|
||||
</div>
|
||||
<div class="device-text">
|
||||
<div class="device-row">
|
||||
<span class="device-name">{{ d.label }}</span>
|
||||
<Mono dim>{{ d.os }}</Mono>
|
||||
<Badge v-if="d.current" tone="ok" dot>this device</Badge>
|
||||
<Badge v-if="d.trusted && !d.current" tone="info">trusted</Badge>
|
||||
<Badge v-if="d.stale" tone="warn">inactive</Badge>
|
||||
</div>
|
||||
<div class="device-meta">
|
||||
<Mono>{{ d.app }}</Mono>
|
||||
<span>·</span>
|
||||
<Mono>{{ d.location }}</Mono>
|
||||
<span>·</span>
|
||||
<Mono>{{ d.ip }}</Mono>
|
||||
<span>·</span>
|
||||
<span>active {{ d.lastActive }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<UiButton v-if="!d.current" size="sm" variant="ghost" @click="onRevoke(d)">
|
||||
<template #leading><UiIcon name="logout" :size="13" /></template>
|
||||
Revoke
|
||||
</UiButton>
|
||||
<EnduserDeviceActions
|
||||
:device="d"
|
||||
@rename="onRename"
|
||||
@trust="toast.ok(`${$event.label} ${$event.trusted ? 'untrusted' : 'trusted'}`)"
|
||||
@history="toast.info(`Viewing history of ${$event.label}`)"
|
||||
@revoke="onRevoke"
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<!-- Mobile & tablet -->
|
||||
<div class="section-label" style="margin-top: 24px;">
|
||||
<UiIcon name="device" :size="13" stroke="var(--text-mute)" />
|
||||
<Mono dim>Mobile & tablet · {{ mobiles.length }}</Mono>
|
||||
</div>
|
||||
<ul class="device-list">
|
||||
<li v-for="d in mobiles" :key="d.id">
|
||||
<Card :pad="16">
|
||||
<div class="device">
|
||||
<div class="device-icon">
|
||||
<span :class="d.kind === 'tablet' ? 'tablet' : 'phone'" />
|
||||
</div>
|
||||
<div class="device-text">
|
||||
<div class="device-row">
|
||||
<span class="device-name">{{ d.label }}</span>
|
||||
<Mono dim>{{ d.os }}</Mono>
|
||||
<Badge v-if="d.trusted" tone="info">trusted</Badge>
|
||||
</div>
|
||||
<div class="device-meta">
|
||||
<Mono>{{ d.app }}</Mono>
|
||||
<span>·</span>
|
||||
<Mono>{{ d.location }}</Mono>
|
||||
<span>·</span>
|
||||
<Mono>{{ d.ip }}</Mono>
|
||||
<span>·</span>
|
||||
<span>active {{ d.lastActive }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<UiButton size="sm" variant="ghost" @click="onRevoke(d)">
|
||||
<template #leading><UiIcon name="logout" :size="13" /></template>
|
||||
Revoke
|
||||
</UiButton>
|
||||
<EnduserDeviceActions
|
||||
:device="d"
|
||||
@rename="onRename"
|
||||
@trust="toast.ok(`${$event.label} ${$event.trusted ? 'untrusted' : 'trusted'}`)"
|
||||
@history="toast.info(`Viewing history of ${$event.label}`)"
|
||||
@revoke="onRevoke"
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Sign-out everywhere modal -->
|
||||
<Modal :open="signOutOpen" eyebrow="Destructive · all sessions" title="Sign out everywhere?" size="md" @close="signOutOpen = false">
|
||||
<div class="modal-stack">
|
||||
<div class="callout-bad">
|
||||
<UiIcon name="shield" :size="16" />
|
||||
<div>All other sessions will be revoked immediately. On each device, you'll need to sign in again with your password and MFA. Anyone using a stolen session token will lose access.</div>
|
||||
</div>
|
||||
<div class="rows">
|
||||
<div class="row">
|
||||
<div>
|
||||
<div class="row-title">Keep this device signed in</div>
|
||||
<Mono dim>recommended · you won't get locked out</Mono>
|
||||
</div>
|
||||
<EnduserToggle v-model="keepCurrent" />
|
||||
</div>
|
||||
<div class="row">
|
||||
<div>
|
||||
<div class="row-title">Force MFA re-enrolment</div>
|
||||
<Mono dim>use if you think your authenticator was compromised</Mono>
|
||||
</div>
|
||||
<EnduserToggle v-model="forceMfa" />
|
||||
</div>
|
||||
</div>
|
||||
<Mono dim>any pending file uploads or chat messages on the other devices will be lost</Mono>
|
||||
</div>
|
||||
<template #footer>
|
||||
<UiButton variant="ghost" @click="signOutOpen = false">Cancel</UiButton>
|
||||
<UiButton variant="danger" @click="confirmSignOutAll">
|
||||
<template #leading><UiIcon name="logout" :size="13" /></template>
|
||||
{{ keepCurrent ? `Sign out ${devices.length - 1} other sessions` : `Sign out all ${devices.length} sessions` }}
|
||||
</UiButton>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
<!-- Rename modal -->
|
||||
<Modal :open="renameDevice !== null" eyebrow="Device · rename" :title="renameDevice ? `Rename ${renameDevice.label}` : ''" size="sm" @close="renameDevice = null">
|
||||
<EnduserFormField label="Device name">
|
||||
<input v-model="renameValue" placeholder="e.g. Work laptop, Anne's iPhone" />
|
||||
</EnduserFormField>
|
||||
<Mono dim style="margin-top: 10px; display: block;">shown to you here and on the workspace audit log · co-workers do not see it</Mono>
|
||||
<template #footer>
|
||||
<UiButton variant="ghost" @click="renameDevice = null">Cancel</UiButton>
|
||||
<UiButton variant="primary" :disabled="!renameValue.trim()" @click="confirmRename">
|
||||
<template #leading><UiIcon name="check" :size="13" /></template>
|
||||
Save name
|
||||
</UiButton>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
<!-- Revoke confirm (Modal · matches source DefList layout) -->
|
||||
<Modal :open="revokeDevice !== null" eyebrow="Revoke session" :title="revokeDevice ? `Sign out ${revokeDevice.label}?` : ''" size="md" @close="revokeDevice = null">
|
||||
<div class="revoke-stack">
|
||||
<div class="revoke-detail">
|
||||
<dl>
|
||||
<div><dt>Device</dt><dd>{{ revokeDevice?.label }}</dd></div>
|
||||
<div><dt>OS</dt><dd>{{ revokeDevice?.os }}</dd></div>
|
||||
<div><dt>App</dt><dd>{{ revokeDevice?.app }}</dd></div>
|
||||
<div><dt>Location</dt><dd>{{ revokeDevice?.location }} · {{ revokeDevice?.ip }}</dd></div>
|
||||
<div><dt>Last active</dt><dd>{{ revokeDevice?.lastActive }}</dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
<p class="revoke-text">
|
||||
This device will be signed out within 30 seconds. The person using it will be returned to the sign-in screen and any unsaved work in the app may be lost.
|
||||
</p>
|
||||
<div v-if="revokeDevice?.trusted" class="callout-warn">
|
||||
<UiIcon name="shield" :size="14" />
|
||||
<div>This is a <b>trusted device</b>. Revoking removes the trust — next sign-in will require MFA.</div>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<UiButton variant="ghost" @click="revokeDevice = null">Cancel</UiButton>
|
||||
<UiButton variant="danger" @click="confirmRevoke">
|
||||
<template #leading><UiIcon name="logout" :size="13" /></template>
|
||||
Sign out device
|
||||
</UiButton>
|
||||
</template>
|
||||
</Modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.content { padding: 20px 40px 64px 40px; max-width: 1000px; }
|
||||
|
||||
.intro { display: flex; justify-content: space-between; align-items: flex-start; gap: 24px; }
|
||||
.intro h3 { font-family: var(--font-display); font-weight: 600; font-size: 17px; margin: 4px 0 0 0; }
|
||||
.intro-body { margin: 12px 0 0 0; font-size: 13px; color: var(--text-mute); line-height: 1.6; }
|
||||
|
||||
.section-label { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; color: var(--text-mute); }
|
||||
|
||||
.device-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 8px; }
|
||||
|
||||
.device { display: flex; align-items: center; gap: 14px; }
|
||||
.device-icon {
|
||||
width: 48px; height: 48px; border-radius: 8px;
|
||||
background: var(--bg); border: 1px solid var(--border);
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
color: var(--text-dim); flex-shrink: 0;
|
||||
}
|
||||
/* Source CSS device glyphs — laptop = rounded rect + base nub,
|
||||
phone = tall portrait rect, tablet = wider rect. */
|
||||
.laptop { width: 38px; height: 26px; border: 1.5px solid currentColor; border-radius: 4px; position: relative; }
|
||||
.laptop::after { content: ''; position: absolute; left: 50%; bottom: -5px; transform: translateX(-50%); width: 14px; height: 2px; background: currentColor; border-radius: 1px; }
|
||||
.phone { width: 22px; height: 36px; border: 1.5px solid currentColor; border-radius: 12px; position: relative; }
|
||||
.phone::after { content: ''; position: absolute; left: 50%; bottom: 3px; transform: translateX(-50%); width: 8px; height: 1px; background: currentColor; opacity: 0.6; border-radius: 1px; }
|
||||
.tablet { width: 30px; height: 36px; border: 1.5px solid currentColor; border-radius: 10px; position: relative; }
|
||||
.tablet::after { content: ''; position: absolute; left: 50%; bottom: 3px; transform: translateX(-50%); width: 8px; height: 1px; background: currentColor; opacity: 0.6; border-radius: 1px; }
|
||||
|
||||
.device-text { flex: 1; min-width: 0; }
|
||||
.device-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
.device-name { font-size: 14px; font-weight: 500; }
|
||||
.device-meta { display: flex; align-items: center; gap: 10px; margin-top: 6px; font-size: 12px; color: var(--text-mute); flex-wrap: wrap; }
|
||||
|
||||
.modal-stack { display: flex; flex-direction: column; gap: 14px; }
|
||||
.callout-bad {
|
||||
padding: 14px;
|
||||
background: rgba(226, 48, 48, 0.06);
|
||||
border: 1px solid rgba(226, 48, 48, 0.20);
|
||||
border-radius: 6px;
|
||||
display: flex; gap: 10px;
|
||||
font-size: 13px; color: var(--text-dim); line-height: 1.5;
|
||||
}
|
||||
.callout-bad :deep(svg) { color: var(--bad); margin-top: 2px; flex-shrink: 0; }
|
||||
|
||||
.callout-warn {
|
||||
padding: 12px;
|
||||
background: rgba(232, 154, 31, 0.06);
|
||||
border: 1px solid rgba(232, 154, 31, 0.20);
|
||||
border-radius: 6px;
|
||||
font-size: 12px; color: var(--text-dim); line-height: 1.55;
|
||||
display: flex; gap: 10px;
|
||||
}
|
||||
.callout-warn :deep(svg) { color: var(--warn); margin-top: 2px; flex-shrink: 0; }
|
||||
|
||||
.rows {
|
||||
padding: 12px;
|
||||
background: var(--bg); border: 1px solid var(--border); border-radius: 6px;
|
||||
display: flex; flex-direction: column; gap: 12px;
|
||||
}
|
||||
.row { display: flex; align-items: center; justify-content: space-between; }
|
||||
.row + .row { border-top: 1px solid var(--border); padding-top: 12px; }
|
||||
.row-title { font-size: 13px; font-weight: 500; }
|
||||
|
||||
.revoke-stack { display: flex; flex-direction: column; gap: 14px; }
|
||||
.revoke-detail { padding: 14px; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; }
|
||||
.revoke-detail dl { margin: 0; display: flex; flex-direction: column; gap: 10px; }
|
||||
.revoke-detail dl > div { display: flex; gap: 12px; }
|
||||
.revoke-detail dt { font-family: var(--font-mono); font-size: 10px; text-transform: uppercase; letter-spacing: 0.12em; color: var(--text-mute); width: 110px; flex-shrink: 0; }
|
||||
.revoke-detail dd { margin: 0; font-size: 13px; color: var(--text); }
|
||||
.revoke-text { margin: 0; font-size: 13px; line-height: 1.6; color: var(--text-dim); }
|
||||
</style>
|
||||
@@ -0,0 +1,484 @@
|
||||
<script setup lang="ts">
|
||||
// Help & support. Faithfully ports project/platform-admin.jsx `HelpScreen`
|
||||
// (lines 306–610). 4 tabs: Knowledge base / My tickets / New ticket / Contact.
|
||||
// A SidePanel opens for a ticket's full conversation thread.
|
||||
|
||||
|
||||
import { helpArticles, myTickets } from '~/data/enduser'
|
||||
|
||||
const toast = useToast()
|
||||
const tab = ref('kb')
|
||||
|
||||
// --- Knowledge base ---
|
||||
const q = ref('')
|
||||
|
||||
const popular = computed(() => helpArticles.filter((a) => a.popular))
|
||||
const categories = computed(() => {
|
||||
const map = new Map<string, typeof helpArticles>()
|
||||
for (const a of helpArticles) {
|
||||
if (!map.has(a.category)) map.set(a.category, [])
|
||||
map.get(a.category)!.push(a)
|
||||
}
|
||||
return Array.from(map.entries())
|
||||
})
|
||||
|
||||
// --- Tickets ---
|
||||
const openTicket = ref<typeof myTickets[number] | null>(null)
|
||||
|
||||
// Ticket conversation thread — mirrors source TicketDetail messages.
|
||||
const ticketThread = computed(() => {
|
||||
if (!openTicket.value) return []
|
||||
return [
|
||||
{ who: 'You', when: `${openTicket.value.age} ago`, them: false,
|
||||
body: `Hi — we're seeing slow delivery to Gmail recipients from @dezky.com. Started yesterday around 14:00 CET. SPF and DKIM all check out via mxtoolbox. Could you investigate?` },
|
||||
{ who: 'Sofie Lindberg', when: '4 h ago', them: true,
|
||||
body: `Thanks for the detailed report — we've reproduced it. Looks like our outbound IP got temporarily greylisted by Google after a brief spike. Working with Postmark to resolve. ETA 30 minutes.` },
|
||||
{ who: 'Sofie Lindberg', when: '2 h ago', them: true,
|
||||
body: `Postmark resolved the greylisting. Delivery should be back to normal. Can you confirm on your end and we'll close this out?` },
|
||||
]
|
||||
})
|
||||
|
||||
// --- New ticket ---
|
||||
const newTicket = reactive({
|
||||
subject: '',
|
||||
affected: 'Mail · delivery to Gmail',
|
||||
severity: 'P3' as 'P1' | 'P2' | 'P3' | 'P4',
|
||||
body: '',
|
||||
})
|
||||
|
||||
function submitTicket() {
|
||||
toast.ok('Ticket submitted', `Severity ${newTicket.severity} · we'll reply within SLA`)
|
||||
newTicket.subject = ''
|
||||
newTicket.body = ''
|
||||
tab.value = 'tickets'
|
||||
}
|
||||
|
||||
// Tone resolver for ticket statuses (matches source TicketsTab inline logic).
|
||||
function ticketTone(status: string): 'ok' | 'info' | 'warn' {
|
||||
if (status === 'resolved') return 'ok'
|
||||
if (status === 'in progress') return 'info'
|
||||
return 'warn'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<PageHeader
|
||||
eyebrow="Get unstuck"
|
||||
title="Help & support"
|
||||
subtitle="Search the knowledge base, file a ticket, or pick up an existing conversation."
|
||||
>
|
||||
<template #actions>
|
||||
<UiButton variant="secondary" @click="toast.info('Opening live chat')">
|
||||
<template #leading><UiIcon name="chat" :size="13" /></template>
|
||||
Live chat
|
||||
</UiButton>
|
||||
<UiButton variant="primary" @click="tab = 'new'">
|
||||
<template #leading><UiIcon name="plus" :size="13" /></template>
|
||||
New ticket
|
||||
</UiButton>
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<div class="tabs-wrap">
|
||||
<Tabs
|
||||
v-model="tab"
|
||||
:items="[
|
||||
{ value: 'kb', label: 'Knowledge base' },
|
||||
{ value: 'tickets', label: 'My tickets', count: myTickets.length },
|
||||
{ value: 'new', label: 'New ticket' },
|
||||
{ value: 'contact', label: 'Contact' },
|
||||
]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<!-- Knowledge base -->
|
||||
<section v-if="tab === 'kb'">
|
||||
<div class="search-wrap">
|
||||
<div class="search">
|
||||
<UiIcon name="search" :size="18" stroke="var(--text-mute)" />
|
||||
<input v-model="q" placeholder="Search articles… try 'MFA setup' or 'OIOUBL'" />
|
||||
<span class="kbd">⌘/</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Popular row -->
|
||||
<div class="kb-section">
|
||||
<Eyebrow style="display: block; margin-bottom: 12px;">Popular</Eyebrow>
|
||||
<div class="popular-grid">
|
||||
<button v-for="a in popular" :key="a.id" class="popular-tile" @click="toast.info('Opening ' + a.title)">
|
||||
<span class="pt-icon"><UiIcon name="file" :size="16" /></span>
|
||||
<div class="pt-text">
|
||||
<div class="pt-title">{{ a.title }}</div>
|
||||
<Mono dim>{{ a.category }} · {{ a.read }}</Mono>
|
||||
</div>
|
||||
<UiIcon name="arrowRight" :size="14" stroke="var(--text-mute)" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- All categories -->
|
||||
<Eyebrow style="display: block; margin-bottom: 12px;">All categories</Eyebrow>
|
||||
<div class="cat-grid">
|
||||
<Card v-for="[cat, articles] in categories" :key="cat" :pad="0">
|
||||
<div class="cat-head">
|
||||
<div class="cat-name">{{ cat }}</div>
|
||||
<Mono dim>{{ articles.length }} article{{ articles.length > 1 ? 's' : '' }}</Mono>
|
||||
</div>
|
||||
<button
|
||||
v-for="a in articles"
|
||||
:key="a.id"
|
||||
class="cat-row"
|
||||
@click="toast.info('Opening ' + a.title)"
|
||||
>
|
||||
<div class="cr-text">
|
||||
<div class="cr-title">{{ a.title }}</div>
|
||||
<Mono dim>{{ a.read }} read</Mono>
|
||||
</div>
|
||||
<UiIcon name="chevRight" :size="13" stroke="var(--text-mute)" />
|
||||
</button>
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- My tickets -->
|
||||
<section v-else-if="tab === 'tickets'">
|
||||
<Card :pad="0">
|
||||
<table class="tickets">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Subject</th>
|
||||
<th>Status</th>
|
||||
<th>Severity</th>
|
||||
<th>Age</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="t in myTickets" :key="t.id" @click="openTicket = t">
|
||||
<td><Mono>{{ t.id }}</Mono></td>
|
||||
<td>
|
||||
<div class="t-subj">{{ t.title }}</div>
|
||||
<Mono dim>{{ t.updates }} update{{ t.updates > 1 ? 's' : '' }} · last {{ t.last }}</Mono>
|
||||
</td>
|
||||
<td><Badge :tone="ticketTone(t.status)" dot>{{ t.status }}</Badge></td>
|
||||
<td><Mono>{{ t.severity }}</Mono></td>
|
||||
<td><Mono dim>{{ t.age }}</Mono></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
<!-- New ticket -->
|
||||
<section v-else-if="tab === 'new'">
|
||||
<div class="new-wrap">
|
||||
<p class="new-intro">
|
||||
Tell us what's not working and we'll get back to you within your plan's SLA. Most P3 tickets are answered within 4 hours during business days.
|
||||
</p>
|
||||
<div class="new-form">
|
||||
<EnduserFormField label="Subject">
|
||||
<input v-model="newTicket.subject" placeholder="What's the problem in one sentence?" />
|
||||
</EnduserFormField>
|
||||
<EnduserFormField label="Affected area">
|
||||
<input v-model="newTicket.affected" />
|
||||
</EnduserFormField>
|
||||
<EnduserFormField label="Severity">
|
||||
<div class="sev-row">
|
||||
<button
|
||||
v-for="s in (['P1', 'P2', 'P3', 'P4'] as const)"
|
||||
:key="s"
|
||||
:class="{ active: newTicket.severity === s }"
|
||||
@click="newTicket.severity = s"
|
||||
>{{ s }}</button>
|
||||
</div>
|
||||
<div class="sev-help">
|
||||
<b>P1</b> · outage affecting whole org · <b>P2</b> · major feature broken · <b>P3</b> · standard · <b>P4</b> · question / feature request
|
||||
</div>
|
||||
</EnduserFormField>
|
||||
<EnduserFormField label="What happened">
|
||||
<textarea v-model="newTicket.body" placeholder="What did you try? What did you expect to happen? What actually happened?" rows="6" />
|
||||
</EnduserFormField>
|
||||
<EnduserFormField label="Attachments">
|
||||
<button class="drop" @click="toast.info('File picker stub')">
|
||||
<UiIcon name="upload" :size="14" />
|
||||
<span>Drag screenshots or click to browse · 25 MB limit</span>
|
||||
</button>
|
||||
</EnduserFormField>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<UiButton variant="ghost" @click="toast.info('Draft saved')">Save draft</UiButton>
|
||||
<UiButton variant="primary" @click="submitTicket">
|
||||
<template #leading><UiIcon name="mail" :size="13" /></template>
|
||||
Submit ticket
|
||||
</UiButton>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Contact -->
|
||||
<section v-else-if="tab === 'contact'">
|
||||
<div class="contact-grid">
|
||||
<Card>
|
||||
<div class="c-card">
|
||||
<span class="c-tile primary"><UiIcon name="chat" :size="20" /></span>
|
||||
<div>
|
||||
<div class="c-l">Live chat</div>
|
||||
<div class="c-d">Available Mon–Fri · 08:00–18:00 CET</div>
|
||||
</div>
|
||||
<UiButton variant="primary" @click="toast.info('Live chat opening')">
|
||||
Open chat
|
||||
<template #trailing><UiIcon name="chevRight" :size="14" /></template>
|
||||
</UiButton>
|
||||
</div>
|
||||
</Card>
|
||||
<Card>
|
||||
<div class="c-card">
|
||||
<span class="c-tile"><UiIcon name="mail" :size="20" /></span>
|
||||
<div>
|
||||
<div class="c-l">Email</div>
|
||||
<div class="c-d">support@dezky.com · response within 4h</div>
|
||||
</div>
|
||||
<UiButton variant="secondary" @click="toast.info('Composing email')">
|
||||
Compose mail
|
||||
<template #trailing><UiIcon name="chevRight" :size="14" /></template>
|
||||
</UiButton>
|
||||
</div>
|
||||
</Card>
|
||||
<Card>
|
||||
<div class="c-card">
|
||||
<span class="c-tile"><UiIcon name="video" :size="20" /></span>
|
||||
<div>
|
||||
<div class="c-l">Schedule a call</div>
|
||||
<div class="c-d">For complex setup or migrations</div>
|
||||
</div>
|
||||
<UiButton variant="secondary" @click="toast.info('Opening scheduler')">
|
||||
Book 30 min
|
||||
<template #trailing><UiIcon name="chevRight" :size="14" /></template>
|
||||
</UiButton>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div class="escalation">
|
||||
<Eyebrow style="display: block; margin-bottom: 10px;">Escalation</Eyebrow>
|
||||
<div class="esc-grid">
|
||||
<div>
|
||||
<Mono dim>P1 outage · 24/7</Mono>
|
||||
<div class="esc-val">+45 70 70 12 34 · oncall@dezky.com</div>
|
||||
</div>
|
||||
<div>
|
||||
<Mono dim>Account manager</Mono>
|
||||
<div class="esc-val">Mette Holst · mette@dezky.com</div>
|
||||
</div>
|
||||
<div>
|
||||
<Mono dim>Status page</Mono>
|
||||
<div class="esc-val"><a href="#">status.dezky.com</a></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- Ticket detail side panel -->
|
||||
<SidePanel
|
||||
:open="openTicket !== null"
|
||||
width="lg"
|
||||
:eyebrow="openTicket?.id"
|
||||
:title="openTicket?.title"
|
||||
@close="openTicket = null"
|
||||
>
|
||||
<template #header v-if="openTicket">
|
||||
<div class="ticket-head">
|
||||
<Badge :tone="ticketTone(openTicket.status)" dot>{{ openTicket.status }}</Badge>
|
||||
<Badge tone="neutral">{{ openTicket.severity }}</Badge>
|
||||
<Mono dim>opened {{ openTicket.age }} ago</Mono>
|
||||
</div>
|
||||
</template>
|
||||
<div class="thread">
|
||||
<div v-for="(m, i) in ticketThread" :key="i" class="msg" :data-them="m.them">
|
||||
<div class="msg-head">
|
||||
<Avatar v-if="m.them" :name="m.who" :size="24" />
|
||||
<span v-else class="msg-you">YOU</span>
|
||||
<span class="msg-who">{{ m.who }}</span>
|
||||
<Mono dim>{{ m.when }}</Mono>
|
||||
</div>
|
||||
<div class="msg-body">{{ m.body }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="reply-box">
|
||||
<textarea placeholder="Write a reply…" rows="4" />
|
||||
</div>
|
||||
<template #footer>
|
||||
<UiButton variant="ghost" @click="toast.ok('Ticket marked resolved')">Mark as resolved</UiButton>
|
||||
<div style="flex: 1;" />
|
||||
<UiButton variant="primary" @click="toast.info('Reply sent')">
|
||||
<template #leading><UiIcon name="mail" :size="13" /></template>
|
||||
Reply
|
||||
</UiButton>
|
||||
</template>
|
||||
</SidePanel>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.tabs-wrap { padding: 0 40px; margin-top: 16px; }
|
||||
.content { padding: 20px 40px 64px 40px; }
|
||||
|
||||
/* KB search */
|
||||
.search-wrap { max-width: 720px; margin: 0 auto 28px auto; }
|
||||
.search {
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
padding: 0 20px; height: 56px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
}
|
||||
.search input {
|
||||
flex: 1; border: none; outline: none; background: transparent;
|
||||
font-size: 15px; color: var(--text); font-family: inherit;
|
||||
}
|
||||
.kbd {
|
||||
font-family: var(--font-mono); font-size: 11px;
|
||||
padding: 3px 8px; background: var(--bg);
|
||||
border-radius: 4px; color: var(--text-mute);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.kb-section { margin-bottom: 28px; }
|
||||
|
||||
/* Popular tiles */
|
||||
.popular-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 10px; }
|
||||
.popular-tile {
|
||||
display: flex; align-items: center; gap: 14px;
|
||||
padding: 18px;
|
||||
background: var(--surface); border: 1px solid var(--border); border-radius: 8px;
|
||||
cursor: pointer; font-family: inherit; color: var(--text); text-align: left;
|
||||
}
|
||||
.popular-tile:hover { border-color: var(--text); }
|
||||
.pt-icon {
|
||||
width: 36px; height: 36px; border-radius: 7px;
|
||||
background: var(--text); color: var(--bg);
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.pt-text { flex: 1; min-width: 0; }
|
||||
.pt-title { font-size: 14px; font-weight: 500; }
|
||||
.pt-text :deep(.mono) { display: block; margin-top: 2px; }
|
||||
|
||||
/* Categories grid */
|
||||
.cat-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 12px; }
|
||||
.cat-head { padding: 16px 20px; border-bottom: 1px solid var(--border); }
|
||||
.cat-name { font-family: var(--font-display); font-weight: 600; font-size: 16px; }
|
||||
.cat-head :deep(.mono) { display: block; margin-top: 2px; }
|
||||
.cat-row {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
width: 100%; padding: 12px 20px;
|
||||
background: transparent; border: none; border-bottom: 1px solid var(--border);
|
||||
cursor: pointer; font-family: inherit; color: var(--text); text-align: left;
|
||||
}
|
||||
.cat-row:last-child { border-bottom: none; }
|
||||
.cat-row:hover { background: var(--row-hover); }
|
||||
.cr-text { flex: 1; min-width: 0; }
|
||||
.cr-title { font-size: 13px; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.cr-text :deep(.mono) { display: block; margin-top: 2px; }
|
||||
|
||||
/* Tickets table */
|
||||
.tickets { width: 100%; border-collapse: collapse; }
|
||||
.tickets thead th {
|
||||
text-align: left;
|
||||
padding: 12px 22px;
|
||||
font-family: var(--font-mono); font-size: 10px; letter-spacing: 0.12em; text-transform: uppercase; color: var(--text-mute);
|
||||
font-weight: 500; background: var(--bg); border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.tickets tbody td {
|
||||
padding: 14px 22px;
|
||||
font-size: 13px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
vertical-align: top;
|
||||
}
|
||||
.tickets tbody tr { cursor: pointer; }
|
||||
.tickets tbody tr:hover { background: var(--row-hover); }
|
||||
.tickets tbody tr:last-child td { border-bottom: none; }
|
||||
.t-subj { font-size: 13px; font-weight: 500; }
|
||||
.tickets td :deep(.mono) { display: block; margin-top: 2px; }
|
||||
|
||||
/* New ticket form */
|
||||
.new-wrap { max-width: 680px; }
|
||||
.new-intro { color: var(--text-dim); font-size: 14px; line-height: 1.6; margin: 0 0 24px 0; }
|
||||
.new-form { display: flex; flex-direction: column; gap: 14px; }
|
||||
.new-form textarea {
|
||||
width: 100%; min-height: 140px; padding: 12px;
|
||||
background: var(--surface); border: 1px solid var(--border); border-radius: 6px;
|
||||
font-size: 13px; color: var(--text); font-family: inherit; resize: vertical; line-height: 1.55;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.sev-row { display: inline-flex; gap: 6px; }
|
||||
.sev-row button {
|
||||
padding: 8px 18px; border-radius: 6px;
|
||||
background: var(--surface); border: 1px solid var(--border);
|
||||
color: var(--text); font-family: inherit; font-size: 13px; font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
.sev-row button.active { background: var(--text); color: var(--bg); border-color: var(--text); }
|
||||
.sev-help { font-size: 12px; color: var(--text-mute); margin-top: 6px; line-height: 1.5; }
|
||||
.sev-help b { color: var(--text); }
|
||||
|
||||
.drop {
|
||||
width: 100%; padding: 20px 14px;
|
||||
background: transparent; border: 1px dashed var(--border-hi); border-radius: 6px;
|
||||
color: var(--text-mute); cursor: pointer; font-family: inherit; font-size: 13px;
|
||||
display: flex; align-items: center; justify-content: center; gap: 8px;
|
||||
}
|
||||
.drop:hover { border-color: var(--text); color: var(--text); background: var(--row-hover); }
|
||||
|
||||
.form-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 20px; }
|
||||
|
||||
/* Contact */
|
||||
.contact-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; max-width: 1100px; margin-bottom: 16px; }
|
||||
.c-card { display: flex; flex-direction: column; align-items: flex-start; gap: 16px; }
|
||||
.c-tile {
|
||||
width: 44px; height: 44px; border-radius: 10px;
|
||||
background: var(--bg); color: var(--text-dim);
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.c-tile.primary { background: var(--text); color: var(--bg); }
|
||||
.c-l { font-family: var(--font-display); font-weight: 600; font-size: 18px; }
|
||||
.c-d { margin-top: 6px; font-size: 13px; color: var(--text-mute); line-height: 1.5; }
|
||||
|
||||
.escalation {
|
||||
padding: 16px;
|
||||
background: var(--surface); border: 1px solid var(--border); border-radius: 8px;
|
||||
max-width: 1100px;
|
||||
}
|
||||
.esc-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; font-size: 13px; }
|
||||
.esc-val { margin-top: 4px; }
|
||||
.esc-val a { color: inherit; }
|
||||
|
||||
/* Ticket detail side panel */
|
||||
.ticket-head { display: flex; gap: 8px; align-items: center; margin-top: 8px; flex-wrap: wrap; }
|
||||
.thread { display: flex; flex-direction: column; gap: 16px; }
|
||||
.msg {
|
||||
padding: 14px;
|
||||
background: var(--surface); border: 1px solid var(--border); border-radius: 8px;
|
||||
}
|
||||
.msg[data-them='true'] { background: var(--bg); }
|
||||
.msg-head { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; }
|
||||
.msg-you {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
width: 24px; height: 24px; border-radius: 999px;
|
||||
background: var(--text); color: var(--bg);
|
||||
font-size: 10px; font-weight: 700;
|
||||
}
|
||||
.msg-who { font-size: 13px; font-weight: 500; }
|
||||
.msg-body { font-size: 13px; line-height: 1.6; color: var(--text); }
|
||||
|
||||
.reply-box { padding-top: 16px; }
|
||||
.reply-box textarea {
|
||||
width: 100%; min-height: 100px; padding: 12px;
|
||||
background: var(--surface); border: 1px solid var(--border); border-radius: 6px;
|
||||
font-size: 13px; color: var(--text); font-family: inherit; resize: vertical;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
</style>
|
||||
+610
-156
@@ -1,186 +1,640 @@
|
||||
<script setup lang="ts">
|
||||
// Post-login landing. Auth middleware (nuxt-oidc-auth) gates access — anonymous
|
||||
// visitors get bounced to /login by the customLoginPage config in nuxt.config.ts.
|
||||
const { user, logout } = useOidcAuth()
|
||||
// End-user dashboard. Faithfully ports project/platform-screens.jsx
|
||||
// `EndUserDashboard` — same layout, same spacing tokens, same copy.
|
||||
|
||||
|
||||
import type { IconName } from '~/components/UiIcon.vue'
|
||||
import { appTiles, currentUser, todayAgenda, recentFiles, needsAttention } from '~/data/enduser'
|
||||
|
||||
const toast = useToast()
|
||||
const router = useRouter()
|
||||
|
||||
const presence = ref<'available' | 'meeting' | 'focus' | 'away'>('available')
|
||||
|
||||
// Date eyebrow ("Monday, 25 May") + dynamic greeting that follows the source's
|
||||
// hour-bucket rules.
|
||||
const now = new Date()
|
||||
const dateEyebrow = now.toLocaleDateString('en-GB', { weekday: 'long', day: 'numeric', month: 'long' })
|
||||
const firstName = currentUser.name.split(' ')[0]
|
||||
const greet = (() => {
|
||||
const h = now.getHours()
|
||||
if (h < 5) return 'Still up'
|
||||
if (h < 12) return 'Good morning'
|
||||
if (h < 17) return 'Good afternoon'
|
||||
return 'Good evening'
|
||||
})()
|
||||
|
||||
const previewFile = ref<typeof recentFiles[number] | null>(null)
|
||||
const joining = ref<typeof todayAgenda[number] | null>(null)
|
||||
const joinMic = ref(true)
|
||||
const joinCam = ref(true)
|
||||
watch(joining, (v) => { if (v) { joinMic.value = true; joinCam.value = true } })
|
||||
|
||||
function openApp(name: string) {
|
||||
toast.info(`Opening ${name}…`)
|
||||
}
|
||||
|
||||
const APP_ICONS: Record<string, IconName> = {
|
||||
mail: 'mail', drev: 'folder', moder: 'video', chat: 'chat',
|
||||
}
|
||||
|
||||
// Tone → icon tint colour for the pending-task icon boxes.
|
||||
function attentionIconStyle(tone: string) {
|
||||
if (tone === 'bad') return { background: 'rgba(226, 48, 48, 0.12)', color: 'var(--bad)' }
|
||||
if (tone === 'warn') return { background: 'rgba(232, 154, 31, 0.12)', color: 'var(--warn)' }
|
||||
return { background: 'rgba(10, 10, 10, 0.08)', color: 'var(--text)' }
|
||||
}
|
||||
|
||||
function fireAttention(item: typeof needsAttention[number]) {
|
||||
if (item.target === 'security') return router.push('/security')
|
||||
if (item.target === 'file') {
|
||||
previewFile.value = { id: 'attn-q3', name: 'Q3 forecast.xlsx', path: 'Drev · /Finance', updated: 'yesterday', size: '482 KB' }
|
||||
return
|
||||
}
|
||||
toast.ok(`${item.cta} · ${item.title}`)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<header class="bar">
|
||||
<div class="brand">
|
||||
<span class="brand-tile">
|
||||
<NodeMark :size="22" />
|
||||
</span>
|
||||
<span class="brand-name">dezky</span>
|
||||
</div>
|
||||
<div class="me">
|
||||
<span class="email">{{ user?.userInfo?.email || user?.userName }}</span>
|
||||
<button class="logout" @click="logout()">sign out</button>
|
||||
<div class="dash">
|
||||
<!-- Greeting + presence -->
|
||||
<header class="head">
|
||||
<div>
|
||||
<Eyebrow>{{ dateEyebrow }}</Eyebrow>
|
||||
<h1>{{ greet }}, {{ firstName }}.</h1>
|
||||
</div>
|
||||
<EnduserPresenceSelector v-model="presence" />
|
||||
</header>
|
||||
|
||||
<main class="stage">
|
||||
<section class="hero">
|
||||
<p class="eyebrow">Workspace · welcome</p>
|
||||
<h1>Hi, {{ user?.userInfo?.name || user?.userName }}.</h1>
|
||||
<p class="tagline">Sovereign workspace platform · all your services in one place.</p>
|
||||
</section>
|
||||
<!-- 4 app tiles -->
|
||||
<section class="tiles">
|
||||
<button v-for="t in appTiles" :key="t.key" class="tile" @click="openApp(t.name)">
|
||||
<span class="tile-icon">
|
||||
<UiIcon :name="APP_ICONS[t.key] ?? 'file'" :size="18" />
|
||||
</span>
|
||||
<div class="tile-body">
|
||||
<div class="tile-name">{{ t.name }}</div>
|
||||
<div class="tile-badge">{{ t.badge }}</div>
|
||||
</div>
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section class="grid">
|
||||
<a href="https://files.dezky.local" target="_blank" class="tile">
|
||||
<span class="tile-name">Files</span>
|
||||
<span class="tile-meta">OCIS · S3-backed storage</span>
|
||||
</a>
|
||||
<a href="https://mail.dezky.local/admin/" target="_blank" class="tile">
|
||||
<span class="tile-name">Mail</span>
|
||||
<span class="tile-meta">Stalwart · JMAP/IMAP/SMTP</span>
|
||||
</a>
|
||||
<a href="https://office.dezky.local" target="_blank" class="tile">
|
||||
<span class="tile-name">Office</span>
|
||||
<span class="tile-meta">Collabora · document editing</span>
|
||||
</a>
|
||||
<a href="https://auth.dezky.local" target="_blank" class="tile">
|
||||
<span class="tile-name">Identity</span>
|
||||
<span class="tile-meta">Authentik · SSO & access</span>
|
||||
</a>
|
||||
</section>
|
||||
</main>
|
||||
<!-- Today's meetings + recent files -->
|
||||
<section class="two-col">
|
||||
<Card :pad="0">
|
||||
<div class="card-head">
|
||||
<div>
|
||||
<Eyebrow>Today</Eyebrow>
|
||||
<div class="card-title">Meetings</div>
|
||||
</div>
|
||||
<UiButton size="sm" variant="ghost" @click="toast.info('Opening calendar at cal.dezky.com')">
|
||||
View calendar
|
||||
<template #trailing><UiIcon name="chevRight" :size="12" /></template>
|
||||
</UiButton>
|
||||
</div>
|
||||
<div
|
||||
v-for="(m, i) in todayAgenda"
|
||||
:key="m.id"
|
||||
class="agenda-row"
|
||||
:class="{ last: i === todayAgenda.length - 1 }"
|
||||
>
|
||||
<div class="agenda-time">{{ m.time }}</div>
|
||||
<div class="agenda-meta">
|
||||
<div class="agenda-title">{{ m.title }}</div>
|
||||
<div class="agenda-with">{{ m.with }}</div>
|
||||
</div>
|
||||
<div class="agenda-in">in {{ m.in }}</div>
|
||||
<UiButton size="sm" variant="primary" @click="joining = m">Join</UiButton>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card :pad="0">
|
||||
<div class="card-head no-action">
|
||||
<Eyebrow>Recent</Eyebrow>
|
||||
<div class="card-title">Files</div>
|
||||
</div>
|
||||
<button
|
||||
v-for="(f, i) in recentFiles.slice(0, 5)"
|
||||
:key="f.id"
|
||||
class="file-row"
|
||||
:class="{ last: i === 4 }"
|
||||
@click="previewFile = f"
|
||||
>
|
||||
<span class="file-icon"><UiIcon name="file" :size="14" /></span>
|
||||
<div class="file-text">
|
||||
<div class="file-name">{{ f.name }}</div>
|
||||
<div class="file-meta">{{ f.path }} · {{ f.updated }}</div>
|
||||
</div>
|
||||
<UiIcon name="chevRight" :size="12" stroke="var(--text-mute)" />
|
||||
</button>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
<!-- Pending tasks -->
|
||||
<section class="block">
|
||||
<Card :pad="0">
|
||||
<div class="card-head">
|
||||
<div>
|
||||
<Eyebrow>Needs your attention</Eyebrow>
|
||||
<div class="card-title">Pending · {{ needsAttention.length }} items</div>
|
||||
</div>
|
||||
<UiButton size="sm" variant="ghost" @click="toast.info('Opening full task list')">
|
||||
See all
|
||||
<template #trailing><UiIcon name="chevRight" :size="12" /></template>
|
||||
</UiButton>
|
||||
</div>
|
||||
<div class="attention">
|
||||
<div
|
||||
v-for="(t, i) in needsAttention"
|
||||
:key="t.id"
|
||||
class="att-row"
|
||||
:class="{
|
||||
'right-col': i % 2 === 1,
|
||||
'bottom': i >= needsAttention.length - 2,
|
||||
}"
|
||||
>
|
||||
<span class="att-icon" :style="attentionIconStyle(t.tone)">
|
||||
<UiIcon :name="(t.icon as IconName)" :size="14" />
|
||||
</span>
|
||||
<div class="att-text">
|
||||
<div class="att-title">{{ t.title }}</div>
|
||||
<Mono dim>{{ t.hint }}</Mono>
|
||||
</div>
|
||||
<UiButton
|
||||
size="sm"
|
||||
:variant="t.tone === 'bad' ? 'primary' : 'secondary'"
|
||||
@click="fireAttention(t)"
|
||||
>
|
||||
{{ t.cta }}
|
||||
</UiButton>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
<!-- Announcement + system status -->
|
||||
<section class="two-col">
|
||||
<div class="announce">
|
||||
<div class="announce-text">
|
||||
<div class="announce-kicker">// announcement</div>
|
||||
<div class="announce-head">We're moving to single-sign-on next Monday. Set up your authenticator app this week.</div>
|
||||
<div class="announce-by">posted by Anne · 2h ago</div>
|
||||
</div>
|
||||
<UiButton variant="primary" @click="router.push('/security')">Set it up</UiButton>
|
||||
</div>
|
||||
|
||||
<Card :pad="0">
|
||||
<div class="card-head no-action">
|
||||
<Eyebrow>System</Eyebrow>
|
||||
<div class="card-title">All services operational</div>
|
||||
</div>
|
||||
<div class="services">
|
||||
<div v-for="s in ['Mail', 'Drev', 'Møder', 'Chat', 'Auth (SSO)']" :key="s" class="svc-row">
|
||||
<span class="svc-name">{{ s }}</span>
|
||||
<div class="svc-state">
|
||||
<StatusDot color="var(--ok)" :size="7" :glow="false" />
|
||||
<span>operational</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
<!-- File preview modal -->
|
||||
<Modal :open="previewFile !== null" eyebrow="Drev · preview" :title="previewFile?.name" size="md" @close="previewFile = null">
|
||||
<div class="preview">
|
||||
<div class="preview-stage">
|
||||
<span class="preview-icon"><UiIcon name="file" :size="28" /></span>
|
||||
<Mono dim>preview not available · open in Drev to view</Mono>
|
||||
</div>
|
||||
<div class="preview-meta">
|
||||
<dl>
|
||||
<div><dt>Location</dt><dd><Mono>{{ previewFile?.path }}</Mono></dd></div>
|
||||
<div><dt>Modified</dt><dd>{{ previewFile?.updated }}</dd></div>
|
||||
<div><dt>Size</dt><dd><Mono>{{ previewFile?.size ?? '2.4 MB' }}</Mono></dd></div>
|
||||
<div><dt>Shared with</dt><dd>3 people · Engineering team</dd></div>
|
||||
<div><dt>Permissions</dt><dd>You can edit</dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
<div class="preview-actions">
|
||||
<UiButton size="sm" variant="secondary" @click="toast.ok('Link copied')">
|
||||
<template #leading><UiIcon name="copy" :size="13" /></template>
|
||||
Copy link
|
||||
</UiButton>
|
||||
<UiButton size="sm" variant="secondary" @click="toast.info('Opening sharing')">
|
||||
<template #leading><UiIcon name="users" :size="13" /></template>
|
||||
Manage access
|
||||
</UiButton>
|
||||
<UiButton size="sm" variant="ghost" @click="toast.info('Starred')">
|
||||
<template #leading><UiIcon name="external" :size="13" /></template>
|
||||
Star
|
||||
</UiButton>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<UiButton variant="ghost" @click="previewFile = null">Close</UiButton>
|
||||
<div style="flex: 1" />
|
||||
<UiButton variant="secondary" @click="toast.info('Downloading')">
|
||||
<template #leading><UiIcon name="download" :size="13" /></template>
|
||||
Download
|
||||
</UiButton>
|
||||
<UiButton variant="primary" @click="toast.info('Opening in Drev')">
|
||||
<template #leading><UiIcon name="external" :size="13" /></template>
|
||||
Open in Drev
|
||||
</UiButton>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
<!-- Join meeting modal -->
|
||||
<Modal :open="joining !== null" eyebrow="Møder" :title="joining ? `Join · ${joining.title}` : ''" size="md" @close="joining = null">
|
||||
<div class="join">
|
||||
<div class="cam">
|
||||
<div class="cam-avatar">A</div>
|
||||
<div class="cam-label">camera preview</div>
|
||||
</div>
|
||||
<div class="join-info">
|
||||
<dl>
|
||||
<div><dt>Meeting</dt><dd>{{ joining?.title }}</dd></div>
|
||||
<div><dt>With</dt><dd>{{ joining?.with }}</dd></div>
|
||||
<div><dt>Starts</dt><dd>{{ joining?.time }} · in {{ joining?.in }}</dd></div>
|
||||
<div><dt>Room</dt><dd><Mono>meet.dezky.com/{{ joining?.title.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '') }}</Mono></dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
<div class="join-toggles">
|
||||
<button class="toggle" :class="{ off: !joinMic }" @click="joinMic = !joinMic">
|
||||
<UiIcon :name="joinMic ? 'check' : 'x'" :size="14" :stroke="joinMic ? 'var(--ok)' : 'var(--bad)'" :stroke-width="2.5" />
|
||||
<div class="toggle-text">
|
||||
<div class="toggle-label">Microphone</div>
|
||||
<Mono dim>{{ joinMic ? 'unmuted' : 'muted' }}</Mono>
|
||||
</div>
|
||||
</button>
|
||||
<button class="toggle" :class="{ off: !joinCam }" @click="joinCam = !joinCam">
|
||||
<UiIcon :name="joinCam ? 'check' : 'x'" :size="14" :stroke="joinCam ? 'var(--ok)' : 'var(--bad)'" :stroke-width="2.5" />
|
||||
<div class="toggle-text">
|
||||
<div class="toggle-label">Camera</div>
|
||||
<Mono dim>{{ joinCam ? 'on' : 'off' }}</Mono>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<UiButton variant="ghost" @click="joining = null">Cancel</UiButton>
|
||||
<UiButton variant="primary" @click="joining = null; toast.ok('Joining meeting…')">
|
||||
<template #leading><UiIcon name="video" :size="13" /></template>
|
||||
Join now
|
||||
</UiButton>
|
||||
</template>
|
||||
</Modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
/* Container — single column, 1400px max, balanced 32 top / 40 sides / 64 bottom. */
|
||||
.dash {
|
||||
padding: 32px 40px 64px 40px;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* Greeting strip. No subtitle line, no bottom border — the design lets the
|
||||
tiles below do the visual divide. */
|
||||
.head {
|
||||
margin-bottom: 32px;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.head h1 {
|
||||
font-family: var(--font-display);
|
||||
font-size: 44px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.03em;
|
||||
line-height: 1.05;
|
||||
margin: 8px 0 0 0;
|
||||
}
|
||||
|
||||
/* App tiles — 4 col grid, 130 minHeight, dark inverted icon box. */
|
||||
.tiles {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 12px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.tile {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
min-height: 130px;
|
||||
cursor: pointer;
|
||||
color: inherit;
|
||||
font-family: inherit;
|
||||
text-align: left;
|
||||
transition: border-color 0.12s, background 0.12s;
|
||||
}
|
||||
|
||||
.bar {
|
||||
padding: 16px 24px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.brand-tile {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 7px;
|
||||
background: #0a0a0a;
|
||||
.tile:hover { border-color: var(--text); }
|
||||
.tile-icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 8px;
|
||||
background: var(--text);
|
||||
color: var(--bg);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.brand-name {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.me {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.email {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.logout {
|
||||
background: transparent;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 6px 12px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.logout:hover {
|
||||
background: rgba(10, 10, 10, 0.04);
|
||||
}
|
||||
|
||||
.stage {
|
||||
flex: 1;
|
||||
padding: 48px 24px;
|
||||
max-width: 960px;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.hero {
|
||||
margin-bottom: 48px;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-mute);
|
||||
margin: 0 0 12px 0;
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
margin: 0;
|
||||
font-family: var(--font-display);
|
||||
font-weight: 600;
|
||||
font-size: 40px;
|
||||
letter-spacing: -0.025em;
|
||||
line-height: 1.05;
|
||||
}
|
||||
|
||||
.tagline {
|
||||
margin: 12px 0 0 0;
|
||||
color: var(--text-dim);
|
||||
font-size: 15px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.tile {
|
||||
background: var(--elevated);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 20px;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
transition: border-color 120ms ease, transform 120ms ease;
|
||||
}
|
||||
|
||||
.tile:hover {
|
||||
border-color: var(--border-hi);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.tile-name {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 600;
|
||||
font-size: 17px;
|
||||
letter-spacing: -0.015em;
|
||||
}
|
||||
.tile-badge {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--text-mute);
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.tile-meta {
|
||||
/* Two-column blocks reused for meetings/files + announce/status */
|
||||
.two-col {
|
||||
display: grid;
|
||||
grid-template-columns: 1.6fr 1fr;
|
||||
gap: 16px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
.two-col:first-of-type { margin-top: 0; }
|
||||
|
||||
/* Stand-alone full-width sections between two-col rows. Source design uses
|
||||
marginTop: 16 between each top-level block after the tile grid. */
|
||||
.block { margin-top: 16px; }
|
||||
|
||||
/* Card head — eyebrow + 18px title + optional ghost action right */
|
||||
.card-head {
|
||||
padding: 20px 24px 16px 24px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
.card-head.no-action { display: block; }
|
||||
.card-title {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 600;
|
||||
font-size: 18px;
|
||||
margin-top: 4px;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
/* Meetings — 4-col row: time / meta / "in X" / Join */
|
||||
.agenda-row {
|
||||
padding: 14px 24px;
|
||||
display: grid;
|
||||
grid-template-columns: 60px 1fr auto auto;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.agenda-row.last { border-bottom: none; }
|
||||
.agenda-time {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.agenda-title { font-size: 14px; font-weight: 500; }
|
||||
.agenda-with { font-size: 12px; color: var(--text-mute); margin-top: 2px; }
|
||||
.agenda-in {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--text-mute);
|
||||
}
|
||||
|
||||
/* Recent files — 28x28 icon, name + meta line, chev */
|
||||
.file-row {
|
||||
padding: 12px 24px;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--border);
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
text-align: left;
|
||||
color: var(--text);
|
||||
transition: background 0.1s;
|
||||
}
|
||||
.file-row.last { border-bottom: none; }
|
||||
.file-row:hover { background: var(--row-hover); }
|
||||
.file-icon {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 6px;
|
||||
background: var(--bg);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-mute);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.file-text { flex: 1; min-width: 0; }
|
||||
.file-name {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.file-meta {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--text-mute);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* Pending tasks — 2-col grid, dividers only between cells, none on last row */
|
||||
.attention { display: grid; grid-template-columns: repeat(2, 1fr); gap: 0; }
|
||||
.att-row {
|
||||
padding: 14px 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.att-row:not(.right-col) { border-right: 1px solid var(--border); }
|
||||
.att-row.bottom { border-bottom: none; }
|
||||
.att-icon {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 6px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.att-text { flex: 1; min-width: 0; }
|
||||
.att-title { font-size: 13px; font-weight: 500; }
|
||||
|
||||
/* Announcement — carbon background, two-up flex row with button on the right */
|
||||
.announce {
|
||||
background: var(--text);
|
||||
color: var(--bg);
|
||||
border-radius: 8px;
|
||||
padding: 28px 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
}
|
||||
.announce-text { min-width: 0; }
|
||||
.announce-kicker {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--accent);
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
.announce-head {
|
||||
font-family: var(--font-display);
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.02em;
|
||||
margin-top: 8px;
|
||||
text-wrap: balance;
|
||||
line-height: 1.25;
|
||||
}
|
||||
.announce-by {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
opacity: 0.6;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
/* System services — 5 rows, mono name + green dot + 'operational' */
|
||||
.services {
|
||||
padding: 14px 24px 18px 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.svc-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 13px;
|
||||
}
|
||||
.svc-name { font-family: var(--font-mono); }
|
||||
.svc-state {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--text-mute);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* Preview modal */
|
||||
.preview { display: flex; flex-direction: column; gap: 14px; }
|
||||
.preview-stage {
|
||||
aspect-ratio: 4 / 3;
|
||||
background: var(--surface);
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.preview-icon {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 12px;
|
||||
background: var(--bg);
|
||||
color: var(--text-dim);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.preview-meta { padding: 14px; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; }
|
||||
.preview-actions { display: flex; gap: 8px; }
|
||||
.preview-meta dl, .join-info dl {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.preview-meta dl > div, .join-info dl > div { display: flex; gap: 12px; }
|
||||
.preview-meta dt, .join-info dt {
|
||||
width: 110px;
|
||||
flex-shrink: 0;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
color: var(--text-mute);
|
||||
}
|
||||
.preview-meta dd, .join-info dd { margin: 0; font-size: 13px; color: var(--text); }
|
||||
|
||||
/* Join meeting modal */
|
||||
.join { display: flex; flex-direction: column; gap: 16px; }
|
||||
.cam {
|
||||
aspect-ratio: 16 / 9;
|
||||
border-radius: 8px;
|
||||
background: linear-gradient(135deg, #0A0A0A, #1A1A1A);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.cam-avatar {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 999px;
|
||||
background: var(--accent);
|
||||
color: var(--accent-fg);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: var(--font-display);
|
||||
font-weight: 600;
|
||||
font-size: 32px;
|
||||
}
|
||||
.cam-label {
|
||||
position: absolute;
|
||||
bottom: 12px;
|
||||
left: 12px;
|
||||
color: #F4F3EE;
|
||||
opacity: 0.6;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
}
|
||||
.join-info { padding: 14px; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; }
|
||||
.join-toggles { display: flex; gap: 10px; }
|
||||
.toggle {
|
||||
flex: 1;
|
||||
padding: 12px 14px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
text-align: left;
|
||||
}
|
||||
.toggle.off {
|
||||
background: rgba(226, 48, 48, 0.08);
|
||||
border-color: rgba(226, 48, 48, 0.3);
|
||||
}
|
||||
.toggle-text { display: flex; flex-direction: column; }
|
||||
.toggle-label { font-size: 13px; font-weight: 500; }
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,407 @@
|
||||
<script setup lang="ts">
|
||||
// Partner audit log. Strict port of PartnerAuditScreen
|
||||
// (platform-partner-depth.jsx lines 1042-1098). Filter bar (search + Actor /
|
||||
// Customer / Action / Last) + cross-customer audit log table + footer note.
|
||||
// Click a row to open a detail SidePanel with before/after diff and origin.
|
||||
|
||||
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
// AuditRow shape the template + side panel render against. Built from the
|
||||
// real /api/partner/activity feed (see `rows` below).
|
||||
interface AuditRow {
|
||||
id: string
|
||||
when: string // formatted timestamp for the table
|
||||
whenIso: string // raw ISO for period filtering
|
||||
actor: string // actor email
|
||||
customer: string // tenant name resolved from tenantSlug, or '—'
|
||||
customerColor: string // tile colour for the cust swatch
|
||||
action: string // dotted verb e.g. 'partner.user_invited'
|
||||
target: string // resourceName from the audit event
|
||||
tone: 'info' | 'warn' | 'ok' | 'bad'
|
||||
}
|
||||
|
||||
interface PartnerTenant { _id: string; slug: string; name: string }
|
||||
interface ActivityEvent {
|
||||
_id: string
|
||||
at: string
|
||||
action: string
|
||||
resourceName?: string
|
||||
tenantSlug?: string
|
||||
outcome?: 'success' | 'failure' | 'pending'
|
||||
actor?: { email?: string; userId?: string }
|
||||
}
|
||||
|
||||
// Pull the partner's tenants (for slug→name + colour lookup) and recent
|
||||
// audit events. Limit 200 — generous compared to the dashboard's 8 — so
|
||||
// filters meaningfully shrink the visible set client-side. Pagination via
|
||||
// `?before` lands when 200 is regularly hit.
|
||||
const { data: tenants } = await useFetch<PartnerTenant[]>('/api/partner/tenants', {
|
||||
key: 'partner-tenants',
|
||||
default: () => [],
|
||||
})
|
||||
const { data: events } = await useFetch<ActivityEvent[]>('/api/partner/activity', {
|
||||
key: 'partner-activity-full',
|
||||
query: { limit: 200 },
|
||||
default: () => [],
|
||||
})
|
||||
|
||||
function tenantNameFromSlug(slug?: string): string {
|
||||
if (!slug) return '—'
|
||||
return tenants.value?.find((t) => t.slug === slug)?.name ?? slug
|
||||
}
|
||||
|
||||
// Deterministic colour per tenant slug so the swatch stays stable across
|
||||
// reloads even though we don't store brand colours on Tenant yet.
|
||||
const PALETTE = ['#D4FF3A', '#4D8BE8', '#34C77B', '#F0B14A', '#F05858', '#A78BFA']
|
||||
function tenantColor(slug?: string): string {
|
||||
if (!slug) return 'var(--text-mute)'
|
||||
let h = 0
|
||||
for (let i = 0; i < slug.length; i++) h = (h * 31 + slug.charCodeAt(i)) | 0
|
||||
return PALETTE[Math.abs(h) % PALETTE.length]
|
||||
}
|
||||
|
||||
function eventTone(e: ActivityEvent): AuditRow['tone'] {
|
||||
if (e.outcome === 'failure') return 'bad'
|
||||
if (e.outcome === 'pending') return 'warn'
|
||||
if (/\.(deleted|suspended|terminated|removed)$/.test(e.action)) return 'bad'
|
||||
return 'info'
|
||||
}
|
||||
|
||||
function fmtTime(iso: string): string {
|
||||
// Always full date + time. The "today shows time only" shortcut made it
|
||||
// unclear whether a bare "08.35" was this morning or yesterday morning;
|
||||
// for an audit log, consistency beats brevity.
|
||||
return new Date(iso).toLocaleString('da-DK', { dateStyle: 'short', timeStyle: 'short' })
|
||||
}
|
||||
|
||||
const rows = computed<AuditRow[]>(() =>
|
||||
(events.value ?? []).map((e) => ({
|
||||
id: e._id,
|
||||
when: fmtTime(e.at),
|
||||
whenIso: e.at,
|
||||
actor: e.actor?.email ?? 'system',
|
||||
customer: tenantNameFromSlug(e.tenantSlug),
|
||||
customerColor: tenantColor(e.tenantSlug),
|
||||
action: e.action,
|
||||
target: e.resourceName ?? '—',
|
||||
tone: eventTone(e),
|
||||
})),
|
||||
)
|
||||
|
||||
const query = ref('')
|
||||
const actorFilter = ref<string>('all')
|
||||
const customerFilter = ref<string>('all')
|
||||
const actionFilter = ref<string>('all')
|
||||
const periodFilter = ref<'24h' | '7d' | '30d'>('7d')
|
||||
|
||||
const actors = computed(() => Array.from(new Set(rows.value.map((r) => r.actor))))
|
||||
const actions = computed(() => Array.from(new Set(rows.value.map((r) => r.action))).sort())
|
||||
// Distinct customer list for the dropdown — replaces the fixture customers
|
||||
// array. '—' (partner-scoped events) shows up as a special "Partner-level"
|
||||
// option so filtering to those is easy.
|
||||
const customerOptions = computed(() => {
|
||||
const names = new Set<string>()
|
||||
for (const r of rows.value) names.add(r.customer)
|
||||
return Array.from(names).sort()
|
||||
})
|
||||
|
||||
const PERIOD_MS: Record<typeof periodFilter.value, number> = {
|
||||
'24h': 24 * 60 * 60 * 1000,
|
||||
'7d': 7 * 24 * 60 * 60 * 1000,
|
||||
'30d': 30 * 24 * 60 * 60 * 1000,
|
||||
}
|
||||
|
||||
const filtered = computed(() => {
|
||||
const cutoff = Date.now() - PERIOD_MS[periodFilter.value]
|
||||
return rows.value.filter((r) => {
|
||||
if (new Date(r.whenIso).getTime() < cutoff) return false
|
||||
if (actorFilter.value !== 'all' && r.actor !== actorFilter.value) return false
|
||||
if (actionFilter.value !== 'all' && r.action !== actionFilter.value) return false
|
||||
if (customerFilter.value !== 'all' && r.customer !== customerFilter.value) return false
|
||||
if (query.value) {
|
||||
const q = query.value.toLowerCase()
|
||||
if (!(r.actor + ' ' + r.customer + ' ' + r.action + ' ' + r.target).toLowerCase().includes(q)) return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
})
|
||||
|
||||
function customerColor(name: string) {
|
||||
if (name === '—') return 'var(--text-mute)'
|
||||
// Look up tenant by name to find the slug, then derive colour.
|
||||
const t = tenants.value?.find((x) => x.name === name)
|
||||
return tenantColor(t?.slug)
|
||||
}
|
||||
|
||||
const detail = ref<AuditRow | null>(null)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<PageHeader
|
||||
eyebrow="Compliance"
|
||||
title="Partner audit log"
|
||||
subtitle="Every action your team has taken across your customer portfolio. Customer admins see this in their own audit log too."
|
||||
/>
|
||||
|
||||
<div class="content">
|
||||
<div class="filters">
|
||||
<div class="search">
|
||||
<UiIcon name="search" :size="14" />
|
||||
<input v-model="query" placeholder="actor, customer, action…" />
|
||||
</div>
|
||||
|
||||
<div class="seg">
|
||||
<span class="seg-label">Actor</span>
|
||||
<select v-model="actorFilter">
|
||||
<option value="all">Anyone</option>
|
||||
<option v-for="a in actors" :key="a" :value="a">{{ a }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="seg">
|
||||
<span class="seg-label">Customer</span>
|
||||
<select v-model="customerFilter">
|
||||
<option value="all">All customers</option>
|
||||
<option v-for="name in customerOptions" :key="name" :value="name">{{ name }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="seg">
|
||||
<span class="seg-label">Action</span>
|
||||
<select v-model="actionFilter">
|
||||
<option value="all">All actions</option>
|
||||
<option v-for="a in actions" :key="a" :value="a">{{ a }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="seg">
|
||||
<span class="seg-label">Last</span>
|
||||
<select v-model="periodFilter">
|
||||
<option value="24h">24 hours</option>
|
||||
<option value="7d">7 days</option>
|
||||
<option value="30d">30 days</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="spacer" />
|
||||
<UiButton variant="secondary" @click="toast.ok('Exporting CSV', `${filtered.length} entries`)">
|
||||
<template #leading><UiIcon name="download" :size="14" /></template>
|
||||
Export CSV
|
||||
</UiButton>
|
||||
</div>
|
||||
|
||||
<Card :pad="0">
|
||||
<table class="dtable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Time</th>
|
||||
<th>Actor</th>
|
||||
<th>Customer</th>
|
||||
<th>Action</th>
|
||||
<th>Target</th>
|
||||
<th class="tone-col" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="r in filtered" :key="r.id" @click="detail = r">
|
||||
<td><Mono>{{ r.when }}</Mono></td>
|
||||
<td>
|
||||
<div class="actor-cell">
|
||||
<Avatar :name="r.actor" :size="22" />
|
||||
<div>
|
||||
<div class="actor-name">{{ r.actor }}</div>
|
||||
<Mono dim>partner</Mono>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<Mono v-if="r.customer === '—'" dim>—</Mono>
|
||||
<div v-else class="cust-cell">
|
||||
<div class="cust-swatch" :style="{ background: customerColor(r.customer) }" />
|
||||
<span>{{ r.customer }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td><Mono class="action-text">{{ r.action }}</Mono></td>
|
||||
<td><span class="target-text">{{ r.target }}</span></td>
|
||||
<td class="tone-col">
|
||||
<Badge :tone="r.tone" dot>{{ r.tone === 'bad' ? 'fail' : r.tone }}</Badge>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</Card>
|
||||
|
||||
<Mono dim class="footer-note">// retention 365 days · write-once · visible to customer admins on their own audit log</Mono>
|
||||
</div>
|
||||
|
||||
<!-- Detail side panel -->
|
||||
<SidePanel
|
||||
:open="!!detail"
|
||||
width="md"
|
||||
eyebrow="Audit event"
|
||||
:title="detail?.action || ''"
|
||||
@close="detail = null"
|
||||
>
|
||||
<template v-if="detail">
|
||||
<div class="detail-head">
|
||||
<Mono dim>{{ detail.when }}</Mono>
|
||||
<Badge :tone="detail.tone" dot>{{ detail.tone === 'bad' ? 'fail' : detail.tone }}</Badge>
|
||||
</div>
|
||||
|
||||
<div class="detail-section">
|
||||
<Eyebrow>Actor</Eyebrow>
|
||||
<div class="actor-row">
|
||||
<Avatar :name="detail.actor" :size="32" />
|
||||
<div>
|
||||
<div class="dn">{{ detail.actor }}</div>
|
||||
<Mono dim>partner staff</Mono>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-section">
|
||||
<Eyebrow>Target</Eyebrow>
|
||||
<div class="target-row">
|
||||
<div v-if="detail.customer !== '—'" class="cust-cell">
|
||||
<div class="cust-swatch" :style="{ background: customerColor(detail.customer) }" />
|
||||
<span>{{ detail.customer }}</span>
|
||||
</div>
|
||||
<Mono>{{ detail.target }}</Mono>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-section">
|
||||
<Eyebrow>Event ID</Eyebrow>
|
||||
<div class="eid"><Mono>{{ detail.id }}</Mono></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #footer>
|
||||
<div class="audit-footer">
|
||||
<UiIcon name="shield" :size="12" />
|
||||
<Mono dim>tamper-evident · retention 365 days</Mono>
|
||||
</div>
|
||||
</template>
|
||||
</SidePanel>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.content { padding: 20px 40px 64px; display: flex; flex-direction: column; gap: 12px; }
|
||||
|
||||
.filters { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
|
||||
.search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 0 10px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
width: 320px;
|
||||
color: var(--text-mute);
|
||||
}
|
||||
.search input {
|
||||
flex: 1;
|
||||
border: none;
|
||||
background: transparent;
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
padding: 9px 0;
|
||||
color: var(--text);
|
||||
}
|
||||
.search input:focus { outline: none; }
|
||||
|
||||
.seg {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 0 10px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
}
|
||||
.seg-label {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-mute);
|
||||
}
|
||||
.seg select {
|
||||
border: none;
|
||||
background: transparent;
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
color: var(--text);
|
||||
padding: 8px 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.seg select:focus { outline: none; }
|
||||
.spacer { flex: 1; }
|
||||
|
||||
.dtable { width: 100%; border-collapse: collapse; }
|
||||
.dtable th {
|
||||
text-align: left;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-mute);
|
||||
font-weight: 500;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.dtable th.tone-col, .dtable td.tone-col { width: 80px; text-align: right; }
|
||||
.dtable td {
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 13px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.dtable tbody tr { cursor: pointer; transition: background 80ms; }
|
||||
.dtable tbody tr:hover { background: var(--row-hover); }
|
||||
|
||||
.actor-cell { display: flex; align-items: center; gap: 8px; }
|
||||
.actor-name { font-size: 12px; font-weight: 500; }
|
||||
|
||||
.cust-cell { display: flex; align-items: center; gap: 6px; }
|
||||
.cust-swatch { width: 12px; height: 12px; border-radius: 2px; flex-shrink: 0; }
|
||||
|
||||
.action-text { font-weight: 500; }
|
||||
.target-text { font-size: 12px; color: var(--text-dim); }
|
||||
|
||||
.footer-note { display: block; margin-top: 4px; }
|
||||
|
||||
/* Side panel detail */
|
||||
.detail-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding-bottom: 14px;
|
||||
margin-bottom: 18px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.detail-section { margin-bottom: 20px; }
|
||||
.detail-section + .detail-section { padding-top: 20px; border-top: 1px solid var(--border); }
|
||||
|
||||
.actor-row { display: flex; align-items: center; gap: 12px; margin-top: 8px; }
|
||||
.dn { font-size: 14px; font-weight: 500; }
|
||||
|
||||
.target-row { display: flex; align-items: center; gap: 10px; margin-top: 8px; font-size: 13px; }
|
||||
|
||||
.eid { margin-top: 8px; }
|
||||
|
||||
.audit-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 11px;
|
||||
width: 100%;
|
||||
}
|
||||
.audit-footer :deep(svg) { color: var(--text-mute); }
|
||||
</style>
|
||||
@@ -0,0 +1,302 @@
|
||||
<script setup lang="ts">
|
||||
// Partner billing. Strict port of PartnerBillingScreen in partner-screens.jsx
|
||||
// (lines 691-838). Four tabs: Overview / Customer invoices / Margin & revenue
|
||||
// / Payouts. Each tab numbers seeded to match the source.
|
||||
|
||||
|
||||
|
||||
import { customers, partnerInvoices, partner } from '~/data/customers'
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
const tab = ref<'overview' | 'invoices' | 'margin' | 'payouts'>('overview')
|
||||
|
||||
const tabs = [
|
||||
{ value: 'overview', label: 'Overview' },
|
||||
{ value: 'invoices', label: 'Customer invoices', count: 47 },
|
||||
{ value: 'margin', label: 'Margin & revenue' },
|
||||
{ value: 'payouts', label: 'Payouts', count: 12 },
|
||||
]
|
||||
|
||||
function statusBadge(s: string): { tone: 'ok' | 'warn' | 'bad' | 'info' | 'neutral'; label: string } {
|
||||
switch (s) {
|
||||
case 'healthy': return { tone: 'ok', label: 'healthy' }
|
||||
case 'attention': return { tone: 'warn', label: 'attention' }
|
||||
case 'past_due': return { tone: 'bad', label: 'past-due' }
|
||||
case 'trial': return { tone: 'info', label: 'trial' }
|
||||
default: return { tone: 'neutral', label: s }
|
||||
}
|
||||
}
|
||||
|
||||
function invoiceTone(s: string): 'ok' | 'warn' | 'bad' | 'neutral' {
|
||||
if (s === 'paid') return 'ok'
|
||||
if (s === 'past_due') return 'bad'
|
||||
if (s === 'sent') return 'warn'
|
||||
return 'neutral'
|
||||
}
|
||||
|
||||
// 52-week revenue series for Margin & revenue tab (deterministic).
|
||||
const revenueSeries = Array.from({ length: 52 }, (_, i) => 8000 + i * 180 + Math.sin(i / 3) * 600)
|
||||
|
||||
const payouts = [
|
||||
{ period: 'May 2026', amt: '11.150,00', paid: '—', ref: 'pending', status: 'pending' as const },
|
||||
{ period: 'April 2026', amt: '10.520,00', paid: '03 May 2026', ref: 'TR-29841', status: 'paid' as const },
|
||||
{ period: 'March 2026', amt: '9.840,00', paid: '03 Apr 2026', ref: 'TR-29402', status: 'paid' as const },
|
||||
{ period: 'Feb 2026', amt: '9.180,00', paid: '03 Mar 2026', ref: 'TR-28977', status: 'paid' as const },
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<PageHeader
|
||||
eyebrow="Commercial"
|
||||
title="Partner billing"
|
||||
subtitle="Aggregate billing across your customer portfolio, margins, and payouts from Dezky."
|
||||
>
|
||||
<template #actions>
|
||||
<UiButton variant="secondary" @click="toast.ok('Exporting', 'PDF compiled')">
|
||||
<template #leading><UiIcon name="download" :size="14" /></template>
|
||||
Export
|
||||
</UiButton>
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<div class="tabs-wrap">
|
||||
<Tabs v-model="tab" :items="tabs" />
|
||||
</div>
|
||||
|
||||
<!-- OVERVIEW -->
|
||||
<div v-if="tab === 'overview'" class="content">
|
||||
<div class="stat-strip">
|
||||
<Card>
|
||||
<Stat label="MRR · portfolio" value="55.750 DKK" delta="+18.2%" delta-tone="up" hint="vs. last month" />
|
||||
</Card>
|
||||
<Card>
|
||||
<Stat :label="`Partner cut · ${partner.marginPct}%`" value="11.150 DKK" delta="+19.0%" delta-tone="up" />
|
||||
</Card>
|
||||
<Card>
|
||||
<Stat label="Net to Dezky" value="44.600 DKK" hint="monthly" />
|
||||
</Card>
|
||||
<Card>
|
||||
<Stat label="Open A/R" value="2.940 DKK" hint="1 customer past-due" delta-tone="down" />
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card :pad="0">
|
||||
<div class="card-head">
|
||||
<div>
|
||||
<Eyebrow>Per customer · this month</Eyebrow>
|
||||
<div class="card-title">Revenue breakdown</div>
|
||||
</div>
|
||||
</div>
|
||||
<table class="dtable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Customer</th>
|
||||
<th>Plan</th>
|
||||
<th>Seats</th>
|
||||
<th class="num">MRR</th>
|
||||
<th class="num">Partner cut ({{ partner.marginPct }}%)</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="c in customers" :key="c.id">
|
||||
<td>
|
||||
<div class="cust-cell">
|
||||
<div class="cust-swatch" :style="{ background: c.brandColor }" />
|
||||
<div>
|
||||
<div class="cust-name">{{ c.name }}</div>
|
||||
<Mono dim>{{ c.domain }}</Mono>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td><Badge tone="neutral">{{ c.planLabel }}</Badge></td>
|
||||
<td><Mono>{{ c.seats.used }}</Mono></td>
|
||||
<td class="num"><span class="mrr">{{ c.mrrDkk.toLocaleString('da-DK') }} DKK</span></td>
|
||||
<td class="num"><span class="cut">{{ Math.round(c.mrrDkk * partner.marginPct / 100).toLocaleString('da-DK') }} DKK</span></td>
|
||||
<td>
|
||||
<Badge :tone="statusBadge(c.status).tone" dot>{{ statusBadge(c.status).label }}</Badge>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- CUSTOMER INVOICES -->
|
||||
<div v-else-if="tab === 'invoices'" class="content">
|
||||
<Card :pad="0">
|
||||
<table class="dtable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Invoice</th>
|
||||
<th>Customer</th>
|
||||
<th>Date</th>
|
||||
<th class="num">Amount</th>
|
||||
<th>Status</th>
|
||||
<th class="action-col" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="inv in partnerInvoices" :key="inv.id">
|
||||
<td><Mono>{{ inv.number }}</Mono></td>
|
||||
<td><span class="cust-name">{{ inv.customer }}</span></td>
|
||||
<td><span class="text-13">{{ inv.date }}</span></td>
|
||||
<td class="num"><Mono>{{ inv.amount.toLocaleString('da-DK') }} DKK</Mono></td>
|
||||
<td>
|
||||
<Badge :tone="invoiceTone(inv.status)" dot>{{ inv.status.replace('_', '-') }}</Badge>
|
||||
</td>
|
||||
<td class="action-col">
|
||||
<UiButton size="sm" variant="ghost" @click="toast.info('Downloading PDF', inv.number)">
|
||||
<template #leading><UiIcon name="download" :size="13" /></template>
|
||||
PDF
|
||||
</UiButton>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- MARGIN & REVENUE -->
|
||||
<div v-else-if="tab === 'margin'" class="content">
|
||||
<div class="grid-2">
|
||||
<Card>
|
||||
<Eyebrow>Margin</Eyebrow>
|
||||
<div class="card-title">Your reseller margin</div>
|
||||
<p class="sub">Per your agreement with Dezky · 20% gross on all customer revenue.</p>
|
||||
<dl class="def">
|
||||
<div><dt>Starter plan</dt><dd>20% · 9,80 DKK per seat / mo</dd></div>
|
||||
<div><dt>Business plan</dt><dd>20% · 25,80 DKK per seat / mo</dd></div>
|
||||
<div><dt>Enterprise plan</dt><dd>15% · negotiated per customer</dd></div>
|
||||
<div><dt>Add-ons</dt><dd>Pass-through · 0%</dd></div>
|
||||
<div><dt>Volume rebate</dt><dd>+2% over 200 active seats · qualifies</dd></div>
|
||||
</dl>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<Eyebrow>Revenue · 12 months</Eyebrow>
|
||||
<div class="card-title">Trailing twelve</div>
|
||||
<div class="ttm-chart">
|
||||
<PartnerSparkline :values="revenueSeries" :width="420" :height="120" stroke="var(--text)" fill="var(--row-hover)" />
|
||||
</div>
|
||||
<div class="ttm-foot">
|
||||
<Mono dim>Jun 2025 · 8.180 DKK</Mono>
|
||||
<Mono dim>May 2026 · 11.150 DKK</Mono>
|
||||
</div>
|
||||
<div class="ttm-total">
|
||||
<Stat label="Total · 12 months" value="118.940 DKK" delta="+36% YoY" delta-tone="up" />
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- PAYOUTS -->
|
||||
<div v-else-if="tab === 'payouts'" class="content">
|
||||
<Card :pad="0">
|
||||
<table class="dtable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Period</th>
|
||||
<th class="num">Amount</th>
|
||||
<th>Paid on</th>
|
||||
<th>Reference</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="p in payouts" :key="p.period">
|
||||
<td><span class="cust-name">{{ p.period }}</span></td>
|
||||
<td class="num"><Mono>{{ p.amt }} DKK</Mono></td>
|
||||
<td><Mono>{{ p.paid }}</Mono></td>
|
||||
<td><Mono dim>{{ p.ref }}</Mono></td>
|
||||
<td>
|
||||
<Badge :tone="p.status === 'paid' ? 'ok' : 'warn'" dot>{{ p.status }}</Badge>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.tabs-wrap { padding: 0 40px; margin-top: 16px; }
|
||||
|
||||
.content { padding: 24px 40px 64px; display: flex; flex-direction: column; gap: 16px; }
|
||||
|
||||
.stat-strip { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; }
|
||||
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
||||
|
||||
.card-head {
|
||||
padding: 20px 24px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.card-title {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 600;
|
||||
font-size: 18px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.sub { font-size: 13px; color: var(--text-mute); margin: 6px 0 0; line-height: 1.5; }
|
||||
|
||||
.dtable { width: 100%; border-collapse: collapse; }
|
||||
.dtable th {
|
||||
text-align: left;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-mute);
|
||||
font-weight: 500;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.dtable th.num, .dtable td.num { text-align: right; }
|
||||
.dtable th.action-col, .dtable td.action-col { width: 80px; text-align: right; }
|
||||
.dtable td {
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 13px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.dtable tbody tr:hover { background: var(--row-hover); }
|
||||
|
||||
.cust-cell { display: flex; align-items: center; gap: 12px; }
|
||||
.cust-swatch { width: 24px; height: 24px; border-radius: 4px; flex-shrink: 0; }
|
||||
.cust-name { font-size: 13px; font-weight: 500; }
|
||||
|
||||
.text-13 { font-size: 13px; }
|
||||
|
||||
.mrr {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.cut {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
color: var(--ok);
|
||||
}
|
||||
|
||||
/* TTM chart */
|
||||
.def { display: flex; flex-direction: column; gap: 10px; margin: 14px 0 0; padding: 0; }
|
||||
.def div { display: grid; grid-template-columns: 160px 1fr; gap: 12px; font-size: 13px; }
|
||||
.def dt { color: var(--text-mute); font-family: var(--font-mono); font-size: 11px; }
|
||||
.def dd { margin: 0; }
|
||||
|
||||
.ttm-chart { margin-top: 14px; }
|
||||
.ttm-chart :deep(svg) { width: 100%; height: 120px; }
|
||||
.ttm-foot {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-top: 16px;
|
||||
}
|
||||
.ttm-total {
|
||||
margin-top: 20px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,250 @@
|
||||
<script setup lang="ts">
|
||||
// Partner branding. Strict port of PartnerBrandingScreen
|
||||
// (partner-screens.jsx lines 839-942). Three cards:
|
||||
// • Your brand · NordicMSP identity
|
||||
// • Customer defaults · what gets pushed to new customers (7 toggles)
|
||||
// • Email templates · 2-col grid of 5 templates
|
||||
|
||||
|
||||
|
||||
import type { EmailTemplate } from '~/components/partner/EmailTemplateEditor.vue'
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
const identityOpen = ref(false)
|
||||
const editing = ref<EmailTemplate | null>(null)
|
||||
|
||||
// Customer defaults · partner-screens.jsx line 872-878
|
||||
const defaults = ref([
|
||||
{ l: 'Accent color', d: 'Cobalt #3F6BFF', on: true },
|
||||
{ l: 'Product name pattern', d: '"{Customer} Workspace" e.g. Acme Workspace', on: true },
|
||||
{ l: 'Custom subdomain', d: 'workspace.{customer-domain}', on: true },
|
||||
{ l: 'Login screen', d: 'NordicMSP co-brand + customer logo', on: true },
|
||||
{ l: 'Email templates', d: '5 templates · NordicMSP voice', on: true },
|
||||
{ l: 'Allow customer override', d: 'Business plans and above', on: true },
|
||||
{ l: 'Lock typography', d: 'Inter Tight + JetBrains Mono · brand-locked', on: false },
|
||||
])
|
||||
|
||||
// Source mustache literals. Constructed in JS to avoid Vue parser eating
|
||||
// nested {{ }} (see CRITICAL note in task brief).
|
||||
const TAG_WORKSPACE = '{' + '{workspace.name}' + '}'
|
||||
const TAG_INVOICE = '{' + '{invoice.id}' + '}'
|
||||
const TAG_PLAN = '{' + '{plan.name}' + '}'
|
||||
|
||||
const templates = ref<EmailTemplate[]>([
|
||||
{ id: 'welcome', name: 'Customer welcome email', subject: `Welcome to ${TAG_WORKSPACE} — managed by NordicMSP`, body: '', edited: '5 days ago' },
|
||||
{ id: 'invitation', name: 'User invitation', subject: `You’ve been invited to ${TAG_WORKSPACE}`, body: '', edited: '3 days ago' },
|
||||
{ id: 'reset', name: 'Password reset', subject: `Reset your ${TAG_WORKSPACE} password`, body: '', edited: 'default' },
|
||||
{ id: 'plan', name: 'Plan change confirmation', subject: `Your plan changed to ${TAG_PLAN}`, body: '', edited: 'default' },
|
||||
{ id: 'invoice', name: 'Invoice notification', subject: `Your NordicMSP invoice ${TAG_INVOICE}`, body: '', edited: '2 weeks ago' },
|
||||
])
|
||||
|
||||
function saveTemplate(t: EmailTemplate) {
|
||||
templates.value = templates.value.map((x) => (x.id === t.id ? { ...t, edited: 'just now' } : x))
|
||||
editing.value = null
|
||||
toast.ok('Template saved', t.name)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<PageHeader
|
||||
eyebrow="Whitelabel"
|
||||
title="Partner branding"
|
||||
subtitle="Your own brand identity, plus the defaults pushed to every customer you provision."
|
||||
/>
|
||||
|
||||
<div class="content">
|
||||
<!-- Your brand · identity card -->
|
||||
<Card>
|
||||
<div class="card-head">
|
||||
<div>
|
||||
<Eyebrow>Your brand</Eyebrow>
|
||||
<div class="card-title">NordicMSP identity</div>
|
||||
<p class="sub">Shown in the partner console and on emails sent by your team.</p>
|
||||
</div>
|
||||
<UiButton size="sm" variant="ghost" @click="identityOpen = true">Edit</UiButton>
|
||||
</div>
|
||||
<div class="id-grid">
|
||||
<dl class="def">
|
||||
<div><dt>Display name</dt><dd>NordicMSP</dd></div>
|
||||
<div><dt>Logo</dt><dd>nordic-logo.svg · 4:1 horizontal</dd></div>
|
||||
<div><dt>Mark</dt><dd>nordic-mark.svg · 1:1</dd></div>
|
||||
<div>
|
||||
<dt>Primary color</dt>
|
||||
<dd>
|
||||
<div class="color-row">
|
||||
<div class="color-swatch" style="background:#3F6BFF" />
|
||||
<Mono>#3F6BFF</Mono>
|
||||
</div>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<dl class="def">
|
||||
<div><dt>Support email</dt><dd>support@nordicmsp.dk</dd></div>
|
||||
<div><dt>Support phone</dt><dd>+45 70 70 12 34</dd></div>
|
||||
<div><dt>Website</dt><dd>nordicmsp.dk</dd></div>
|
||||
<div><dt>Reply-to</dt><dd>no-reply@nordicmsp.dk</dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<!-- Customer defaults · toggle list -->
|
||||
<Card>
|
||||
<div class="card-head">
|
||||
<div>
|
||||
<Eyebrow>Customer defaults</Eyebrow>
|
||||
<div class="card-title">What gets pushed to new customers</div>
|
||||
<p class="sub">Applied at provisioning. Customers can override per their tier entitlements.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="defaults-list">
|
||||
<div
|
||||
v-for="(row, i) in defaults"
|
||||
:key="row.l"
|
||||
class="def-row"
|
||||
:class="{ last: i === defaults.length - 1 }"
|
||||
>
|
||||
<div class="dr-meta">
|
||||
<div class="dr-label">{{ row.l }}</div>
|
||||
<div class="dr-detail">{{ row.d }}</div>
|
||||
</div>
|
||||
<button class="switch" :class="{ on: row.on }" @click="row.on = !row.on">
|
||||
<span class="thumb" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<!-- Email templates · 2-col grid -->
|
||||
<Card>
|
||||
<Eyebrow>Templates</Eyebrow>
|
||||
<div class="card-title">Email templates · NordicMSP defaults</div>
|
||||
<div class="tpl-grid">
|
||||
<button
|
||||
v-for="t in templates"
|
||||
:key="t.id"
|
||||
class="tpl-row"
|
||||
@click="editing = t"
|
||||
>
|
||||
<UiIcon name="mail" :size="14" />
|
||||
<div class="tpl-meta">
|
||||
<div class="tpl-top">
|
||||
<span class="tpl-name">{{ t.name }}</span>
|
||||
<Badge :tone="t.edited === 'default' ? 'neutral' : 'info'">{{ t.edited === 'default' ? 'default' : 'edited' }}</Badge>
|
||||
</div>
|
||||
<Mono dim>edited {{ t.edited }}</Mono>
|
||||
</div>
|
||||
<UiIcon name="chevRight" :size="14" />
|
||||
</button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<PartnerEditIdentityModal :open="identityOpen" @close="identityOpen = false" />
|
||||
|
||||
<PartnerEmailTemplateEditor
|
||||
:template="editing"
|
||||
brand-color="#3F6BFF"
|
||||
brand-name="NordicMSP"
|
||||
@close="editing = null"
|
||||
@save="saveTemplate"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.content { padding: 24px 40px 64px; display: flex; flex-direction: column; gap: 16px; max-width: 1100px; }
|
||||
|
||||
.card-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.card-title {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 600;
|
||||
font-size: 17px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.sub { font-size: 13px; color: var(--text-mute); margin: 6px 0 0; max-width: 580px; line-height: 1.5; }
|
||||
|
||||
/* Identity DefList grid */
|
||||
.id-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
.def { display: flex; flex-direction: column; gap: 10px; margin: 0; padding: 0; }
|
||||
.def div { display: grid; grid-template-columns: 140px 1fr; gap: 12px; font-size: 13px; align-items: center; }
|
||||
.def dt { color: var(--text-mute); font-family: var(--font-mono); font-size: 11px; }
|
||||
.def dd { margin: 0; }
|
||||
.color-row { display: flex; align-items: center; gap: 8px; }
|
||||
.color-swatch { width: 14px; height: 14px; border-radius: 3px; }
|
||||
|
||||
/* Defaults toggle list */
|
||||
.defaults-list { display: flex; flex-direction: column; }
|
||||
.def-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.def-row.last { border-bottom: none; }
|
||||
.dr-meta { flex: 1; min-width: 0; }
|
||||
.dr-label { font-size: 13px; font-weight: 500; }
|
||||
.dr-detail { font-size: 12px; color: var(--text-mute); margin-top: 2px; }
|
||||
|
||||
.switch {
|
||||
width: 36px;
|
||||
height: 20px;
|
||||
border-radius: 999px;
|
||||
background: var(--border);
|
||||
border: none;
|
||||
padding: 2px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
transition: background 150ms;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.switch.on { background: var(--text); }
|
||||
.thumb {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 999px;
|
||||
background: var(--bg);
|
||||
transition: transform 150ms;
|
||||
}
|
||||
.switch.on .thumb { transform: translateX(16px); }
|
||||
|
||||
/* Templates grid */
|
||||
.tpl-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.tpl-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 6px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
.tpl-row:hover { background: var(--row-hover); }
|
||||
.tpl-row :deep(svg) { color: var(--text-mute); flex-shrink: 0; }
|
||||
.tpl-meta { flex: 1; min-width: 0; }
|
||||
.tpl-top { display: flex; align-items: center; gap: 8px; }
|
||||
.tpl-name { font-weight: 500; }
|
||||
</style>
|
||||
@@ -0,0 +1,530 @@
|
||||
<script setup lang="ts">
|
||||
// Full portfolio list. Strict port of CustomersScreen in partner-screens.jsx
|
||||
// (lines 366-494). Table + cards view toggle, status/plan filters, search.
|
||||
// Click a row → confirm enter customer modal → partnerMode.enter() + /admin.
|
||||
|
||||
|
||||
|
||||
import type { CustomerOrg, CustomerStatus } from '~/data/customers'
|
||||
|
||||
const toast = useToast()
|
||||
const router = useRouter()
|
||||
const partnerMode = usePartnerMode()
|
||||
|
||||
const view = ref<'table' | 'cards'>('table')
|
||||
const query = ref('')
|
||||
const statusFilter = ref<'all' | CustomerStatus>('all')
|
||||
const planFilter = ref<'all' | 'starter' | 'business' | 'enterprise'>('all')
|
||||
|
||||
const wizardOpen = ref(false)
|
||||
const entryCustomer = ref<CustomerOrg | null>(null)
|
||||
|
||||
// Real tenants attached to this partner (via /api/partner/tenants → platform-api
|
||||
// /users/me/partner/tenants). The backend doesn't yet store health-score,
|
||||
// MRR, or industry, so those render as placeholders. Plan + seats now come
|
||||
// from real fields.
|
||||
interface PartnerTenantDoc {
|
||||
_id: string
|
||||
slug: string
|
||||
name: string
|
||||
status: 'active' | 'pending' | 'suspended' | 'deleted'
|
||||
plan?: 'mvp' | 'pro' | 'enterprise'
|
||||
seats?: number
|
||||
// Active User docs whose tenantIds include this tenant. Comes from
|
||||
// /api/partner/tenants (server-side aggregation), so the column can show
|
||||
// "used / total" without a second client round-trip.
|
||||
userCount?: number
|
||||
domains?: string[]
|
||||
createdAt?: string
|
||||
}
|
||||
|
||||
const { data: rawTenants, refresh: refreshTenants } = await useFetch<PartnerTenantDoc[]>(
|
||||
'/api/partner/tenants',
|
||||
{ key: 'partner-tenants', default: () => [] },
|
||||
)
|
||||
|
||||
// Per-tenant MRR comes from the same aggregation that powers the dashboard
|
||||
// MRR card. We reuse the cached response (same key) instead of issuing a
|
||||
// second fetch; the customers page just reads the breakdown to fill the MRR
|
||||
// column per row.
|
||||
interface MrrBreakdownRow {
|
||||
tenantId: string
|
||||
currency: 'DKK' | 'EUR' | 'USD'
|
||||
monthlyMinor: number
|
||||
custom: boolean
|
||||
}
|
||||
interface MrrResponse {
|
||||
totals: Array<{ currency: 'DKK' | 'EUR' | 'USD'; monthlyMinor: number }>
|
||||
breakdown: MrrBreakdownRow[]
|
||||
}
|
||||
const { data: mrr, refresh: refreshMrr } = await useFetch<MrrResponse>('/api/partner/mrr', {
|
||||
key: 'partner-mrr',
|
||||
default: () => ({ totals: [], breakdown: [] }),
|
||||
})
|
||||
const mrrByTenant = computed(() => {
|
||||
const m = new Map<string, MrrBreakdownRow>()
|
||||
for (const row of mrr.value?.breakdown ?? []) m.set(row.tenantId, row)
|
||||
return m
|
||||
})
|
||||
|
||||
function mapTenantStatus(s: PartnerTenantDoc['status']): CustomerStatus {
|
||||
// Tenant.status values overlap partially with the fixture's CustomerStatus.
|
||||
// Best-effort map: deleted/suspended → suspended badge, pending → trial,
|
||||
// active stays active (rendered as 'healthy').
|
||||
if (s === 'active') return 'healthy'
|
||||
if (s === 'pending') return 'trial'
|
||||
return 'suspended'
|
||||
}
|
||||
|
||||
// Backend plan enum (mvp/pro/enterprise) → fixture-friendly slug + label.
|
||||
// Same mapping mirrored in CustomerCreateWizard.vue; keep both in sync if
|
||||
// the backend enum ever changes.
|
||||
const PLAN_INFO: Record<
|
||||
'mvp' | 'pro' | 'enterprise',
|
||||
{ slug: CustomerOrg['plan']; label: CustomerOrg['planLabel'] }
|
||||
> = {
|
||||
mvp: { slug: 'starter', label: 'Starter' },
|
||||
pro: { slug: 'business', label: 'Business' },
|
||||
enterprise: { slug: 'enterprise', label: 'Enterprise' },
|
||||
}
|
||||
|
||||
// Type extension on the row so the table can show the currency next to
|
||||
// the amount (the original fixture only had a DKK number, but real data
|
||||
// can be in DKK/EUR/USD).
|
||||
interface CustomerRow extends CustomerOrg {
|
||||
mrrCurrency: 'DKK' | 'EUR' | 'USD'
|
||||
mrrCustom: boolean
|
||||
}
|
||||
|
||||
const customers = computed<CustomerRow[]>(() =>
|
||||
(rawTenants.value ?? []).map((t) => {
|
||||
const info = PLAN_INFO[t.plan ?? 'pro']
|
||||
const subMrr = mrrByTenant.value.get(t._id)
|
||||
return {
|
||||
id: t._id,
|
||||
name: t.name,
|
||||
domain: t.domains?.[0] ?? `${t.slug}.dezky.com`,
|
||||
plan: info.slug,
|
||||
planLabel: info.label,
|
||||
// Real seat utilisation: count of active User docs attached to this
|
||||
// tenant vs the contractual seat total from provisioning.
|
||||
seats: { used: t.userCount ?? 0, total: t.seats ?? 0 },
|
||||
health: 100,
|
||||
status: mapTenantStatus(t.status),
|
||||
// MRR for this tenant, in major units. From /api/partner/mrr; 0 when
|
||||
// the sub has no priced amount (Enterprise / pre-catalog tenants).
|
||||
mrrDkk: subMrr ? Math.round(subMrr.monthlyMinor / 100) : 0,
|
||||
mrrCurrency: subMrr?.currency ?? 'DKK',
|
||||
mrrCustom: subMrr?.custom ?? false,
|
||||
brandColor: '#D4FF3A',
|
||||
industry: '—',
|
||||
createdOn: t.createdAt ?? '',
|
||||
since: t.createdAt ?? '',
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
const filtered = computed(() => {
|
||||
return customers.value.filter((c) => {
|
||||
if (statusFilter.value !== 'all' && c.status !== statusFilter.value) return false
|
||||
if (planFilter.value !== 'all' && c.plan !== planFilter.value) return false
|
||||
if (query.value) {
|
||||
const q = query.value.toLowerCase()
|
||||
if (!(c.name + ' ' + c.domain).toLowerCase().includes(q)) return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
})
|
||||
|
||||
const statusOpts = [
|
||||
{ value: 'all', label: 'All' },
|
||||
{ value: 'healthy', label: 'Healthy' },
|
||||
{ value: 'attention', label: 'Attention' },
|
||||
{ value: 'past_due', label: 'Past due' },
|
||||
{ value: 'trial', label: 'Trial' },
|
||||
] as const
|
||||
|
||||
const planOpts = [
|
||||
{ value: 'all', label: 'All' },
|
||||
{ value: 'starter', label: 'Starter' },
|
||||
{ value: 'business', label: 'Business' },
|
||||
{ value: 'enterprise', label: 'Enterprise' },
|
||||
] as const
|
||||
|
||||
function statusBadge(s: CustomerStatus): { tone: 'ok' | 'warn' | 'bad' | 'info' | 'neutral'; label: string } {
|
||||
switch (s) {
|
||||
case 'healthy': return { tone: 'ok', label: 'healthy' }
|
||||
case 'attention': return { tone: 'warn', label: 'attention' }
|
||||
case 'past_due': return { tone: 'bad', label: 'past-due' }
|
||||
case 'trial': return { tone: 'info', label: 'trial' }
|
||||
case 'suspended': return { tone: 'neutral', label: 'suspended' }
|
||||
}
|
||||
}
|
||||
|
||||
function startEnter(c: CustomerOrg) {
|
||||
entryCustomer.value = c
|
||||
}
|
||||
|
||||
async function onProvisioned() {
|
||||
toast.ok('Customer provisioned', 'Tenant created — provisioning runs in the background')
|
||||
// Refetch so the new tenant + its subscription's MRR show up without a
|
||||
// full reload. refreshNuxtData also nudges the dashboard's cached
|
||||
// MRR card the next time the user navigates back to /partner.
|
||||
await Promise.all([
|
||||
refreshTenants(),
|
||||
refreshMrr(),
|
||||
refreshNuxtData('partner-tenants'),
|
||||
refreshNuxtData('partner-mrr'),
|
||||
])
|
||||
}
|
||||
|
||||
function confirmEnter(reason: string) {
|
||||
if (!entryCustomer.value) return
|
||||
const c = entryCustomer.value
|
||||
partnerMode.enter(c.id)
|
||||
entryCustomer.value = null
|
||||
toast.info(`Entered ${c.name}`, reason ? `Reason: ${reason}` : 'No reason captured')
|
||||
router.push('/admin')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<PageHeader
|
||||
eyebrow="Portfolio"
|
||||
title="Customer organizations"
|
||||
subtitle="Every customer org under your reseller agreement. Click to manage as partner."
|
||||
>
|
||||
<template #actions>
|
||||
<UiButton variant="secondary" @click="toast.ok('Exporting CSV', `${customers.length} customers`)">
|
||||
<template #leading><UiIcon name="download" :size="14" /></template>
|
||||
Export CSV
|
||||
</UiButton>
|
||||
<UiButton variant="primary" @click="wizardOpen = true">
|
||||
<template #leading><UiIcon name="plus" :size="14" /></template>
|
||||
New customer
|
||||
</UiButton>
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<div class="content">
|
||||
<!-- Filter bar -->
|
||||
<div class="filters">
|
||||
<div class="search">
|
||||
<UiIcon name="search" :size="14" />
|
||||
<input v-model="query" placeholder="Search customer or domain…" />
|
||||
</div>
|
||||
|
||||
<div class="seg">
|
||||
<span class="seg-label">Status</span>
|
||||
<select v-model="statusFilter">
|
||||
<option v-for="o in statusOpts" :key="o.value" :value="o.value">{{ o.label }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="seg">
|
||||
<span class="seg-label">Plan</span>
|
||||
<select v-model="planFilter">
|
||||
<option v-for="o in planOpts" :key="o.value" :value="o.value">{{ o.label }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="spacer" />
|
||||
|
||||
<Mono dim>{{ filtered.length }} of {{ customers.length }}</Mono>
|
||||
|
||||
<div class="view-toggle">
|
||||
<button
|
||||
v-for="v in (['table','cards'] as const)"
|
||||
:key="v"
|
||||
:class="{ active: view === v }"
|
||||
@click="view = v"
|
||||
>{{ v }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Table view -->
|
||||
<Card v-if="view === 'table'" :pad="0">
|
||||
<div class="table-wrap">
|
||||
<table class="dtable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="sortable">Customer <UiIcon name="chevUpDown" :size="11" /></th>
|
||||
<th class="sortable">Plan <UiIcon name="chevUpDown" :size="11" /></th>
|
||||
<th class="sortable">Seats <UiIcon name="chevUpDown" :size="11" /></th>
|
||||
<th class="sortable num">MRR <UiIcon name="chevUpDown" :size="11" /></th>
|
||||
<th class="sortable">Status <UiIcon name="chevUpDown" :size="11" /></th>
|
||||
<th>Customer since</th>
|
||||
<th class="action-col" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="c in filtered" :key="c.id" @click="startEnter(c)">
|
||||
<td>
|
||||
<div class="cust-cell">
|
||||
<div class="cust-swatch" :style="{ background: c.brandColor }" />
|
||||
<div>
|
||||
<div class="cust-name">{{ c.name }}</div>
|
||||
<Mono dim>{{ c.domain }}</Mono>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<Badge :tone="c.plan === 'enterprise' ? 'invert' : 'neutral'">{{ c.planLabel }}</Badge>
|
||||
</td>
|
||||
<td>
|
||||
<Mono>{{ c.seats.used }}/{{ c.seats.total }}</Mono>
|
||||
</td>
|
||||
<td class="num">
|
||||
<span class="mrr">{{ c.mrrDkk > 0 ? c.mrrDkk.toLocaleString('da-DK') + ' ' + c.mrrCurrency : (c.mrrCustom ? 'custom' : '—') }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<Badge :tone="statusBadge(c.status).tone" dot>{{ statusBadge(c.status).label }}</Badge>
|
||||
</td>
|
||||
<td><Mono dim>{{ c.since }}</Mono></td>
|
||||
<td class="action-col" @click.stop>
|
||||
<UiButton size="sm" variant="secondary" @click="startEnter(c)">
|
||||
Manage
|
||||
<template #trailing><UiIcon name="chevRight" :size="12" /></template>
|
||||
</UiButton>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="filtered.length === 0">
|
||||
<td colspan="7" class="empty">No customers match these filters.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<!-- Cards view -->
|
||||
<div v-else class="cards-grid">
|
||||
<button
|
||||
v-for="c in filtered"
|
||||
:key="c.id"
|
||||
class="ccard"
|
||||
@click="startEnter(c)"
|
||||
>
|
||||
<div class="ccard-stripe" :style="{ background: c.brandColor }" />
|
||||
<div class="ccard-body">
|
||||
<div class="ccard-head">
|
||||
<div class="ccard-id">
|
||||
<div class="ccard-name">{{ c.name }}</div>
|
||||
<Mono dim>{{ c.domain }}</Mono>
|
||||
</div>
|
||||
<Badge :tone="statusBadge(c.status).tone" dot>{{ statusBadge(c.status).label }}</Badge>
|
||||
</div>
|
||||
<div class="ccard-meta">
|
||||
<div>
|
||||
<Eyebrow>Plan</Eyebrow>
|
||||
<div class="cm-val">{{ c.planLabel }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<Eyebrow>Seats</Eyebrow>
|
||||
<div class="cm-val mono">{{ c.seats.used }} / {{ c.seats.total }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<Eyebrow>MRR</Eyebrow>
|
||||
<div class="cm-val mono">{{ c.mrrDkk > 0 ? c.mrrDkk.toLocaleString('da-DK') + ' ' + c.mrrCurrency : (c.mrrCustom ? 'custom' : '—') }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<Eyebrow>Since</Eyebrow>
|
||||
<div class="cm-val">{{ c.since }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PartnerCustomerCreateWizard
|
||||
:open="wizardOpen"
|
||||
@close="wizardOpen = false"
|
||||
@done="onProvisioned"
|
||||
/>
|
||||
<PartnerEnterCustomerConfirmModal
|
||||
:customer="entryCustomer"
|
||||
@close="entryCustomer = null"
|
||||
@confirm="confirmEnter"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.content { padding: 16px 40px 64px; display: flex; flex-direction: column; gap: 16px; }
|
||||
|
||||
.filters {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 0 10px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
width: 320px;
|
||||
color: var(--text-mute);
|
||||
}
|
||||
.search input {
|
||||
flex: 1;
|
||||
border: none;
|
||||
background: transparent;
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
padding: 9px 0;
|
||||
color: var(--text);
|
||||
}
|
||||
.search input:focus { outline: none; }
|
||||
|
||||
.seg {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 0 10px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
}
|
||||
.seg-label {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-mute);
|
||||
}
|
||||
.seg select {
|
||||
border: none;
|
||||
background: transparent;
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
color: var(--text);
|
||||
padding: 8px 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.seg select:focus { outline: none; }
|
||||
|
||||
.spacer { flex: 1; }
|
||||
|
||||
.view-toggle {
|
||||
display: flex;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 2px;
|
||||
background: var(--surface);
|
||||
}
|
||||
.view-toggle button {
|
||||
padding: 6px 12px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--text-mute);
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
.view-toggle button.active { background: var(--text); color: var(--bg); }
|
||||
|
||||
/* Table */
|
||||
.table-wrap { overflow-x: auto; }
|
||||
.dtable { width: 100%; border-collapse: collapse; }
|
||||
.dtable th {
|
||||
text-align: left;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-mute);
|
||||
font-weight: 500;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.dtable th.sortable :deep(svg) { opacity: 0.5; margin-left: 4px; vertical-align: middle; }
|
||||
.dtable th.num, .dtable td.num { text-align: right; }
|
||||
.dtable th.action-col, .dtable td.action-col { width: 120px; text-align: right; }
|
||||
|
||||
.dtable td {
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 13px;
|
||||
color: var(--text);
|
||||
vertical-align: middle;
|
||||
}
|
||||
.dtable tbody tr { cursor: pointer; transition: background 80ms; }
|
||||
.dtable tbody tr:hover { background: var(--row-hover); }
|
||||
|
||||
.cust-cell { display: flex; align-items: center; gap: 12px; }
|
||||
.cust-swatch { width: 28px; height: 28px; border-radius: 6px; flex-shrink: 0; }
|
||||
.cust-name { font-size: 13px; font-weight: 500; }
|
||||
|
||||
.mrr {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.empty {
|
||||
text-align: center;
|
||||
color: var(--text-mute);
|
||||
font-size: 13px;
|
||||
padding: 48px 0;
|
||||
}
|
||||
|
||||
/* Cards */
|
||||
.cards-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.ccard {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
padding: 0;
|
||||
transition: border-color 120ms;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.ccard:hover { border-color: var(--text); }
|
||||
.ccard-stripe { height: 6px; }
|
||||
.ccard-body { padding: 16px; }
|
||||
.ccard-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
.ccard-id { min-width: 0; }
|
||||
.ccard-name {
|
||||
font-family: var(--font-display);
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.015em;
|
||||
}
|
||||
.ccard-meta {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
padding-top: 14px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.cm-val {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.cm-val.mono { font-family: var(--font-mono); }
|
||||
</style>
|
||||
@@ -0,0 +1,573 @@
|
||||
<script setup lang="ts">
|
||||
// Partner dashboard. Landing page for partner-admin role. Mirrors
|
||||
// partner-screens.jsx PartnerDashboard (lines 196-365) line-for-line:
|
||||
// MRR-card-plus-3-stat strip, customer-health 4-col grid, attention list,
|
||||
// recent activity table.
|
||||
|
||||
|
||||
|
||||
import { customers, partnerMrrSparkline, partner as fixturePartner } from '~/data/customers'
|
||||
import type { CustomerOrg } from '~/data/customers'
|
||||
|
||||
const toast = useToast()
|
||||
const router = useRouter()
|
||||
const partnerMode = usePartnerMode()
|
||||
|
||||
// Real partner identity from /api/me; falls back to the fixture if the user
|
||||
// somehow lands here without partner data (middleware should've redirected
|
||||
// them, but defending the read keeps the page from crashing).
|
||||
const { partner: realPartner } = useMe()
|
||||
const partner = computed(() => realPartner.value ?? fixturePartner)
|
||||
|
||||
const wizardOpen = ref(false)
|
||||
const entryCustomer = ref<CustomerOrg | null>(null)
|
||||
|
||||
// Real MRR from platform-api. Subscriptions are grouped by currency so a
|
||||
// partner with mixed-currency customers (e.g. some DKK, some EUR) sees a
|
||||
// per-currency total rather than an FX-fudged single number.
|
||||
type Currency = 'DKK' | 'EUR' | 'USD'
|
||||
interface MrrResponse {
|
||||
totals: Array<{ currency: Currency; monthlyMinor: number }>
|
||||
breakdown: Array<{
|
||||
tenantId: string
|
||||
tenantName: string
|
||||
currency: Currency
|
||||
monthlyMinor: number
|
||||
custom: boolean
|
||||
}>
|
||||
}
|
||||
const { data: mrr } = await useFetch<MrrResponse>('/api/partner/mrr', {
|
||||
key: 'partner-mrr',
|
||||
default: () => ({ totals: [], breakdown: [] }),
|
||||
})
|
||||
|
||||
const totalsDisplay = computed(() =>
|
||||
(mrr.value?.totals ?? []).map((t) => ({
|
||||
currency: t.currency,
|
||||
majorAmount: Math.round(t.monthlyMinor / 100),
|
||||
})),
|
||||
)
|
||||
const hasCustomPriced = computed(() => (mrr.value?.breakdown ?? []).some((b) => b.custom))
|
||||
|
||||
// Compact one-line summary used in the page subtitle.
|
||||
const totalsLine = computed(() => {
|
||||
const parts = totalsDisplay.value.map(
|
||||
(t) => `${t.majorAmount.toLocaleString('da-DK')} ${t.currency}`,
|
||||
)
|
||||
if (parts.length === 0) return '0 DKK / mo'
|
||||
return parts.join(' + ') + ' / mo'
|
||||
})
|
||||
|
||||
// Real customer count from the breakdown.
|
||||
const totalCustomers = computed(() => (mrr.value?.breakdown ?? []).length)
|
||||
|
||||
// Real end-user count = sum of active User docs across this partner's
|
||||
// tenants. Reuses the cached /api/partner/tenants response (same key as
|
||||
// the customers page) so this dashboard doesn't issue a second fetch.
|
||||
interface PartnerTenant {
|
||||
_id: string
|
||||
slug: string
|
||||
name: string
|
||||
status: 'active' | 'pending' | 'suspended' | 'deleted'
|
||||
plan?: 'mvp' | 'pro' | 'enterprise'
|
||||
seats?: number
|
||||
userCount?: number
|
||||
newUserCount30d?: number // active users created in the last 30 days
|
||||
createdAt?: string // tenant creation timestamp, for the customers delta
|
||||
provisioningStatus?: {
|
||||
authentik?: 'pending' | 'ok' | 'error' | 'skipped'
|
||||
stalwart?: 'pending' | 'ok' | 'error' | 'skipped'
|
||||
ocis?: 'pending' | 'ok' | 'error' | 'skipped'
|
||||
}
|
||||
}
|
||||
const { data: tenants } = await useFetch<PartnerTenant[]>('/api/partner/tenants', {
|
||||
key: 'partner-tenants',
|
||||
default: () => [],
|
||||
})
|
||||
const totalUsers = computed(() =>
|
||||
(tenants.value ?? []).reduce((s, t) => s + (t.userCount ?? 0), 0),
|
||||
)
|
||||
|
||||
// 30-day deltas. Customers delta is derived from tenant.createdAt (already
|
||||
// on the doc); end-user delta uses the aggregated newUserCount30d. Both
|
||||
// render as "+N / 30d" — or hide when 0 to keep the card clean on a quiet
|
||||
// month.
|
||||
const SINCE_30D = Date.now() - 30 * 24 * 60 * 60 * 1000
|
||||
const newCustomers30d = computed(
|
||||
() => (tenants.value ?? []).filter((t) => t.createdAt && new Date(t.createdAt).getTime() >= SINCE_30D).length,
|
||||
)
|
||||
const newUsers30d = computed(
|
||||
() => (tenants.value ?? []).reduce((s, t) => s + (t.newUserCount30d ?? 0), 0),
|
||||
)
|
||||
const customersDelta = computed(() => (newCustomers30d.value > 0 ? `+${newCustomers30d.value} / 30d` : ''))
|
||||
const usersDelta = computed(() => (newUsers30d.value > 0 ? `+${newUsers30d.value} / 30d` : ''))
|
||||
|
||||
// Sparkline is still fixture-driven — historical MRR isn't stored yet, so
|
||||
// the chart shape is decorative. Keep it for the design until we wire a
|
||||
// daily MRR snapshot job.
|
||||
const sparkline = partnerMrrSparkline
|
||||
const sparkLast = sparkline[sparkline.length - 1]
|
||||
const sparkTrendPct = '18.2' // matches source label
|
||||
|
||||
// Attention list · partner-screens.jsx line 207-212
|
||||
const alerts = [
|
||||
{ id: 'a-bygherre', tone: 'bad' as const, cust: 'Bygherre Cloud', msg: 'Invoice 21 days past due · 2.940 DKK', action: 'Review', custId: 'c-bygherre' },
|
||||
{ id: 'a-henriksen', tone: 'warn' as const, cust: 'Henriksen Revision', msg: 'SPF record missing on h-revision.dk', action: 'Fix DNS', custId: 'c-henriksen' },
|
||||
{ id: 'a-aalborg', tone: 'warn' as const, cust: 'Aalborg Logistik', msg: 'Approaching seat limit · 87/100 used', action: 'Upsell', custId: 'c-aalborg' },
|
||||
{ id: 'a-norrebro', tone: 'info' as const, cust: 'Nørrebro Studio', msg: 'Trial ends in 7 days', action: 'Follow up', custId: 'c-norrebro' },
|
||||
]
|
||||
|
||||
// Recent activity · partner-screens.jsx line 332-336
|
||||
const activity = [
|
||||
{ when: '14:02', cust: 'Acme Workspace', who: 'Anne Baslund', action: 'invited 3 users', tone: 'info' as const },
|
||||
{ when: '12:18', cust: 'Bygherre Cloud', who: 'system', action: 'invoice marked past-due', tone: 'bad' as const },
|
||||
{ when: '11:44', cust: 'Aalborg Logistik', who: 'Sofie Lindberg', action: 'upgraded to Enterprise', tone: 'ok' as const },
|
||||
{ when: '10:08', cust: 'Nørrebro Studio', who: 'NordicMSP', action: 'created new customer org', tone: 'info' as const },
|
||||
{ when: '09:34', cust: 'Henriksen Revision', who: 'system', action: 'DNS health alert · SPF', tone: 'warn' as const },
|
||||
]
|
||||
|
||||
function statusBadge(s: string): { tone: 'ok' | 'warn' | 'bad' | 'info' | 'neutral'; label: string } {
|
||||
switch (s) {
|
||||
case 'healthy': return { tone: 'ok', label: 'healthy' }
|
||||
case 'attention': return { tone: 'warn', label: 'attention' }
|
||||
case 'past_due': return { tone: 'bad', label: 'past-due' }
|
||||
case 'trial': return { tone: 'info', label: 'trial' }
|
||||
default: return { tone: 'neutral', label: s }
|
||||
}
|
||||
}
|
||||
|
||||
function startEnter(c: CustomerOrg) {
|
||||
entryCustomer.value = c
|
||||
}
|
||||
function confirmEnter(reason: string) {
|
||||
if (!entryCustomer.value) return
|
||||
const c = entryCustomer.value
|
||||
partnerMode.enter(c.id)
|
||||
entryCustomer.value = null
|
||||
toast.info(`Entered ${c.name}`, reason ? `Reason: ${reason}` : 'No reason captured')
|
||||
router.push('/admin')
|
||||
}
|
||||
|
||||
function onAlert(a: typeof alerts[number]) {
|
||||
toast.ok(`${a.action}: ${a.cust}`, 'Workflow stub fired')
|
||||
}
|
||||
|
||||
function activitySwatch(name: string) {
|
||||
return customers.find((c) => c.name === name)?.brandColor || 'var(--text-mute)'
|
||||
}
|
||||
|
||||
// ── Real health + activity (replace fixture cards) ───────────────────────
|
||||
|
||||
// Health badge derived from real tenant state. We have:
|
||||
// - Tenant.status: active / pending / suspended / deleted
|
||||
// - provisioningStatus per integration: ok / pending / error / skipped
|
||||
// Map: any provisioning error or suspended/deleted → bad. Pending or
|
||||
// awaiting provisioning → warn. Active + all integrations ok|skipped → ok.
|
||||
function tenantHealth(t: PartnerTenant): 'ok' | 'warn' | 'bad' {
|
||||
if (t.status === 'suspended' || t.status === 'deleted') return 'bad'
|
||||
const states = Object.values(t.provisioningStatus ?? {}) as Array<string | undefined>
|
||||
if (states.some((s) => s === 'error')) return 'bad'
|
||||
if (t.status === 'pending' || states.some((s) => s === 'pending')) return 'warn'
|
||||
return 'ok'
|
||||
}
|
||||
|
||||
const PLAN_LABEL: Record<'mvp' | 'pro' | 'enterprise', string> = {
|
||||
mvp: 'Starter',
|
||||
pro: 'Business',
|
||||
enterprise: 'Enterprise',
|
||||
}
|
||||
|
||||
const healthTiles = computed(() =>
|
||||
(tenants.value ?? []).map((t) => ({
|
||||
id: t._id,
|
||||
slug: t.slug,
|
||||
name: t.name,
|
||||
planLabel: PLAN_LABEL[t.plan ?? 'pro'],
|
||||
usedSeats: t.userCount ?? 0,
|
||||
totalSeats: t.seats ?? 0,
|
||||
tone: tenantHealth(t),
|
||||
})),
|
||||
)
|
||||
const healthyCount = computed(() => healthTiles.value.filter((t) => t.tone === 'ok').length)
|
||||
|
||||
// Real audit feed. Each event has resourceName + actor.email + at +
|
||||
// outcome ('success'|'failure'|'pending'). We render the verb in
|
||||
// dotted form (e.g. tenant.created) since the dashboard is a glance
|
||||
// view — clickthrough to /partner/audit can show the full row context.
|
||||
interface ActivityEvent {
|
||||
_id: string
|
||||
at: string
|
||||
action: string
|
||||
resourceName?: string
|
||||
tenantSlug?: string
|
||||
outcome?: 'success' | 'failure' | 'pending'
|
||||
actor?: { email?: string; userId?: string }
|
||||
}
|
||||
const { data: activityRaw } = await useFetch<ActivityEvent[]>('/api/partner/activity', {
|
||||
key: 'partner-activity',
|
||||
query: { limit: 8 },
|
||||
default: () => [],
|
||||
})
|
||||
|
||||
function eventTone(e: ActivityEvent): 'ok' | 'warn' | 'bad' | 'info' {
|
||||
if (e.outcome === 'failure') return 'bad'
|
||||
if (e.outcome === 'pending') return 'warn'
|
||||
// Heuristic: actions ending in .deleted / .suspended / .terminated read as bad
|
||||
if (/\.(deleted|suspended|terminated|removed)$/.test(e.action)) return 'bad'
|
||||
return 'info'
|
||||
}
|
||||
|
||||
function fmtTime(iso: string): string {
|
||||
const d = new Date(iso)
|
||||
return d.toLocaleTimeString('da-DK', { hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
function tenantNameFromSlug(slug?: string): string {
|
||||
if (!slug) return '—'
|
||||
return tenants.value?.find((t) => t.slug === slug)?.name ?? slug
|
||||
}
|
||||
|
||||
const realActivity = computed(() =>
|
||||
(activityRaw.value ?? []).map((e) => ({
|
||||
id: e._id,
|
||||
when: fmtTime(e.at),
|
||||
cust: tenantNameFromSlug(e.tenantSlug),
|
||||
who: e.actor?.email ?? 'system',
|
||||
action: e.action,
|
||||
tone: eventTone(e),
|
||||
})),
|
||||
)
|
||||
|
||||
function provisioned() {
|
||||
toast.ok('Customer provisioned', 'Welcome email is on its way to the first admin')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<PageHeader
|
||||
:eyebrow="`${partner.name} · Partner console`"
|
||||
title="Portfolio overview"
|
||||
:subtitle="`${totalCustomers} customer organizations · ${totalUsers} end users · ${totalsLine} MRR${hasCustomPriced ? ' (+ custom-priced)' : ''}`"
|
||||
>
|
||||
<template #actions>
|
||||
<UiButton variant="secondary" @click="toast.ok('Exporting', 'Portfolio PDF · sent to your inbox')">
|
||||
<template #leading><UiIcon name="download" :size="14" /></template>
|
||||
Export report
|
||||
</UiButton>
|
||||
<UiButton variant="primary" @click="wizardOpen = true">
|
||||
<template #leading><UiIcon name="plus" :size="14" /></template>
|
||||
New customer
|
||||
</UiButton>
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<div class="content">
|
||||
<!-- Top strip: MRR card (1.4fr) + 3 stat cards -->
|
||||
<div class="top-strip">
|
||||
<Card :pad="0" class="mrr-card">
|
||||
<div class="mrr-head">
|
||||
<Eyebrow>Current MRR</Eyebrow>
|
||||
<div class="mrr-totals">
|
||||
<template v-if="totalsDisplay.length === 0">
|
||||
<div class="mrr-value">0 <span class="dkk">DKK / mo</span></div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div v-for="t in totalsDisplay" :key="t.currency" class="mrr-line">
|
||||
<span class="mrr-amount">{{ t.majorAmount.toLocaleString('da-DK') }}</span>
|
||||
<span class="mrr-cur">{{ t.currency }} / mo</span>
|
||||
</div>
|
||||
</template>
|
||||
<Mono v-if="hasCustomPriced" dim>+ custom-priced</Mono>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mrr-chart">
|
||||
<!-- Sparkline values are placeholder until a daily MRR-snapshot
|
||||
job exists. Multi-currency makes a single sparkline less
|
||||
meaningful anyway; the chart is decorative for now. -->
|
||||
<PartnerSparkline :values="sparkline" :width="420" :height="64" stroke="var(--text)" fill="var(--row-hover)" />
|
||||
</div>
|
||||
</Card>
|
||||
<Card>
|
||||
<Stat label="Customers" :value="totalCustomers" :delta="customersDelta" delta-tone="up" />
|
||||
</Card>
|
||||
<Card>
|
||||
<Stat label="End users" :value="totalUsers" :delta="usersDelta" delta-tone="up" />
|
||||
</Card>
|
||||
<Card>
|
||||
<Stat label="Issues" :value="alerts.length" hint="1 critical · 2 warning" />
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- Health grid + Attention -->
|
||||
<div class="grid-2">
|
||||
<Card :pad="0">
|
||||
<div class="card-head">
|
||||
<div>
|
||||
<Eyebrow>Health</Eyebrow>
|
||||
<div class="card-title">Customer status</div>
|
||||
</div>
|
||||
<Mono dim>{{ healthyCount }} healthy of {{ totalCustomers }}</Mono>
|
||||
</div>
|
||||
<div v-if="healthTiles.length === 0" class="empty-state">
|
||||
<Mono dim>// no customers yet — provision your first from the New customer button</Mono>
|
||||
</div>
|
||||
<div v-else class="health-grid">
|
||||
<NuxtLink
|
||||
v-for="t in healthTiles"
|
||||
:key="t.id"
|
||||
:to="`/partner/customers`"
|
||||
class="health-tile"
|
||||
>
|
||||
<div class="tile-head">
|
||||
<span class="tile-name">{{ t.name }}</span>
|
||||
<StatusDot :color="`var(--${t.tone})`" :size="6" :glow="false" />
|
||||
</div>
|
||||
<div class="tile-meta">{{ t.planLabel }} · {{ t.usedSeats }}/{{ t.totalSeats }}</div>
|
||||
<Mono dim class="tile-slug">{{ t.slug }}</Mono>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card :pad="0">
|
||||
<div class="card-head">
|
||||
<div>
|
||||
<Eyebrow>Attention</Eyebrow>
|
||||
<div class="card-title">What needs your attention</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="attn-list">
|
||||
<div
|
||||
v-for="a in alerts"
|
||||
:key="a.id"
|
||||
class="attn-row"
|
||||
:style="{ borderLeftColor: `var(--${a.tone})` }"
|
||||
>
|
||||
<div class="attn-meta">
|
||||
<div class="attn-top">
|
||||
<span class="attn-cust">{{ a.cust }}</span>
|
||||
<Mono dim>{{ a.tone }}</Mono>
|
||||
</div>
|
||||
<div class="attn-msg">{{ a.msg }}</div>
|
||||
</div>
|
||||
<UiButton size="sm" variant="secondary" @click="onAlert(a)">{{ a.action }}</UiButton>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- Recent activity -->
|
||||
<Card :pad="0">
|
||||
<div class="card-head">
|
||||
<div>
|
||||
<Eyebrow>Activity</Eyebrow>
|
||||
<div class="card-title">Recent across portfolio</div>
|
||||
</div>
|
||||
<UiButton size="sm" variant="ghost" @click="router.push('/partner/audit')">
|
||||
View all
|
||||
<template #trailing><UiIcon name="chevRight" :size="12" /></template>
|
||||
</UiButton>
|
||||
</div>
|
||||
<div v-if="realActivity.length === 0" class="empty-state">
|
||||
<Mono dim>// no recent events yet</Mono>
|
||||
</div>
|
||||
<div v-else class="activity-list">
|
||||
<div
|
||||
v-for="(a, i) in realActivity"
|
||||
:key="a.id"
|
||||
class="activity-row"
|
||||
:class="{ last: i === realActivity.length - 1 }"
|
||||
>
|
||||
<Mono>{{ a.when }}</Mono>
|
||||
<div class="activity-cust">
|
||||
<span class="activity-cust-name">{{ a.cust }}</span>
|
||||
</div>
|
||||
<div class="activity-text">
|
||||
<span class="activity-who">{{ a.who }}</span> <Mono dim>{{ a.action }}</Mono>
|
||||
</div>
|
||||
<div class="activity-tone">
|
||||
<Badge :tone="a.tone" dot>{{ a.tone }}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<PartnerCustomerCreateWizard
|
||||
:open="wizardOpen"
|
||||
@close="wizardOpen = false"
|
||||
@done="provisioned"
|
||||
/>
|
||||
<PartnerEnterCustomerConfirmModal
|
||||
:customer="entryCustomer"
|
||||
@close="entryCustomer = null"
|
||||
@confirm="confirmEnter"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.content { padding: 24px 40px 64px; display: flex; flex-direction: column; gap: 16px; }
|
||||
|
||||
/* Top strip: MRR card (1.4fr) + 3 stat cards */
|
||||
.top-strip {
|
||||
display: grid;
|
||||
grid-template-columns: 1.4fr 1fr 1fr 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.mrr-card { overflow: hidden; display: flex; flex-direction: column; }
|
||||
.mrr-head { padding: 20px 24px 12px; }
|
||||
.mrr-value-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.mrr-totals {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.mrr-line {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
}
|
||||
.mrr-amount {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 600;
|
||||
font-size: 28px;
|
||||
letter-spacing: -0.025em;
|
||||
line-height: 1.05;
|
||||
}
|
||||
.mrr-cur { font-size: 14px; color: var(--text-mute); font-weight: 500; }
|
||||
.mrr-value {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 600;
|
||||
font-size: 32px;
|
||||
letter-spacing: -0.025em;
|
||||
line-height: 1;
|
||||
}
|
||||
.dkk { font-size: 18px; color: var(--text-mute); font-weight: 500; }
|
||||
.trend {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
color: var(--ok);
|
||||
font-weight: 500;
|
||||
}
|
||||
.mrr-chart { padding: 0 12px 12px; }
|
||||
.mrr-chart :deep(svg) { width: 100%; height: 64px; }
|
||||
|
||||
/* 2-up grid */
|
||||
.grid-2 {
|
||||
display: grid;
|
||||
grid-template-columns: 1.4fr 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.card-head {
|
||||
padding: 20px 24px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.card-title {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 600;
|
||||
font-size: 18px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* Health grid: 4 columns of tiles */
|
||||
.health-grid {
|
||||
padding: 12px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.health-tile {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
transition: border-color 120ms;
|
||||
}
|
||||
.health-tile:hover { border-color: var(--text); }
|
||||
.health-tile { text-decoration: none; display: block; }
|
||||
.tile-slug { display: block; margin-top: 6px; font-size: 10px; }
|
||||
|
||||
.empty-state {
|
||||
padding: 32px 24px;
|
||||
text-align: center;
|
||||
color: var(--text-mute);
|
||||
}
|
||||
.tile-head { display: flex; align-items: center; gap: 8px; }
|
||||
.tile-swatch { width: 16px; height: 16px; border-radius: 3px; flex-shrink: 0; }
|
||||
.tile-name {
|
||||
flex: 1;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.tile-meta {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
color: var(--text-mute);
|
||||
margin-top: 8px;
|
||||
}
|
||||
.tile-mrr {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
/* Attention list */
|
||||
.attn-list { padding: 8px 8px 12px; display: flex; flex-direction: column; gap: 4px; }
|
||||
.attn-row {
|
||||
padding: 10px 14px;
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
border-left: 2px solid var(--border);
|
||||
}
|
||||
.attn-meta { flex: 1; min-width: 0; }
|
||||
.attn-top { display: flex; align-items: baseline; gap: 6px; flex-wrap: wrap; }
|
||||
.attn-cust { font-size: 12px; font-weight: 600; }
|
||||
.attn-msg { font-size: 12px; color: var(--text-dim); margin-top: 2px; }
|
||||
|
||||
/* Recent activity */
|
||||
.activity-list { display: flex; flex-direction: column; }
|
||||
.activity-row {
|
||||
display: grid;
|
||||
grid-template-columns: 60px 200px 1fr 100px;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 24px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.activity-row.last { border-bottom: none; }
|
||||
.activity-cust { display: flex; align-items: center; gap: 8px; }
|
||||
.activity-swatch { width: 12px; height: 12px; border-radius: 2px; flex-shrink: 0; }
|
||||
.activity-cust-name { font-size: 13px; font-weight: 500; }
|
||||
.activity-text { font-size: 13px; color: var(--text-dim); }
|
||||
.activity-who { color: var(--text); }
|
||||
.activity-tone { text-align: right; }
|
||||
|
||||
@media (max-width: 1280px) {
|
||||
.top-strip { grid-template-columns: 1fr 1fr; }
|
||||
.grid-2 { grid-template-columns: 1fr; }
|
||||
.health-grid { grid-template-columns: repeat(3, 1fr); }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,764 @@
|
||||
<script setup lang="ts">
|
||||
// Partner reports. Strict port of PartnerReportsScreen
|
||||
// (platform-partner-depth.jsx lines 22-318 + 559-852). Four tabs:
|
||||
// • Customer health · Stats + per-customer health table (Escalate / Check in)
|
||||
// • Revenue · Stats + MRR sparkline + By plan + Top customers
|
||||
// • Churn · Stats + cohort retention heatmap + exit reasons
|
||||
// • Custom reports · Saved reports table + create modal
|
||||
|
||||
|
||||
|
||||
import { customers, partnerMrrSparkline } from '~/data/customers'
|
||||
import type { CustomerOrg } from '~/data/customers'
|
||||
import type { TaskContext } from '~/components/partner/CustomerTaskPanel.vue'
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
const tab = ref<'health' | 'revenue' | 'churn' | 'custom'>('health')
|
||||
const period = ref<'30d' | '90d' | '12mo' | 'ytd'>('90d')
|
||||
|
||||
const tabs = [
|
||||
{ value: 'health', label: 'Customer health' },
|
||||
{ value: 'revenue', label: 'Revenue' },
|
||||
{ value: 'churn', label: 'Churn' },
|
||||
{ value: 'custom', label: 'Custom reports', count: 3 },
|
||||
]
|
||||
|
||||
const periodOpts = [
|
||||
{ value: '30d', label: '30 days' },
|
||||
{ value: '90d', label: '90 days' },
|
||||
{ value: '12mo', label: '12 months' },
|
||||
{ value: 'ytd', label: 'Year-to-date' },
|
||||
] as const
|
||||
|
||||
const exportOpen = ref(false)
|
||||
const newReportOpen = ref(false)
|
||||
|
||||
// HEALTH ─────────────────────────────────────────────────────────────────────
|
||||
// Health scoring exactly mirrors platform-partner-depth.jsx:73-80.
|
||||
const scored = computed(() => customers.map((c) => {
|
||||
let score = 100
|
||||
if (c.status === 'past_due') score -= 50
|
||||
else if (c.status === 'attention') score -= 30
|
||||
else if (c.status === 'trial') score -= 10
|
||||
if (c.seats.used / c.seats.total > 0.85) score -= 10
|
||||
return { ...c, score }
|
||||
}))
|
||||
|
||||
const cohort = computed(() => ({
|
||||
healthy: scored.value.filter((c) => c.score >= 75).length,
|
||||
watch: scored.value.filter((c) => c.score >= 50 && c.score < 75).length,
|
||||
risk: scored.value.filter((c) => c.score < 50).length,
|
||||
}))
|
||||
|
||||
function healthColor(h: number) {
|
||||
if (h >= 75) return 'var(--ok)'
|
||||
if (h >= 50) return 'var(--warn)'
|
||||
return 'var(--bad)'
|
||||
}
|
||||
|
||||
const taskCtx = ref<TaskContext | null>(null)
|
||||
function openTask(c: CustomerOrg & { score: number }, mode: 'escalate' | 'checkin') {
|
||||
taskCtx.value = { customer: c, score: c.score, mode }
|
||||
}
|
||||
|
||||
// Deterministic mini trend sparkline (30 points) for the per-customer row.
|
||||
function miniTrend(seed: number) {
|
||||
return Array.from({ length: 30 }, (_, i) => 60 + Math.sin((i + seed) / 4) * 12 + ((i * seed) % 5))
|
||||
}
|
||||
|
||||
// REVENUE ────────────────────────────────────────────────────────────────────
|
||||
const totalMrr = computed(() => customers.reduce((s, c) => s + c.mrrDkk, 0))
|
||||
|
||||
// Top 5 by MRR
|
||||
const topByMrr = computed(() => [...customers].sort((a, b) => b.mrrDkk - a.mrrDkk).slice(0, 5))
|
||||
|
||||
// By-plan revenue mix · platform-partner-depth.jsx:176-180
|
||||
const revenueMix = [
|
||||
{ n: 'Enterprise', v: 42900, p: 77, c: 'var(--text)' },
|
||||
{ n: 'Business', v: 11340, p: 20, c: 'var(--info)' },
|
||||
{ n: 'Starter', v: 1510, p: 3, c: 'var(--text-mute)' },
|
||||
]
|
||||
|
||||
// CHURN cohort heatmap · platform-partner-depth.jsx:237-243
|
||||
const cohorts: Array<[string, number, Array<number | '—'>]> = [
|
||||
['Nov 2024', 1, [100, 100, 100, 100, 100, 100]],
|
||||
['Aug 2025', 1, [100, 100, 100, 100, 100, '—']],
|
||||
['Sep 2025', 1, [100, 100, 100, 100, 100, '—']],
|
||||
['Feb 2026', 3, [100, 100, 100, '—', '—', '—']],
|
||||
['Mar 2026', 2, [100, 100, '—', '—', '—', '—']],
|
||||
['May 2026', 1, [100, '—', '—', '—', '—', '—']],
|
||||
]
|
||||
const cohortHeaders = ['M+0', 'M+1', 'M+2', 'M+3', 'M+6', 'M+12']
|
||||
|
||||
// CUSTOM REPORTS · platform-partner-depth.jsx:280-283
|
||||
const savedReports = ref([
|
||||
{ id: 'r1', name: 'Quarterly board · Q1 2026', owner: 'Anne Baslund', schedule: 'Quarterly · 1st', last: '03 Apr 2026', recipients: 4, format: 'PDF' },
|
||||
{ id: 'r2', name: 'Customer Health · weekly digest', owner: 'Anne Baslund', schedule: 'Mondays 09:00 CET', last: '13 May 2026', recipients: 2, format: 'PDF' },
|
||||
{ id: 'r3', name: 'Margin breakdown by partner cut', owner: 'Mikkel Nørgaard', schedule: 'On-demand', last: '08 May 2026', recipients: 1, format: 'CSV' },
|
||||
])
|
||||
|
||||
const running = ref<string | null>(null)
|
||||
const reportMenuFor = ref<string | null>(null)
|
||||
const reportMenuPos = ref<{ top: number; right: number }>({ top: 0, right: 0 })
|
||||
const confirmDeleteId = ref<string | null>(null)
|
||||
|
||||
function runReport(id: string) {
|
||||
running.value = id
|
||||
const r = savedReports.value.find((x) => x.id === id)
|
||||
setTimeout(() => { running.value = null }, 1800)
|
||||
if (r) toast.info(`Running ${r.name}`, 'You will be emailed when ready')
|
||||
}
|
||||
|
||||
function openReportMenu(rId: string, e: MouseEvent) {
|
||||
e.stopPropagation()
|
||||
const btn = (e.currentTarget as HTMLElement).getBoundingClientRect()
|
||||
reportMenuPos.value = { top: btn.bottom + 4, right: window.innerWidth - btn.right }
|
||||
reportMenuFor.value = reportMenuFor.value === rId ? null : rId
|
||||
}
|
||||
|
||||
function reportActions(r: typeof savedReports.value[number]) {
|
||||
return [
|
||||
{ i: 'external', l: 'Run again', fn: () => runReport(r.id) },
|
||||
{ i: 'download', l: `Download last (${r.format})`, fn: () => toast.ok('Downloading', `${r.name}.${r.format.toLowerCase()}`) },
|
||||
{ i: 'mail', l: 'Send to recipients now', fn: () => toast.info('Sending', `${r.recipients} recipients`) },
|
||||
{ i: 'copy', l: 'Copy shareable link', fn: () => toast.ok('Link copied') },
|
||||
{ sep: true },
|
||||
{ i: 'brush', l: 'Edit report…', fn: () => toast.info('Editing', r.name) },
|
||||
{ i: 'copy', l: 'Duplicate', fn: () => toast.ok('Duplicated', r.name) },
|
||||
{ i: 'calendar', l: r.schedule === 'On-demand' ? 'Add schedule…' : 'Pause schedule', fn: () => toast.info('Schedule', r.schedule) },
|
||||
{ sep: true },
|
||||
{ i: 'trash', l: 'Delete report', danger: true, fn: () => { confirmDeleteId.value = r.id } },
|
||||
] as Array<{ i?: string; l?: string; danger?: boolean; sep?: boolean; fn?: () => void }>
|
||||
}
|
||||
|
||||
const confirmDeleteReport = computed(() => savedReports.value.find((r) => r.id === confirmDeleteId.value))
|
||||
|
||||
function deleteReport() {
|
||||
const r = savedReports.value.find((x) => x.id === confirmDeleteId.value)
|
||||
if (r) {
|
||||
savedReports.value = savedReports.value.filter((x) => x.id !== confirmDeleteId.value)
|
||||
toast.bad('Report deleted', r.name)
|
||||
}
|
||||
confirmDeleteId.value = null
|
||||
}
|
||||
|
||||
function closeMenu() { reportMenuFor.value = null }
|
||||
|
||||
onMounted(() => {
|
||||
const onScroll = () => closeMenu()
|
||||
document.addEventListener('click', closeMenu)
|
||||
window.addEventListener('scroll', onScroll, true)
|
||||
window.addEventListener('resize', onScroll)
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('click', closeMenu)
|
||||
window.removeEventListener('scroll', onScroll, true)
|
||||
window.removeEventListener('resize', onScroll)
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<PageHeader
|
||||
eyebrow="Analytics"
|
||||
title="Partner reports"
|
||||
subtitle="Health, revenue, churn, and custom rollups across your customer portfolio."
|
||||
>
|
||||
<template #actions>
|
||||
<UiButton variant="secondary" @click="exportOpen = true">
|
||||
<template #leading><UiIcon name="download" :size="14" /></template>
|
||||
Export PDF
|
||||
</UiButton>
|
||||
<UiButton variant="primary" @click="newReportOpen = true">
|
||||
<template #leading><UiIcon name="plus" :size="14" /></template>
|
||||
New report
|
||||
</UiButton>
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<div class="tabs-bar">
|
||||
<Tabs v-model="tab" :items="tabs" class="tabs-stretch" />
|
||||
<div class="period-chip">
|
||||
<span class="seg-label">Period</span>
|
||||
<select v-model="period">
|
||||
<option v-for="o in periodOpts" :key="o.value" :value="o.value">{{ o.label }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- HEALTH -->
|
||||
<div v-if="tab === 'health'" class="content">
|
||||
<div class="stat-strip">
|
||||
<Card>
|
||||
<Stat label="Healthy" :value="cohort.healthy" :delta="`${Math.round(cohort.healthy / scored.length * 100)}% of portfolio`" />
|
||||
</Card>
|
||||
<Card>
|
||||
<Stat label="Watch" :value="cohort.watch" delta="2 customers · check in" delta-tone="up" />
|
||||
</Card>
|
||||
<Card>
|
||||
<Stat label="At risk" :value="cohort.risk" delta="1 customer · escalate" delta-tone="down" />
|
||||
</Card>
|
||||
<Card>
|
||||
<Stat label="NPS · est" value="58" delta="+6 from last period" delta-tone="up" hint="based on 12 responses" />
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card :pad="0">
|
||||
<div class="card-head">
|
||||
<div>
|
||||
<Eyebrow>Per customer</Eyebrow>
|
||||
<div class="card-title">Health scores</div>
|
||||
</div>
|
||||
</div>
|
||||
<table class="dtable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Customer</th>
|
||||
<th>Plan</th>
|
||||
<th>Health</th>
|
||||
<th>Seat usage</th>
|
||||
<th class="num">MRR</th>
|
||||
<th>Trend · 90d</th>
|
||||
<th class="action-col" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(c, i) in scored" :key="c.id">
|
||||
<td>
|
||||
<div class="cust-cell">
|
||||
<div class="cust-swatch" :style="{ background: c.brandColor }" />
|
||||
<div>
|
||||
<div class="cust-name">{{ c.name }}</div>
|
||||
<Mono dim>{{ c.domain }}</Mono>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<Badge :tone="c.plan === 'enterprise' ? 'invert' : 'neutral'">{{ c.planLabel }}</Badge>
|
||||
</td>
|
||||
<td>
|
||||
<div class="health-cell">
|
||||
<div class="hbar">
|
||||
<div class="hfill" :style="{ width: c.score + '%', background: healthColor(c.score) }" />
|
||||
</div>
|
||||
<Mono>{{ c.score }}</Mono>
|
||||
</div>
|
||||
</td>
|
||||
<td><Mono dim>{{ c.seats.used }}/{{ c.seats.total }} · {{ Math.round(c.seats.used/c.seats.total*100) }}%</Mono></td>
|
||||
<td class="num"><Mono>{{ c.mrrDkk > 0 ? c.mrrDkk.toLocaleString('da-DK') + ' DKK' : '—' }}</Mono></td>
|
||||
<td>
|
||||
<PartnerSparkline :values="miniTrend(i + 1)" :width="80" :height="22" stroke="var(--text)" fill="transparent" :show-dot="false" />
|
||||
</td>
|
||||
<td class="action-col">
|
||||
<UiButton v-if="c.score < 50" size="sm" variant="primary" @click="openTask(c, 'escalate')">Escalate</UiButton>
|
||||
<UiButton v-else-if="c.score < 75" size="sm" variant="secondary" @click="openTask(c, 'checkin')">Check in</UiButton>
|
||||
<Mono v-else dim>—</Mono>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- REVENUE -->
|
||||
<div v-if="tab === 'revenue'" class="content">
|
||||
<div class="stat-strip">
|
||||
<Card>
|
||||
<Stat label="MRR · current" value="55.750 DKK" delta="+18.2%" delta-tone="up" hint="vs. 90d ago" />
|
||||
</Card>
|
||||
<Card>
|
||||
<Stat label="Partner margin" value="11.150 DKK" delta="+19.0%" delta-tone="up" hint="20% of MRR" />
|
||||
</Card>
|
||||
<Card>
|
||||
<Stat label="ARR · projected" value="669.000 DKK" delta="+24% YoY" delta-tone="up" />
|
||||
</Card>
|
||||
<Card>
|
||||
<Stat label="ARPU" value="6.969 DKK" hint="per customer / mo" />
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card :pad="0">
|
||||
<div class="card-head">
|
||||
<div>
|
||||
<Eyebrow>MRR · last 90 days</Eyebrow>
|
||||
<div class="card-title">Trend</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="big-chart">
|
||||
<PartnerSparkline :values="partnerMrrSparkline" :width="1080" :height="160" stroke="var(--text)" fill="var(--row-hover)" />
|
||||
<div class="chart-foot">
|
||||
<span>Feb 14 · 38.180 DKK</span>
|
||||
<span>May 14 · 55.750 DKK</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div class="grid-2">
|
||||
<Card>
|
||||
<Eyebrow>By plan</Eyebrow>
|
||||
<div class="card-title">Revenue mix</div>
|
||||
<div class="plan-mix">
|
||||
<div v-for="p in revenueMix" :key="p.n" class="mix-row">
|
||||
<div class="mix-head">
|
||||
<span class="mix-name">{{ p.n }}</span>
|
||||
<Mono>{{ p.v.toLocaleString('da-DK') }} DKK · {{ p.p }}%</Mono>
|
||||
</div>
|
||||
<div class="mix-bar"><div class="mix-fill" :style="{ width: p.p + '%', background: p.c }" /></div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card>
|
||||
<Eyebrow>Top customers</Eyebrow>
|
||||
<div class="card-title">By MRR</div>
|
||||
<div class="top-list">
|
||||
<div v-for="c in topByMrr" :key="c.id" class="top-row">
|
||||
<div class="top-swatch" :style="{ background: c.brandColor }" />
|
||||
<span class="top-name">{{ c.name }}</span>
|
||||
<Mono>{{ c.mrrDkk.toLocaleString('da-DK') }} DKK</Mono>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CHURN -->
|
||||
<div v-if="tab === 'churn'" class="content">
|
||||
<div class="stat-strip">
|
||||
<Card>
|
||||
<Stat label="Gross churn · 90d" value="0%" delta="0 customers" />
|
||||
</Card>
|
||||
<Card>
|
||||
<Stat label="Net churn · MRR" value="−2.1%" delta="contracted from upgrades" delta-tone="up" />
|
||||
</Card>
|
||||
<Card>
|
||||
<Stat label="At-risk MRR" value="2.940 DKK" hint="1 customer · past-due" delta-tone="down" />
|
||||
</Card>
|
||||
<Card>
|
||||
<Stat label="Avg tenure" value="14 mo" delta="trending up" delta-tone="up" />
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card :pad="0">
|
||||
<div class="card-head">
|
||||
<div>
|
||||
<Eyebrow>Cohort retention</Eyebrow>
|
||||
<div class="card-title">Customers by signup month</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cohort-wrap">
|
||||
<table class="cohort">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Cohort</th>
|
||||
<th>Size</th>
|
||||
<th v-for="h in cohortHeaders" :key="h">{{ h }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(c, i) in cohorts" :key="i">
|
||||
<td><Mono>{{ c[0] }}</Mono></td>
|
||||
<td class="cohort-size"><Mono>{{ c[1] }}</Mono></td>
|
||||
<td v-for="(v, j) in c[2]" :key="j" class="cell">
|
||||
<Mono v-if="v === '—'" dim>—</Mono>
|
||||
<span
|
||||
v-else
|
||||
class="heat"
|
||||
:style="{
|
||||
background: (v as number) >= 100 ? 'rgba(31,138,91,0.16)' : (v as number) >= 80 ? 'rgba(232,154,31,0.16)' : 'rgba(226,48,48,0.16)',
|
||||
color: (v as number) >= 100 ? 'var(--ok)' : (v as number) >= 80 ? 'var(--warn)' : 'var(--bad)',
|
||||
}"
|
||||
>{{ v }}%</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<Eyebrow>Why customers leave</Eyebrow>
|
||||
<div class="card-title">Top exit reasons (last 12 months)</div>
|
||||
<p class="exit-empty">
|
||||
No churn yet. When customers do leave, exit reasons will surface here automatically (from cancel/pause flow inputs).
|
||||
</p>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- CUSTOM REPORTS -->
|
||||
<div v-if="tab === 'custom'" class="content">
|
||||
<div class="custom-head">
|
||||
<p class="custom-blurb">
|
||||
Build a report once, schedule or run on demand. We'll email a PDF to the recipients you specify.
|
||||
</p>
|
||||
<UiButton variant="primary" @click="newReportOpen = true">
|
||||
<template #leading><UiIcon name="plus" :size="14" /></template>
|
||||
New custom report
|
||||
</UiButton>
|
||||
</div>
|
||||
|
||||
<Card :pad="0">
|
||||
<table class="dtable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Report</th>
|
||||
<th>Schedule</th>
|
||||
<th>Owner</th>
|
||||
<th>Last run</th>
|
||||
<th class="action-col" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="r in savedReports" :key="r.id">
|
||||
<td><span class="cust-name">{{ r.name }}</span></td>
|
||||
<td><Mono>{{ r.schedule }}</Mono></td>
|
||||
<td>
|
||||
<div class="owner-cell">
|
||||
<Avatar :name="r.owner" :size="20" />
|
||||
<span>{{ r.owner }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td><Mono dim>{{ r.last }}</Mono></td>
|
||||
<td class="action-col" @click.stop>
|
||||
<div class="actions-row">
|
||||
<UiButton size="sm" variant="ghost" @click="runReport(r.id)">
|
||||
<template #leading>
|
||||
<UiIcon :name="running === r.id ? 'refresh' : 'external'" :size="13" />
|
||||
</template>
|
||||
{{ running === r.id ? 'Running…' : 'Run' }}
|
||||
</UiButton>
|
||||
<button class="kebab" @click="openReportMenu(r.id, $event)">
|
||||
<UiIcon name="more" :size="13" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- Portaled custom report row actions menu -->
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="reportMenuFor"
|
||||
class="menu"
|
||||
:style="{ top: reportMenuPos.top + 'px', right: reportMenuPos.right + 'px' }"
|
||||
@click.stop
|
||||
>
|
||||
<template v-for="(it, i) in reportActions(savedReports.find(r => r.id === reportMenuFor)!)" :key="i">
|
||||
<div v-if="it.sep" class="menu-sep" />
|
||||
<button
|
||||
v-else
|
||||
class="menu-item"
|
||||
:class="{ danger: it.danger }"
|
||||
@click="(it.fn?.(), closeMenu())"
|
||||
>
|
||||
<UiIcon :name="(it.i as any)" :size="14" />
|
||||
<span>{{ it.l }}</span>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<!-- Confirm delete report modal -->
|
||||
<Modal
|
||||
:open="!!confirmDeleteId"
|
||||
eyebrow="Permanent action"
|
||||
title="Delete this report?"
|
||||
size="sm"
|
||||
@close="confirmDeleteId = null"
|
||||
>
|
||||
<template v-if="confirmDeleteReport">
|
||||
<div class="danger-callout">
|
||||
<UiIcon name="trash" :size="16" />
|
||||
<p>
|
||||
The report configuration and its schedule will be deleted. Past PDFs already delivered to recipients are unaffected.
|
||||
</p>
|
||||
</div>
|
||||
<div class="del-summary">
|
||||
<dl class="def">
|
||||
<div><dt>Report</dt><dd>{{ confirmDeleteReport.name }}</dd></div>
|
||||
<div><dt>Schedule</dt><dd>{{ confirmDeleteReport.schedule }}</dd></div>
|
||||
<div><dt>Recipients</dt><dd>{{ confirmDeleteReport.recipients }} addresses</dd></div>
|
||||
<div><dt>Last run</dt><dd>{{ confirmDeleteReport.last }}</dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<UiButton variant="ghost" @click="confirmDeleteId = null">Cancel</UiButton>
|
||||
<UiButton variant="danger" @click="deleteReport">
|
||||
<template #leading><UiIcon name="trash" :size="14" /></template>
|
||||
Delete report
|
||||
</UiButton>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
<PartnerCustomerTaskPanel
|
||||
:task="taskCtx"
|
||||
@close="taskCtx = null"
|
||||
@save="(t) => toast.ok(t.mode === 'escalate' ? 'Escalation created' : 'Check-in scheduled', t.customer.name)"
|
||||
/>
|
||||
<PartnerNewCustomReportModal
|
||||
:open="newReportOpen"
|
||||
@close="newReportOpen = false"
|
||||
@created="(n) => toast.ok('Report created', n)"
|
||||
/>
|
||||
|
||||
<!-- Export PDF Modal -->
|
||||
<Modal
|
||||
:open="exportOpen"
|
||||
eyebrow="Partner reports · export"
|
||||
title="Export reports to PDF"
|
||||
size="md"
|
||||
@close="exportOpen = false"
|
||||
>
|
||||
<p class="export-blurb">Select which tabs to include in the PDF, the period, cover style, and how you want it delivered.</p>
|
||||
<div class="export-meta">
|
||||
<Mono dim>// pdf preview · 3 sections · estimated 11 pages · NordicMSP cover + footer</Mono>
|
||||
</div>
|
||||
<template #footer>
|
||||
<UiButton variant="ghost" @click="exportOpen = false">Cancel</UiButton>
|
||||
<UiButton variant="primary" @click="exportOpen = false; toast.ok('PDF queued', 'You will be emailed when ready')">
|
||||
<template #leading><UiIcon name="download" :size="14" /></template>
|
||||
Download PDF
|
||||
</UiButton>
|
||||
</template>
|
||||
</Modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.tabs-bar {
|
||||
padding: 0 40px;
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
.tabs-stretch { flex: 1; }
|
||||
.period-chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 0 10px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
}
|
||||
.period-chip .seg-label {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-mute);
|
||||
}
|
||||
.period-chip select {
|
||||
border: none;
|
||||
background: transparent;
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
color: var(--text);
|
||||
padding: 8px 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.period-chip select:focus { outline: none; }
|
||||
|
||||
.content { padding: 20px 40px 64px; display: flex; flex-direction: column; gap: 16px; }
|
||||
|
||||
.stat-strip { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; }
|
||||
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
||||
|
||||
.card-head {
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.card-title {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 600;
|
||||
font-size: 17px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.big-chart { padding: 20px; }
|
||||
.big-chart :deep(svg) { width: 100%; height: 160px; }
|
||||
.chart-foot {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-top: 12px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--text-mute);
|
||||
}
|
||||
|
||||
.dtable { width: 100%; border-collapse: collapse; }
|
||||
.dtable th {
|
||||
text-align: left;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-mute);
|
||||
font-weight: 500;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.dtable th.num { text-align: right; }
|
||||
.dtable th.action-col, .dtable td.action-col { width: 160px; text-align: right; }
|
||||
.dtable td {
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 13px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.dtable td.num { text-align: right; }
|
||||
.dtable tbody tr:hover { background: var(--row-hover); }
|
||||
|
||||
.cust-cell { display: flex; align-items: center; gap: 12px; }
|
||||
.cust-swatch { width: 22px; height: 22px; border-radius: 4px; flex-shrink: 0; }
|
||||
.cust-name { font-size: 13px; font-weight: 500; }
|
||||
|
||||
.owner-cell { display: flex; align-items: center; gap: 8px; }
|
||||
.owner-cell span { font-size: 12px; }
|
||||
|
||||
.health-cell { display: inline-flex; align-items: center; gap: 10px; }
|
||||
.hbar {
|
||||
width: 90px;
|
||||
height: 5px;
|
||||
background: var(--border);
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.hfill { height: 100%; }
|
||||
|
||||
/* Revenue · plan mix */
|
||||
.plan-mix { display: flex; flex-direction: column; gap: 12px; margin-top: 4px; }
|
||||
.mix-head { display: flex; justify-content: space-between; font-size: 13px; margin-bottom: 6px; }
|
||||
.mix-name { font-weight: 500; }
|
||||
.mix-bar { height: 6px; background: var(--border); border-radius: 999px; overflow: hidden; }
|
||||
.mix-fill { height: 100%; }
|
||||
|
||||
.top-list { display: flex; flex-direction: column; gap: 10px; margin-top: 4px; }
|
||||
.top-row {
|
||||
display: grid;
|
||||
grid-template-columns: 20px 1fr 110px;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
.top-swatch { width: 14px; height: 14px; border-radius: 3px; }
|
||||
.top-name { font-size: 13px; font-weight: 500; }
|
||||
|
||||
/* Cohort heatmap */
|
||||
.cohort-wrap { overflow-x: auto; }
|
||||
.cohort { width: 100%; border-collapse: collapse; min-width: 600px; }
|
||||
.cohort th {
|
||||
padding: 10px 16px;
|
||||
text-align: center;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-mute);
|
||||
font-weight: 500;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.cohort th:first-child { text-align: left; padding-left: 20px; }
|
||||
.cohort td { padding: 10px 16px; text-align: center; border-bottom: 1px solid var(--border); }
|
||||
.cohort td:first-child { text-align: left; padding-left: 20px; }
|
||||
.cohort-size { font-family: var(--font-mono); font-size: 12px; }
|
||||
.cohort .cell { padding: 6px; }
|
||||
.heat {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 38px;
|
||||
height: 22px;
|
||||
border-radius: 4px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.exit-empty { font-size: 13px; color: var(--text-mute); line-height: 1.6; margin: 8px 0 0; }
|
||||
|
||||
/* Custom reports */
|
||||
.custom-head { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 0; }
|
||||
.custom-blurb { font-size: 13px; color: var(--text-mute); margin: 0; max-width: 540px; line-height: 1.5; }
|
||||
.actions-row { display: flex; gap: 4px; justify-content: flex-end; align-items: center; }
|
||||
.kebab {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-mute);
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.kebab:hover { background: var(--row-hover); color: var(--text); }
|
||||
|
||||
/* Confirm delete + Export modals */
|
||||
.danger-callout {
|
||||
padding: 14px;
|
||||
background: rgba(226, 48, 48, 0.06);
|
||||
border: 1px solid rgba(226, 48, 48, 0.22);
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.danger-callout :deep(svg) { color: var(--bad); margin-top: 2px; flex-shrink: 0; }
|
||||
.danger-callout p { font-size: 13px; color: var(--text-dim); line-height: 1.5; margin: 0; }
|
||||
.del-summary {
|
||||
padding: 12px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
}
|
||||
.def { display: flex; flex-direction: column; gap: 8px; margin: 0; padding: 0; }
|
||||
.def div { display: grid; grid-template-columns: 120px 1fr; gap: 12px; font-size: 13px; }
|
||||
.def dt { color: var(--text-mute); font-family: var(--font-mono); font-size: 11px; }
|
||||
.def dd { margin: 0; }
|
||||
|
||||
.export-blurb { font-size: 13px; color: var(--text-dim); margin: 0 0 14px; line-height: 1.55; }
|
||||
.export-meta {
|
||||
padding: 12px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
/* Portaled menu */
|
||||
.menu {
|
||||
position: fixed;
|
||||
min-width: 240px;
|
||||
padding: 4px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.18);
|
||||
z-index: 100;
|
||||
}
|
||||
.menu .menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
border-radius: 5px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
text-align: left;
|
||||
color: var(--text);
|
||||
}
|
||||
.menu .menu-item:hover { background: var(--row-hover); }
|
||||
.menu .menu-item.danger { color: var(--bad); }
|
||||
.menu .menu-item svg { color: var(--text-mute); flex-shrink: 0; }
|
||||
.menu .menu-item.danger svg { color: var(--bad); }
|
||||
.menu .menu-item span { flex: 1; }
|
||||
.menu .menu-sep { height: 1px; background: var(--border); margin: 4px 0; }
|
||||
</style>
|
||||
@@ -0,0 +1,326 @@
|
||||
<script setup lang="ts">
|
||||
// Partner settings. Strict port of PartnerSettingsScreen
|
||||
// (platform-partner-depth.jsx lines 858-1037). Four tabs:
|
||||
// • Agreement — active reseller agreement + DefLists + documents
|
||||
// • Contact info — NordicMSP company info form
|
||||
// • Tax — tax/invoicing DefList + payout method card
|
||||
// • Notifications — partner-level event rows with cadence + channels
|
||||
|
||||
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
const tab = ref<'agreement' | 'contact' | 'tax' | 'notifications'>('agreement')
|
||||
const tabs = [
|
||||
{ value: 'agreement', label: 'Agreement' },
|
||||
{ value: 'contact', label: 'Contact info' },
|
||||
{ value: 'tax', label: 'Tax' },
|
||||
{ value: 'notifications', label: 'Notifications' },
|
||||
]
|
||||
|
||||
// Contact info (editable but kept simple — strict port focuses on layout)
|
||||
const contact = reactive({
|
||||
legalName: 'NordicMSP ApS',
|
||||
tradingName: 'NordicMSP',
|
||||
address: 'Vesterport 12, 1620 København V',
|
||||
country: 'DK',
|
||||
primaryEmail: 'partners@nordicmsp.dk',
|
||||
primaryPhone: '+45 70 70 12 34',
|
||||
supportHotline: '+45 70 70 12 35',
|
||||
website: 'nordicmsp.dk',
|
||||
})
|
||||
|
||||
// Documents · platform-partner-depth.jsx:922-927
|
||||
const docs = [
|
||||
{ n: 'Reseller agreement · v2025.11.pdf', size: '184 KB', date: '14 Nov 2025' },
|
||||
{ n: 'DPA · Data Processing Addendum.pdf', size: '92 KB', date: '14 Jan 2024' },
|
||||
{ n: 'Service Level Agreement.pdf', size: '64 KB', date: '14 Jan 2024' },
|
||||
{ n: 'Margin schedule · v2025.11.xlsx', size: '24 KB', date: '14 Nov 2025' },
|
||||
]
|
||||
|
||||
// Notifications · platform-partner-depth.jsx:1013-1020
|
||||
const events = [
|
||||
{ event: 'New customer signed up', when: 'immediate', channels: 'email · chat' },
|
||||
{ event: 'Customer past-due invoice', when: 'immediate', channels: 'email · in-app' },
|
||||
{ event: 'Customer approaching limit', when: 'daily', channels: 'email' },
|
||||
{ event: 'Customer downgrade or churn', when: 'immediate', channels: 'email · chat · in-app' },
|
||||
{ event: 'Payout processed', when: 'immediate', channels: 'email' },
|
||||
{ event: 'New ticket from a customer', when: 'immediate', channels: 'chat' },
|
||||
{ event: 'Dezky agreement change', when: 'immediate', channels: 'email' },
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<PageHeader
|
||||
eyebrow="Partner"
|
||||
title="Partner settings"
|
||||
subtitle="Agreement terms, business details, tax setup, and partner-level notifications."
|
||||
/>
|
||||
|
||||
<div class="tabs-wrap">
|
||||
<Tabs v-model="tab" :items="tabs" />
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<!-- AGREEMENT -->
|
||||
<template v-if="tab === 'agreement'">
|
||||
<Card>
|
||||
<div class="card-head">
|
||||
<div>
|
||||
<Eyebrow>Reseller agreement</Eyebrow>
|
||||
<div class="card-title">Active · v2025.11</div>
|
||||
<p class="sub">Effective since 14 Jan 2024 · auto-renews 14 Jan 2027</p>
|
||||
</div>
|
||||
<div class="head-actions">
|
||||
<UiButton size="sm" variant="secondary" @click="toast.ok('Downloading', 'Reseller agreement v2025.11')">
|
||||
<template #leading><UiIcon name="download" :size="13" /></template>
|
||||
Download PDF
|
||||
</UiButton>
|
||||
<UiButton size="sm" variant="ghost" @click="toast.info('Version history', 'Showing all 3 versions')">View history</UiButton>
|
||||
</div>
|
||||
</div>
|
||||
<div class="agree-grid">
|
||||
<dl class="def">
|
||||
<div><dt>Tier</dt><dd>Tier 2 · Established</dd></div>
|
||||
<div><dt>Default margin</dt><dd>20% on Starter & Business</dd></div>
|
||||
<div><dt>Enterprise margin</dt><dd>Negotiated · 15% baseline</dd></div>
|
||||
<div><dt>Volume rebate</dt><dd>+2% over 200 active seats · qualifies</dd></div>
|
||||
<div><dt>Payout cadence</dt><dd>Monthly · 3rd business day</dd></div>
|
||||
<div><dt>Min commitment</dt><dd>5 active customers</dd></div>
|
||||
</dl>
|
||||
<dl class="def">
|
||||
<div><dt>Effective</dt><dd>14 Jan 2024</dd></div>
|
||||
<div><dt>Term</dt><dd>36 months · auto-renew</dd></div>
|
||||
<div><dt>Notice period</dt><dd>90 days written</dd></div>
|
||||
<div><dt>Liability cap</dt><dd>12 months of fees</dd></div>
|
||||
<div><dt>Governing law</dt><dd>Denmark · Copenhagen</dd></div>
|
||||
<div><dt>Signed by</dt><dd>Anne Baslund · NordicMSP</dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<Eyebrow>Documents</Eyebrow>
|
||||
<div class="card-title">Related files</div>
|
||||
<div class="doc-list">
|
||||
<button
|
||||
v-for="d in docs"
|
||||
:key="d.n"
|
||||
class="doc-row"
|
||||
@click="toast.info('Downloading', d.n)"
|
||||
>
|
||||
<UiIcon name="file" :size="14" />
|
||||
<span class="dr-name">{{ d.n }}</span>
|
||||
<Mono dim>{{ d.size }} · {{ d.date }}</Mono>
|
||||
<UiIcon name="download" :size="13" />
|
||||
</button>
|
||||
</div>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<!-- CONTACT INFO -->
|
||||
<template v-if="tab === 'contact'">
|
||||
<Card>
|
||||
<div class="card-head">
|
||||
<div>
|
||||
<Eyebrow>Business</Eyebrow>
|
||||
<div class="card-title">NordicMSP company info</div>
|
||||
</div>
|
||||
<UiButton size="sm" variant="ghost" @click="toast.ok('Saved', 'Contact info updated')">Edit</UiButton>
|
||||
</div>
|
||||
<div class="contact-grid">
|
||||
<div class="col">
|
||||
<label class="field"><Eyebrow>Legal name</Eyebrow><input v-model="contact.legalName" /></label>
|
||||
<label class="field"><Eyebrow>Trading name</Eyebrow><input v-model="contact.tradingName" /></label>
|
||||
<label class="field"><Eyebrow>Address</Eyebrow><input v-model="contact.address" /></label>
|
||||
<label class="field"><Eyebrow>Country</Eyebrow><CountrySelect v-model="contact.country" /></label>
|
||||
</div>
|
||||
<div class="col">
|
||||
<label class="field"><Eyebrow>Primary contact · email</Eyebrow><input v-model="contact.primaryEmail" /></label>
|
||||
<label class="field"><Eyebrow>Primary contact · phone</Eyebrow><input v-model="contact.primaryPhone" /></label>
|
||||
<label class="field"><Eyebrow>Support hotline</Eyebrow><input v-model="contact.supportHotline" /></label>
|
||||
<label class="field"><Eyebrow>Public website</Eyebrow><input v-model="contact.website" /></label>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<!-- TAX -->
|
||||
<template v-if="tab === 'tax'">
|
||||
<Card>
|
||||
<Eyebrow>Identification</Eyebrow>
|
||||
<div class="card-title">Tax & invoicing</div>
|
||||
<div class="tax-grid">
|
||||
<dl class="def">
|
||||
<div><dt>Country</dt><dd>Denmark</dd></div>
|
||||
<div><dt>CVR</dt><dd>41 88 22 04</dd></div>
|
||||
<div><dt>VAT number</dt><dd>DK 41 88 22 04</dd></div>
|
||||
<div><dt>VAT rate</dt><dd>25% · standard DK</dd></div>
|
||||
</dl>
|
||||
<dl class="def">
|
||||
<div><dt>Currency</dt><dd>DKK · EUR available</dd></div>
|
||||
<div><dt>Invoicing</dt><dd>OIOUBL · NemHandel</dd></div>
|
||||
<div><dt>EAN/GLN</dt><dd>5790000123456</dd></div>
|
||||
<div><dt>Tax exempt</dt><dd>No</dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<div class="card-head">
|
||||
<div>
|
||||
<Eyebrow>Payout method</Eyebrow>
|
||||
<div class="card-title">Where Dezky pays your margin</div>
|
||||
</div>
|
||||
<UiButton size="sm" variant="ghost" @click="toast.info('Change payout method', 'Contact partner success to switch')">Change</UiButton>
|
||||
</div>
|
||||
<div class="payout-row">
|
||||
<div class="payout-icon"><UiIcon name="card" :size="20" /></div>
|
||||
<div class="payout-meta">
|
||||
<div class="payout-bank">Danske Bank · ApS account</div>
|
||||
<Mono dim>IBAN DK•• •••• •••• •••• 1820 · BIC DABADKKK</Mono>
|
||||
</div>
|
||||
<Badge tone="ok" dot>verified</Badge>
|
||||
</div>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<!-- NOTIFICATIONS -->
|
||||
<template v-if="tab === 'notifications'">
|
||||
<Card :pad="0">
|
||||
<div class="card-head pad">
|
||||
<div>
|
||||
<Eyebrow>Partner-level events</Eyebrow>
|
||||
<div class="card-title">Where to send each event</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="notif-list">
|
||||
<div
|
||||
v-for="(row, i) in events"
|
||||
:key="row.event"
|
||||
class="notif-row"
|
||||
:class="{ last: i === events.length - 1 }"
|
||||
>
|
||||
<span class="notif-event">{{ row.event }}</span>
|
||||
<Mono>{{ row.when }}</Mono>
|
||||
<Mono dim>{{ row.channels }}</Mono>
|
||||
<UiButton size="sm" variant="ghost" @click="toast.info('Edit notification', row.event)">Edit</UiButton>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.tabs-wrap { padding: 0 40px; margin-top: 16px; }
|
||||
|
||||
.content { padding: 20px 40px 64px; display: flex; flex-direction: column; gap: 16px; max-width: 1000px; }
|
||||
|
||||
.card-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.card-head.pad {
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.card-title {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 600;
|
||||
font-size: 17px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.sub { font-size: 13px; color: var(--text-mute); margin: 6px 0 0; line-height: 1.5; }
|
||||
.head-actions { display: flex; gap: 6px; flex-shrink: 0; }
|
||||
|
||||
.agree-grid, .tax-grid, .contact-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
/* DefList */
|
||||
.def { display: flex; flex-direction: column; gap: 10px; margin: 0; padding: 0; }
|
||||
.def div { display: grid; grid-template-columns: 160px 1fr; gap: 12px; font-size: 13px; align-items: center; }
|
||||
.def dt { color: var(--text-mute); font-family: var(--font-mono); font-size: 11px; }
|
||||
.def dd { margin: 0; }
|
||||
|
||||
/* Documents */
|
||||
.doc-list { display: flex; flex-direction: column; gap: 6px; margin-top: 12px; }
|
||||
.doc-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 6px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
.doc-row:hover { background: var(--row-hover); }
|
||||
.doc-row :deep(svg) { color: var(--text-mute); flex-shrink: 0; }
|
||||
.dr-name { flex: 1; font-weight: 500; }
|
||||
|
||||
/* Contact form */
|
||||
.contact-grid .col { display: flex; flex-direction: column; gap: 14px; }
|
||||
.field { display: flex; flex-direction: column; gap: 6px; }
|
||||
.field input {
|
||||
padding: 9px 12px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
color: var(--text);
|
||||
}
|
||||
.field input:focus { outline: none; border-color: var(--border-hi); }
|
||||
|
||||
/* Payout */
|
||||
.payout-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 14px;
|
||||
background: var(--bg);
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border);
|
||||
margin-top: 8px;
|
||||
}
|
||||
.payout-icon {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 8px;
|
||||
background: var(--text);
|
||||
color: var(--bg);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.payout-meta { flex: 1; min-width: 0; }
|
||||
.payout-bank { font-size: 14px; font-weight: 500; }
|
||||
|
||||
/* Notifications */
|
||||
.notif-list { padding: 8px; }
|
||||
.notif-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 120px 220px 80px;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 14px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.notif-row.last { border-bottom: none; }
|
||||
.notif-event { font-size: 13px; font-weight: 500; }
|
||||
</style>
|
||||
@@ -0,0 +1,286 @@
|
||||
<script setup lang="ts">
|
||||
// Partner team. Strict port of PartnerTeamScreen + PartnerTeammateRowActions
|
||||
// (partner-screens.jsx lines 1054-1099 + 1431-1524). Owner row (Anne Baslund)
|
||||
// has destructive actions (Suspend, Remove) disabled with "owner" mono tag.
|
||||
|
||||
|
||||
|
||||
import { customers } from '~/data/customers'
|
||||
import type { TeamMember } from '~/components/partner/TeammatePanel.vue'
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
const inviteOpen = ref(false)
|
||||
const openMember = ref<TeamMember | null>(null)
|
||||
|
||||
// Real partner team from platform-api (proxied via /api/partner/users).
|
||||
// Falls back to an empty list while the request is in flight. Each row's
|
||||
// access/mfa fields are placeholders until per-user access controls and
|
||||
// Authentik MFA introspection land — the underlying User doc only stores
|
||||
// identity + tenantIds + partnerId today.
|
||||
interface PartnerUserDoc {
|
||||
_id: string
|
||||
authentikSubjectId: string
|
||||
email: string
|
||||
name: string
|
||||
role: string
|
||||
active: boolean
|
||||
lastLoginAt?: string
|
||||
createdAt?: string
|
||||
}
|
||||
|
||||
const { data: rawTeam } = await useFetch<PartnerUserDoc[]>('/api/partner/users', {
|
||||
default: () => [],
|
||||
})
|
||||
|
||||
function lastSeenLabel(iso?: string): string {
|
||||
if (!iso) return 'never'
|
||||
const ms = Date.now() - new Date(iso).getTime()
|
||||
if (ms < 60_000) return 'just now'
|
||||
const m = Math.floor(ms / 60_000)
|
||||
if (m < 60) return `${m} min ago`
|
||||
const h = Math.floor(m / 60)
|
||||
if (h < 24) return `${h} h ago`
|
||||
const d = Math.floor(h / 24)
|
||||
return `${d} d ago`
|
||||
}
|
||||
|
||||
const members = computed<TeamMember[]>(() =>
|
||||
(rawTeam.value ?? []).map((u) => ({
|
||||
id: u.authentikSubjectId,
|
||||
name: u.name,
|
||||
email: u.email,
|
||||
role: u.role === 'admin' ? 'Partner admin' : u.role === 'owner' ? 'Owner' : 'Partner staff',
|
||||
access: 'all',
|
||||
mfa: '—',
|
||||
lastSeen: lastSeenLabel(u.lastLoginAt),
|
||||
isOwner: u.role === 'owner',
|
||||
})),
|
||||
)
|
||||
|
||||
function accessLabel(m: TeamMember) {
|
||||
if (m.access === 'all') return `all (${customers.length})`
|
||||
if (m.access === 'none') return 'no access'
|
||||
// Specific count for fixtures: Mikkel = 6, Oliver = 3
|
||||
if (m.email === 'mikkel@nordicmsp.dk') return `6 of ${customers.length}`
|
||||
if (m.email === 'oliver@nordicmsp.dk') return `3 of ${customers.length}`
|
||||
return `${customers.length - 5} of ${customers.length}`
|
||||
}
|
||||
|
||||
function onSent(payload: { email: string; role: string }) {
|
||||
toast.ok('Invitation sent', `${payload.role} invite to ${payload.email}`)
|
||||
}
|
||||
|
||||
// Row actions popover · mirrors PartnerTeammateRowActions (lines 1431-1524).
|
||||
const menuFor = ref<string | null>(null)
|
||||
const menuPos = ref<{ top: number; right: number }>({ top: 0, right: 0 })
|
||||
|
||||
function openMenu(m: TeamMember, e: MouseEvent) {
|
||||
e.stopPropagation()
|
||||
const btn = (e.currentTarget as HTMLElement).getBoundingClientRect()
|
||||
menuPos.value = { top: btn.bottom + 4, right: window.innerWidth - btn.right }
|
||||
menuFor.value = menuFor.value === m.id ? null : m.id
|
||||
}
|
||||
|
||||
function actionsFor(m: TeamMember) {
|
||||
return [
|
||||
{ i: 'users', l: 'View details', fn: () => openMember.value = m },
|
||||
{ i: 'brush', l: 'Change role…', fn: () => openMember.value = m },
|
||||
{ i: 'building', l: 'Customer access…', fn: () => openMember.value = m },
|
||||
{ sep: true },
|
||||
{ i: 'refresh', l: 'Resend invitation', fn: () => toast.info('Invitation resent', m.email) },
|
||||
{ i: 'shield', l: 'Reset MFA', fn: () => toast.warn('MFA reset', `${m.name} must enrol again on next sign-in`) },
|
||||
{ i: 'key', l: 'Reset password', fn: () => toast.info('Password reset', `Email sent to ${m.email}`) },
|
||||
{ sep: true },
|
||||
{ i: 'x', l: 'Suspend account', fn: () => toast.warn('Account suspended', m.name), disabled: m.isOwner },
|
||||
{ i: 'trash', l: 'Remove from team', danger: true, fn: () => toast.bad('Removal pending', `${m.name} will be removed`), disabled: m.isOwner },
|
||||
]
|
||||
}
|
||||
|
||||
function closeMenu() { menuFor.value = null }
|
||||
|
||||
onMounted(() => {
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') closeMenu() }
|
||||
const onScroll = () => closeMenu()
|
||||
document.addEventListener('keydown', onKey)
|
||||
document.addEventListener('click', closeMenu)
|
||||
window.addEventListener('scroll', onScroll, true)
|
||||
window.addEventListener('resize', onScroll)
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('keydown', onKey)
|
||||
document.removeEventListener('click', closeMenu)
|
||||
window.removeEventListener('scroll', onScroll, true)
|
||||
window.removeEventListener('resize', onScroll)
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<PageHeader
|
||||
eyebrow="People"
|
||||
title="Partner team"
|
||||
subtitle="People at NordicMSP with access to the partner console and your customers."
|
||||
>
|
||||
<template #actions>
|
||||
<UiButton variant="primary" @click="inviteOpen = true">
|
||||
<template #leading><UiIcon name="plus" :size="14" /></template>
|
||||
Invite teammate
|
||||
</UiButton>
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<div class="content">
|
||||
<Card :pad="0">
|
||||
<table class="dtable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Role</th>
|
||||
<th>Customer access</th>
|
||||
<th>MFA</th>
|
||||
<th>Last seen</th>
|
||||
<th class="action-col" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="m in members"
|
||||
:key="m.id"
|
||||
@click="openMember = m"
|
||||
>
|
||||
<td>
|
||||
<div class="user-cell">
|
||||
<Avatar :name="m.name" :size="28" />
|
||||
<div>
|
||||
<div class="user-name">{{ m.name }}</div>
|
||||
<Mono dim>{{ m.email }}</Mono>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<Badge :tone="m.role === 'Partner admin' ? 'invert' : 'neutral'">{{ m.role }}</Badge>
|
||||
</td>
|
||||
<td><Mono>{{ accessLabel(m) }}</Mono></td>
|
||||
<td><Badge tone="ok" dot>enabled</Badge></td>
|
||||
<td><Mono dim>{{ m.lastSeen }}</Mono></td>
|
||||
<td class="action-col" @click.stop>
|
||||
<button class="kebab" @click="openMenu(m, $event)">
|
||||
<UiIcon name="more" :size="14" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- Portaled action menu -->
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="menuFor"
|
||||
class="menu"
|
||||
:style="{ top: menuPos.top + 'px', right: menuPos.right + 'px' }"
|
||||
@click.stop
|
||||
>
|
||||
<template v-for="(it, i) in actionsFor(members.find(m => m.id === menuFor)!)" :key="i">
|
||||
<div v-if="it.sep" class="menu-sep" />
|
||||
<button
|
||||
v-else
|
||||
class="menu-item"
|
||||
:class="{ danger: it.danger, disabled: it.disabled }"
|
||||
:disabled="it.disabled"
|
||||
@click="(it.fn?.(), closeMenu())"
|
||||
>
|
||||
<UiIcon :name="(it.i as any)" :size="13" />
|
||||
<span>{{ it.l }}</span>
|
||||
<Mono
|
||||
v-if="members.find(m => m.id === menuFor)?.isOwner && (it.l?.startsWith('Suspend') || it.l?.startsWith('Remove'))"
|
||||
dim
|
||||
class="owner-tag"
|
||||
>owner</Mono>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<PartnerInviteTeammateModal :open="inviteOpen" @close="inviteOpen = false" @sent="onSent" />
|
||||
<PartnerTeammatePanel :member="openMember" @close="openMember = null" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.content { padding: 24px 40px 64px; }
|
||||
|
||||
.dtable { width: 100%; border-collapse: collapse; }
|
||||
.dtable th {
|
||||
text-align: left;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-mute);
|
||||
font-weight: 500;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.dtable th.action-col { width: 40px; }
|
||||
.dtable td {
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 13px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.dtable tbody tr { cursor: pointer; transition: background 80ms; }
|
||||
.dtable tbody tr:hover { background: var(--row-hover); }
|
||||
|
||||
.user-cell { display: flex; align-items: center; gap: 12px; }
|
||||
.user-name { font-size: 13px; font-weight: 500; }
|
||||
|
||||
.kebab {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-mute);
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.kebab:hover { background: var(--row-hover); color: var(--text); }
|
||||
</style>
|
||||
|
||||
<style>
|
||||
/* Portaled menu — global because Teleport-ed to body */
|
||||
.menu {
|
||||
position: fixed;
|
||||
min-width: 220px;
|
||||
padding: 4px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.18);
|
||||
z-index: 100;
|
||||
}
|
||||
.menu .menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
border-radius: 5px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
text-align: left;
|
||||
color: var(--text);
|
||||
}
|
||||
.menu .menu-item:hover:not(:disabled) { background: var(--row-hover); }
|
||||
.menu .menu-item.danger { color: var(--bad); }
|
||||
.menu .menu-item.disabled, .menu .menu-item:disabled { color: var(--text-mute); cursor: not-allowed; opacity: 0.5; }
|
||||
.menu .menu-item svg { color: var(--text-mute); flex-shrink: 0; }
|
||||
.menu .menu-item.danger svg { color: var(--bad); }
|
||||
.menu .menu-item span { flex: 1; }
|
||||
.menu .menu-sep { height: 1px; background: var(--border); margin: 4px 0; }
|
||||
.menu .owner-tag { font-size: 9px; }
|
||||
</style>
|
||||
@@ -0,0 +1,904 @@
|
||||
<script setup lang="ts">
|
||||
// My profile. Faithfully ports project/platform-enduser.jsx `ProfileScreenDeep`
|
||||
// (lines 972–1400) and its tab sub-components. 5 tabs: Profile / Work info /
|
||||
// Preferences / Email signature / Notifications, plus a sticky save bar that
|
||||
// surfaces when any tab is dirty.
|
||||
|
||||
|
||||
const toast = useToast()
|
||||
const tweaks = usePortalTweaks()
|
||||
|
||||
// Honour ?tab=… so the topbar bell's "Preferences" can deep-link to
|
||||
// the Notifications tab (since /notifications no longer exists).
|
||||
const route = useRoute()
|
||||
const validTabs = ['profile', 'work', 'preferences', 'signature', 'notifications'] as const
|
||||
const initialTab = (typeof route.query.tab === 'string' && (validTabs as readonly string[]).includes(route.query.tab))
|
||||
? route.query.tab as string
|
||||
: 'profile'
|
||||
const tab = ref(initialTab)
|
||||
const dirty = ref(false)
|
||||
const markDirty = () => { dirty.value = true }
|
||||
|
||||
function discard() {
|
||||
dirty.value = false
|
||||
toast.warn('Discarded unsaved changes')
|
||||
}
|
||||
function save() {
|
||||
dirty.value = false
|
||||
toast.ok('Profile saved · changes live in ~10 seconds')
|
||||
}
|
||||
|
||||
// Modals
|
||||
const photoOpen = ref(false)
|
||||
const quietOpen = ref(false)
|
||||
const overrideOpen = ref(false)
|
||||
|
||||
// Mustache-tag literals — held as JS constants so the template doesn't have
|
||||
// to nest `{{ ... }}` inside `{{ ... }}` (which trips Vue's parser).
|
||||
const AVAILABLE_VARS_TEXT =
|
||||
'{' + '{full_name}}' + ' · ' +
|
||||
'{' + '{first_name}}' + ' · ' +
|
||||
'{' + '{job_title}}' + ' · ' +
|
||||
'{' + '{phone}}' + ' · ' +
|
||||
'{' + '{email}}' + ' · ' +
|
||||
'{' + '{company_name}}' + ' · ' +
|
||||
'{' + '{pronouns}}'
|
||||
const MERGE_TAG_LITERAL = '{' + '{merge}}'
|
||||
|
||||
// --- Profile tab ---
|
||||
const profile = reactive({
|
||||
firstName: 'Anne',
|
||||
lastName: 'Baslund',
|
||||
displayName: 'Anne B.',
|
||||
pronouns: 'she/her',
|
||||
email: 'anne@dezky.com',
|
||||
phone: '+45 21 47 88 02',
|
||||
})
|
||||
|
||||
// --- Work info ---
|
||||
const work = reactive({
|
||||
title: 'Founder · CEO',
|
||||
department: 'Leadership',
|
||||
manager: '(none · top of org)',
|
||||
location: 'Copenhagen, DK',
|
||||
startDate: '14 Jan 2026',
|
||||
officeDays: 'Mon · Wed · Fri',
|
||||
})
|
||||
|
||||
// Match source `WorkInfoTab` connections list (5 items, only Google + MS365 connected).
|
||||
const connections = ref([
|
||||
{ id: 'google', name: 'Google', initial: 'G', color: '#4285F4', connected: true, account: 'anne@gmail.com', scopes: 'calendar · contacts', since: '14 Jan 2026' },
|
||||
{ id: 'microsoft', name: 'Microsoft 365', initial: 'M', color: '#00A4EF', connected: true, account: 'anne.baslund@outlook.com', scopes: 'calendar', since: '02 Mar 2026' },
|
||||
{ id: 'apple', name: 'Apple', initial: 'A', color: '#000000', connected: false, account: '', scopes: '', since: '' },
|
||||
{ id: 'github', name: 'GitHub', initial: 'g', color: '#181717', connected: false, account: '', scopes: '', since: '' },
|
||||
{ id: 'slack', name: 'Slack (legacy DM)', initial: 's', color: '#4A154B', connected: false, account: '', scopes: '', since: '' },
|
||||
])
|
||||
|
||||
const apiTokens = ref([
|
||||
{ id: 'tk-1', name: 'CLI · macbook', scope: 'read:files write:files', last: '2 d ago' },
|
||||
{ id: 'tk-2', name: 'Zapier integration', scope: 'read:mail', last: '14 d ago' },
|
||||
])
|
||||
|
||||
// --- Preferences ---
|
||||
const theme = ref<'system' | 'light' | 'dark'>('system')
|
||||
const density = ref<'Comfortable' | 'Compact'>('Comfortable')
|
||||
const reduceMotion = ref(false)
|
||||
|
||||
const ooo = reactive({
|
||||
enabled: false,
|
||||
from: '2026-06-14',
|
||||
to: '2026-06-21',
|
||||
message: "I’m on holiday until 21 June and will reply when I’m back.\n\nFor urgent matters, contact Mikkel at mikkel@dezky.com.",
|
||||
redirect: true,
|
||||
})
|
||||
|
||||
const region = reactive({
|
||||
language: 'English (UK)',
|
||||
spellcheck: 'Dansk · English',
|
||||
timezone: 'Europe/Copenhagen · CEST',
|
||||
dateFormat: 'DD MMM YYYY · 14 May 2026',
|
||||
timeFormat: '24-hour · 14:32',
|
||||
weekStarts: 'Monday',
|
||||
currency: 'DKK · 1.940,00',
|
||||
workHours: '09:00 – 17:00',
|
||||
})
|
||||
|
||||
function pickTheme(v: 'system' | 'light' | 'dark') {
|
||||
theme.value = v
|
||||
if (v === 'light' || v === 'dark') tweaks.setTheme(v)
|
||||
markDirty()
|
||||
}
|
||||
|
||||
// --- Signature ---
|
||||
const sig = reactive({
|
||||
// Signature body is plain text shown in the contentEditable preview.
|
||||
// Source uses 3 lines + a disclosure footer; we keep the same structure.
|
||||
body: 'Anne Baslund\nFounder · CEO at baslund\n+45 21 47 88 02 · anne@dezky.com',
|
||||
})
|
||||
const sigApply = reactive({ newEmails: true, replies: true, invites: false, ooo: false })
|
||||
|
||||
// --- Notifications ---
|
||||
// Source `NotifPrefsTab` channels and event labels.
|
||||
const notifChannels = ['In-app', 'Email', 'Push · mobile', 'Slack mirror'] as const
|
||||
const notifEvents = ref([
|
||||
{ id: 'mentions', label: 'Mentions in chat', vals: [true, true, true, false] },
|
||||
{ id: 'dms', label: 'Direct messages', vals: [true, true, true, false] },
|
||||
{ id: 'shared', label: 'Files shared with me', vals: [true, true, false, false] },
|
||||
{ id: 'invites', label: 'Meeting invites', vals: [true, true, true, false] },
|
||||
{ id: 'reminders', label: 'Calendar reminders', vals: [true, false, true, false] },
|
||||
{ id: 'security', label: 'Security alerts on my account', vals: [true, true, true, false] },
|
||||
{ id: 'announcements',label: 'Workspace announcements (admin)', vals: [true, true, false, false] },
|
||||
{ id: 'billing', label: 'Billing reminders (admin only)', vals: [true, true, false, false] },
|
||||
{ id: 'digest', label: 'Weekly digest', vals: [false, true, false, false] },
|
||||
{ id: 'marketing', label: 'Marketing from dezky', vals: [false, false, false, false] },
|
||||
])
|
||||
|
||||
// Quiet hours modal state — mirrors source EditQuietHoursModal.
|
||||
const quiet = reactive({
|
||||
weekFrom: '22:00',
|
||||
weekTo: '07:00',
|
||||
weekendDifferent: true,
|
||||
wkndFrom: '00:00',
|
||||
wkndTo: '09:00',
|
||||
pauseHolidays: true,
|
||||
allowManager: false,
|
||||
})
|
||||
|
||||
// Change photo modal state — mirrors source ChangePhotoModal.
|
||||
const photo = reactive({ uploaded: false, crop: 50 })
|
||||
function uploadPhoto() { photo.uploaded = true }
|
||||
function removePhoto() { photo.uploaded = false }
|
||||
watch(photoOpen, (v) => { if (v) { photo.uploaded = false; photo.crop = 50 } })
|
||||
|
||||
// Signature override modal state.
|
||||
const override = reactive({ scope: 'domain' as 'domain' | 'recipient' | 'category', domain: 'baslund.dk', name: 'External communications', body: 'Best regards,\nAnne Baslund\nFounder · baslund\nbaslund.dk · +45 21 47 88 02' })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<PageHeader
|
||||
eyebrow="Account"
|
||||
title="My profile"
|
||||
subtitle="Update your personal details, preferences, and email signature."
|
||||
/>
|
||||
|
||||
<div class="tabs-wrap">
|
||||
<Tabs
|
||||
v-model="tab"
|
||||
:items="[
|
||||
{ value: 'profile', label: 'Profile' },
|
||||
{ value: 'work', label: 'Work info' },
|
||||
{ value: 'preferences', label: 'Preferences' },
|
||||
{ value: 'signature', label: 'Email signature' },
|
||||
{ value: 'notifications', label: 'Notifications' },
|
||||
]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<!-- Profile tab -->
|
||||
<section v-if="tab === 'profile'" class="profile-grid">
|
||||
<Card>
|
||||
<div class="photo">
|
||||
<Avatar :name="`${profile.firstName} ${profile.lastName}`" :size="88" />
|
||||
<UiButton size="sm" variant="secondary" @click="photoOpen = true">
|
||||
<template #leading><UiIcon name="upload" :size="13" /></template>
|
||||
Change photo
|
||||
</UiButton>
|
||||
<div class="photo-meta">
|
||||
<div class="display">{{ profile.firstName }} {{ profile.lastName }}</div>
|
||||
<Mono dim>{{ profile.email }}</Mono>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card>
|
||||
<header class="card-header">
|
||||
<Eyebrow>Personal</Eyebrow>
|
||||
<h2>Contact details</h2>
|
||||
</header>
|
||||
<div class="grid-2">
|
||||
<EnduserFormField label="First name"><input v-model="profile.firstName" @input="markDirty" /></EnduserFormField>
|
||||
<EnduserFormField label="Last name"><input v-model="profile.lastName" @input="markDirty" /></EnduserFormField>
|
||||
<EnduserFormField label="Display name"><input v-model="profile.displayName" @input="markDirty" /></EnduserFormField>
|
||||
<EnduserFormField label="Pronouns"><input v-model="profile.pronouns" @input="markDirty" /></EnduserFormField>
|
||||
<EnduserFormField label="Email"><input v-model="profile.email" @input="markDirty" /></EnduserFormField>
|
||||
<EnduserFormField label="Phone"><input v-model="profile.phone" @input="markDirty" /></EnduserFormField>
|
||||
</div>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
<!-- Work info tab -->
|
||||
<section v-else-if="tab === 'work'" class="stack">
|
||||
<Card>
|
||||
<header class="card-header">
|
||||
<Eyebrow>Workplace</Eyebrow>
|
||||
<h2>What you do at baslund</h2>
|
||||
</header>
|
||||
<div class="grid-2">
|
||||
<EnduserFormField label="Job title"><input v-model="work.title" @input="markDirty" /></EnduserFormField>
|
||||
<EnduserFormField label="Department"><input v-model="work.department" @input="markDirty" /></EnduserFormField>
|
||||
<EnduserFormField label="Manager"><input v-model="work.manager" @input="markDirty" /></EnduserFormField>
|
||||
<EnduserFormField label="Location"><input v-model="work.location" @input="markDirty" /></EnduserFormField>
|
||||
<EnduserFormField label="Start date"><input v-model="work.startDate" @input="markDirty" /></EnduserFormField>
|
||||
<EnduserFormField label="Office days"><input v-model="work.officeDays" @input="markDirty" /></EnduserFormField>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<header class="card-header">
|
||||
<Eyebrow>Connected accounts</Eyebrow>
|
||||
<h2>Link external services</h2>
|
||||
<p>For calendar sync, contact lookup, and single sign-on. dezky never sees your password.</p>
|
||||
</header>
|
||||
<ul class="conn">
|
||||
<li v-for="p in connections" :key="p.id">
|
||||
<span class="conn-tile" :style="{ background: p.color }">{{ p.initial }}</span>
|
||||
<div class="conn-text">
|
||||
<div class="conn-name">
|
||||
{{ p.name }}
|
||||
<Badge v-if="p.connected" tone="ok" dot>connected</Badge>
|
||||
</div>
|
||||
<Mono v-if="p.connected" dim>{{ p.account }} · {{ p.scopes }} · since {{ p.since }}</Mono>
|
||||
<Mono v-else dim>not linked</Mono>
|
||||
</div>
|
||||
<div class="conn-actions">
|
||||
<template v-if="p.connected">
|
||||
<UiButton size="sm" variant="ghost" @click="toast.info(`Managing ${p.name}`)">Manage</UiButton>
|
||||
<UiButton size="sm" variant="ghost" @click="toast.warn(`Disconnecting ${p.name}`); markDirty()">Disconnect</UiButton>
|
||||
</template>
|
||||
<UiButton v-else size="sm" variant="secondary" @click="toast.info(`Connecting ${p.name}`)">
|
||||
<template #leading><UiIcon name="external" :size="13" /></template>
|
||||
Connect
|
||||
</UiButton>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<header class="card-header with-actions">
|
||||
<div class="head-text">
|
||||
<Eyebrow>Developers</Eyebrow>
|
||||
<h2>Personal API tokens</h2>
|
||||
<p>Use these in scripts and integrations that act on your behalf. Treat like passwords.</p>
|
||||
</div>
|
||||
<UiButton size="sm" variant="secondary" @click="toast.info('New token wizard')">
|
||||
<template #leading><UiIcon name="plus" :size="13" /></template>
|
||||
New token
|
||||
</UiButton>
|
||||
</header>
|
||||
<ul class="tokens">
|
||||
<li v-for="t in apiTokens" :key="t.id">
|
||||
<UiIcon name="key" :size="15" stroke="var(--text-mute)" />
|
||||
<div class="conn-text">
|
||||
<div class="conn-name">{{ t.name }}</div>
|
||||
<Mono dim>{{ t.scope }} · last used {{ t.last }}</Mono>
|
||||
</div>
|
||||
<UiButton size="sm" variant="ghost" @click="toast.warn(`Token ${t.id} revoked`)">Revoke</UiButton>
|
||||
</li>
|
||||
</ul>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
<!-- Preferences tab -->
|
||||
<section v-else-if="tab === 'preferences'" class="stack">
|
||||
<Card>
|
||||
<header class="card-header with-actions">
|
||||
<div class="head-text">
|
||||
<Eyebrow>Out of office</Eyebrow>
|
||||
<h2>Auto-reply when you're away</h2>
|
||||
<p>Active for the dates below. Senders get a one-time reply per thread.</p>
|
||||
</div>
|
||||
<EnduserToggle v-model="ooo.enabled" @update:model-value="markDirty" />
|
||||
</header>
|
||||
<div class="ooo" :data-on="ooo.enabled">
|
||||
<div class="grid-2">
|
||||
<EnduserFormField label="From"><input v-model="ooo.from" @input="markDirty" /></EnduserFormField>
|
||||
<EnduserFormField label="Until"><input v-model="ooo.to" @input="markDirty" /></EnduserFormField>
|
||||
</div>
|
||||
<EnduserFormField label="Auto-reply message">
|
||||
<textarea v-model="ooo.message" rows="5" @input="markDirty" />
|
||||
</EnduserFormField>
|
||||
<div class="redirect-row">
|
||||
<div>
|
||||
<div class="rr-l">Forward incoming mail to a coworker</div>
|
||||
<Mono dim>mikkel@dezky.com · gets a tag so they know it's a forward</Mono>
|
||||
</div>
|
||||
<EnduserToggle v-model="ooo.redirect" @update:model-value="markDirty" />
|
||||
</div>
|
||||
<Mono dim>presence will be set to <b style="color: var(--text);">Away</b> automatically · status reverts on the end date</Mono>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<header class="card-header">
|
||||
<Eyebrow>Language & region</Eyebrow>
|
||||
<h2>Where and how dezky speaks to you</h2>
|
||||
</header>
|
||||
<div class="grid-2">
|
||||
<EnduserFormField label="Display language"><input v-model="region.language" @input="markDirty" /></EnduserFormField>
|
||||
<EnduserFormField label="Spell-check language"><input v-model="region.spellcheck" @input="markDirty" /></EnduserFormField>
|
||||
<EnduserFormField label="Timezone"><input v-model="region.timezone" @input="markDirty" /></EnduserFormField>
|
||||
<EnduserFormField label="Date format"><input v-model="region.dateFormat" @input="markDirty" /></EnduserFormField>
|
||||
<EnduserFormField label="Time format"><input v-model="region.timeFormat" @input="markDirty" /></EnduserFormField>
|
||||
<EnduserFormField label="Week starts on"><input v-model="region.weekStarts" @input="markDirty" /></EnduserFormField>
|
||||
<EnduserFormField label="Currency"><input v-model="region.currency" @input="markDirty" /></EnduserFormField>
|
||||
<EnduserFormField label="Working hours"><input v-model="region.workHours" @input="markDirty" /></EnduserFormField>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<header class="card-header">
|
||||
<Eyebrow>Appearance</Eyebrow>
|
||||
<h2>How dezky looks</h2>
|
||||
<p>Affects this device only. Global theme is set per-workspace by admins.</p>
|
||||
</header>
|
||||
<div class="appearance">
|
||||
<EnduserFormField label="Theme">
|
||||
<div class="theme-tiles">
|
||||
<button
|
||||
v-for="t in [
|
||||
{ v: 'system' as const, l: 'Match system', bg: 'linear-gradient(135deg, #F4F3EE 50%, #0A0A0A 50%)' },
|
||||
{ v: 'light' as const, l: 'Light', bg: '#F4F3EE' },
|
||||
{ v: 'dark' as const, l: 'Dark', bg: '#0A0A0A' },
|
||||
]"
|
||||
:key="t.v"
|
||||
class="theme-tile"
|
||||
:class="{ active: theme === t.v }"
|
||||
@click="pickTheme(t.v)"
|
||||
>
|
||||
<span class="theme-swatch" :style="{ background: t.bg }" />
|
||||
<span>{{ t.l }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</EnduserFormField>
|
||||
<EnduserFormField label="Density">
|
||||
<div class="density">
|
||||
<button
|
||||
v-for="d in (['Comfortable', 'Compact'] as const)"
|
||||
:key="d"
|
||||
:class="{ active: density === d }"
|
||||
@click="density = d; tweaks.setDensity(d === 'Comfortable' ? 'comfy' : 'compact'); markDirty()"
|
||||
>{{ d }}</button>
|
||||
</div>
|
||||
</EnduserFormField>
|
||||
<EnduserFormField label="Reduce motion">
|
||||
<div class="row-toggle">
|
||||
<EnduserToggle v-model="reduceMotion" @update:model-value="markDirty" />
|
||||
<span>Honor system preference</span>
|
||||
</div>
|
||||
</EnduserFormField>
|
||||
</div>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
<!-- Signature tab -->
|
||||
<section v-else-if="tab === 'signature'" class="sig-grid">
|
||||
<Card>
|
||||
<header class="card-header">
|
||||
<Eyebrow>Email signature</Eyebrow>
|
||||
<h2>Your default signature</h2>
|
||||
<p>Appended to outgoing email from your dezky address. You can override per-account.</p>
|
||||
</header>
|
||||
<div class="toolbar">
|
||||
<button class="tb b" @click="markDirty">B</button>
|
||||
<button class="tb i" @click="markDirty">I</button>
|
||||
<button class="tb u" @click="markDirty">U</button>
|
||||
<button class="tb" @click="markDirty">⇿</button>
|
||||
<button class="tb mono" @click="markDirty">link</button>
|
||||
<button class="tb mono" @click="markDirty">image</button>
|
||||
</div>
|
||||
<div
|
||||
class="sig-editor"
|
||||
contenteditable="true"
|
||||
@input="markDirty"
|
||||
spellcheck="false"
|
||||
>
|
||||
<div class="sig-line bold">Anne Baslund</div>
|
||||
<div class="sig-line dim">Founder · CEO at <b>baslund</b></div>
|
||||
<div class="sig-line mute">+45 21 47 88 02 · anne@dezky.com</div>
|
||||
<div class="sig-foot">We collect personal data — see our <u>privacy policy</u>. Sent via <span class="mono">dezky</span>.</div>
|
||||
</div>
|
||||
<div class="vars">
|
||||
<Mono>// available variables</Mono><br />
|
||||
{{ AVAILABLE_VARS_TEXT }}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div class="sig-side">
|
||||
<Card>
|
||||
<header class="card-header">
|
||||
<Eyebrow>When to use</Eyebrow>
|
||||
<h2>Apply to</h2>
|
||||
</header>
|
||||
<ul class="apply-list">
|
||||
<li>
|
||||
<label><input type="checkbox" v-model="sigApply.newEmails" @change="markDirty" /><span>New emails</span></label>
|
||||
</li>
|
||||
<li>
|
||||
<label><input type="checkbox" v-model="sigApply.replies" @change="markDirty" /><span>Replies and forwards</span></label>
|
||||
</li>
|
||||
<li>
|
||||
<label><input type="checkbox" v-model="sigApply.invites" @change="markDirty" /><span>Calendar invites</span></label>
|
||||
</li>
|
||||
<li>
|
||||
<label><input type="checkbox" v-model="sigApply.ooo" @change="markDirty" /><span>Out-of-office replies</span></label>
|
||||
</li>
|
||||
</ul>
|
||||
</Card>
|
||||
<Card>
|
||||
<header class="card-header with-actions">
|
||||
<div class="head-text">
|
||||
<Eyebrow>Per-account</Eyebrow>
|
||||
<h2>Account overrides</h2>
|
||||
</div>
|
||||
<UiButton size="sm" variant="ghost" @click="overrideOpen = true">
|
||||
<template #leading><UiIcon name="plus" :size="13" /></template>
|
||||
Add
|
||||
</UiButton>
|
||||
</header>
|
||||
<Mono dim>// no overrides · all accounts use the default signature</Mono>
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Notifications tab -->
|
||||
<section v-else-if="tab === 'notifications'">
|
||||
<Card :pad="0" style="max-width: 920px;">
|
||||
<div class="notif-head">
|
||||
<Eyebrow>Channels · per event</Eyebrow>
|
||||
<div class="notif-title">Notification preferences</div>
|
||||
</div>
|
||||
<table class="notif">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="lhs">Event</th>
|
||||
<th v-for="c in notifChannels" :key="c">{{ c }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="e in notifEvents" :key="e.id">
|
||||
<td class="lhs">{{ e.label }}</td>
|
||||
<td v-for="(v, j) in e.vals" :key="j">
|
||||
<input type="checkbox" v-model="e.vals[j]" @change="markDirty" />
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="notif-foot">
|
||||
<Mono dim>// quiet hours · 22:00 – 07:00 · respects your working hours</Mono>
|
||||
<UiButton size="sm" variant="ghost" @click="quietOpen = true">Edit quiet hours</UiButton>
|
||||
</div>
|
||||
</Card>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<EnduserSaveBar :dirty="dirty" @discard="discard" @save="save" />
|
||||
|
||||
<!-- Change photo modal -->
|
||||
<Modal :open="photoOpen" eyebrow="Account · photo" title="Change profile photo" size="md" @close="photoOpen = false">
|
||||
<div class="photo-modal">
|
||||
<button v-if="!photo.uploaded" class="photo-upload" @click="uploadPhoto">
|
||||
<UiIcon name="upload" :size="28" stroke="var(--text-mute)" />
|
||||
<div class="pu-text">
|
||||
<div class="pu-l">Drop a photo here, or click to browse</div>
|
||||
<Mono dim style="margin-top: 6px; display: block;">jpg · png · webp · up to 5 MB · best at 512×512</Mono>
|
||||
</div>
|
||||
</button>
|
||||
<template v-else>
|
||||
<div class="crop-row">
|
||||
<div class="crop-main">
|
||||
<Eyebrow style="display: block; margin-bottom: 8px;">Crop preview</Eyebrow>
|
||||
<div class="crop-stage">
|
||||
<div class="crop-mask" />
|
||||
<span class="crop-letter" :style="{ transform: `translate(${(photo.crop - 50) * 0.4}px, 0)` }">A</span>
|
||||
</div>
|
||||
<div style="margin-top: 12px;">
|
||||
<Mono dim style="display: block; margin-bottom: 4px;">Horizontal position</Mono>
|
||||
<input type="range" min="0" max="100" v-model.number="photo.crop" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="crop-side">
|
||||
<Eyebrow style="display: block; margin-bottom: 8px;">Final result</Eyebrow>
|
||||
<div class="circle large"><span :style="{ transform: `translate(${(photo.crop - 50) * 0.1}px, 0)` }">A</span></div>
|
||||
<Mono dim>large · 88×88</Mono>
|
||||
<div class="circle small"><span>A</span></div>
|
||||
<Mono dim>chat · 32×32</Mono>
|
||||
</div>
|
||||
</div>
|
||||
<UiButton size="sm" variant="ghost" @click="removePhoto">Replace photo</UiButton>
|
||||
</template>
|
||||
<div class="def-block">
|
||||
<Mono dim>// where it appears</Mono>
|
||||
<div class="def-body">Profile pages, Chat avatars, mail headers, meeting tiles, and the partner console. Co-workers in your workspace can see it.</div>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<UiButton v-if="photo.uploaded" variant="danger" @click="removePhoto">
|
||||
<template #leading><UiIcon name="trash" :size="13" /></template>
|
||||
Remove
|
||||
</UiButton>
|
||||
<div style="flex: 1;" />
|
||||
<UiButton variant="ghost" @click="photoOpen = false">Cancel</UiButton>
|
||||
<UiButton variant="primary" :disabled="!photo.uploaded" @click="photoOpen = false; markDirty(); toast.ok('Photo queued for upload')">
|
||||
<template #leading><UiIcon name="check" :size="13" /></template>
|
||||
{{ photo.uploaded ? 'Use this photo' : 'Select a photo' }}
|
||||
</UiButton>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
<!-- Edit quiet hours modal -->
|
||||
<Modal :open="quietOpen" eyebrow="Notifications · quiet hours" title="Edit quiet hours" size="md" @close="quietOpen = false">
|
||||
<div class="quiet-stack">
|
||||
<div class="callout-info">
|
||||
<UiIcon name="bell" :size="14" />
|
||||
<span>Push and email notifications are silenced during quiet hours. In-app indicators still update so nothing is missed — just no pings.</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Eyebrow style="display: block; margin-bottom: 8px;">Weekdays · Mon–Fri</Eyebrow>
|
||||
<div class="grid-2">
|
||||
<EnduserFormField label="From"><input v-model="quiet.weekFrom" /></EnduserFormField>
|
||||
<EnduserFormField label="Until"><input v-model="quiet.weekTo" /></EnduserFormField>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="weekend-head">
|
||||
<Eyebrow>Weekends · Sat–Sun</Eyebrow>
|
||||
<label class="inline-check">
|
||||
<input type="checkbox" v-model="quiet.weekendDifferent" />
|
||||
different from weekdays
|
||||
</label>
|
||||
</div>
|
||||
<div v-if="quiet.weekendDifferent" class="grid-2">
|
||||
<EnduserFormField label="From"><input v-model="quiet.wkndFrom" /></EnduserFormField>
|
||||
<EnduserFormField label="Until"><input v-model="quiet.wkndTo" /></EnduserFormField>
|
||||
</div>
|
||||
<Mono v-else dim>same as weekdays · {{ quiet.weekFrom }} – {{ quiet.weekTo }}</Mono>
|
||||
</div>
|
||||
|
||||
<div class="quiet-rules">
|
||||
<div class="qr-row">
|
||||
<div>
|
||||
<div class="qr-l">Pause on public holidays</div>
|
||||
<Mono dim>silence all day on Danish public holidays</Mono>
|
||||
</div>
|
||||
<EnduserToggle v-model="quiet.pauseHolidays" />
|
||||
</div>
|
||||
<div class="qr-row">
|
||||
<div>
|
||||
<div class="qr-l">Always allow your manager</div>
|
||||
<Mono dim>Mikkel Nørgaard can still reach you during quiet hours</Mono>
|
||||
</div>
|
||||
<EnduserToggle v-model="quiet.allowManager" />
|
||||
</div>
|
||||
<div class="qr-row">
|
||||
<div>
|
||||
<div class="qr-l">Override for P1 incidents</div>
|
||||
<Mono dim>always notify for on-call pages and security alerts</Mono>
|
||||
</div>
|
||||
<EnduserToggle :model-value="true" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Mono dim style="display: block; text-align: center;">shown on your profile card so teammates know when not to expect you</Mono>
|
||||
</div>
|
||||
<template #footer>
|
||||
<UiButton variant="ghost" @click="quietOpen = false">Cancel</UiButton>
|
||||
<UiButton variant="primary" @click="quietOpen = false; toast.ok('Quiet hours saved')">
|
||||
<template #leading><UiIcon name="check" :size="13" /></template>
|
||||
Save quiet hours
|
||||
</UiButton>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
<!-- Add signature override modal -->
|
||||
<Modal :open="overrideOpen" eyebrow="Email signature · override" title="New signature override" size="md" @close="overrideOpen = false">
|
||||
<div class="override-stack">
|
||||
<EnduserFormField label="Override name">
|
||||
<input v-model="override.name" placeholder="e.g. External communications" />
|
||||
</EnduserFormField>
|
||||
|
||||
<div>
|
||||
<Eyebrow style="display: block; margin-bottom: 8px;">Applies to</Eyebrow>
|
||||
<div class="scope-grid">
|
||||
<button
|
||||
v-for="o in [
|
||||
{ v: 'domain' as const, l: 'Sending domain', d: 'e.g. mail from @baslund.dk' },
|
||||
{ v: 'recipient' as const, l: 'Recipients', d: 'mail to specific domains' },
|
||||
{ v: 'category' as const, l: 'Category', d: 'replies / forwards / OOO' },
|
||||
]"
|
||||
:key="o.v"
|
||||
class="scope-card"
|
||||
:class="{ active: override.scope === o.v }"
|
||||
@click="override.scope = o.v"
|
||||
>
|
||||
<div class="sc-l">{{ o.l }}</div>
|
||||
<Mono dim>{{ o.d }}</Mono>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<EnduserFormField v-if="override.scope === 'domain'" label="Send from">
|
||||
<div class="domain-input">
|
||||
<span>*@</span>
|
||||
<input v-model="override.domain" />
|
||||
</div>
|
||||
</EnduserFormField>
|
||||
<EnduserFormField v-else-if="override.scope === 'recipient'" label="When sending to">
|
||||
<input value="*.dk, *.no, *.se" placeholder="comma-separated domains" />
|
||||
</EnduserFormField>
|
||||
<EnduserFormField v-else label="On message type">
|
||||
<input value="Replies and forwards" />
|
||||
</EnduserFormField>
|
||||
|
||||
<EnduserFormField label="Signature">
|
||||
<textarea v-model="override.body" rows="6" />
|
||||
<Mono dim style="display: block; margin-top: 6px;">plain text · supports the same {{ MERGE_TAG_LITERAL }} variables as your default signature</Mono>
|
||||
</EnduserFormField>
|
||||
</div>
|
||||
<template #footer>
|
||||
<UiButton variant="ghost" @click="overrideOpen = false">Cancel</UiButton>
|
||||
<UiButton variant="primary" @click="overrideOpen = false; toast.ok('Override saved'); markDirty()">
|
||||
<template #leading><UiIcon name="check" :size="13" /></template>
|
||||
Save override
|
||||
</UiButton>
|
||||
</template>
|
||||
</Modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.tabs-wrap { padding: 0 40px; margin-top: 16px; }
|
||||
.content { padding: 20px 40px 96px 40px; max-width: 1100px; }
|
||||
|
||||
.stack { display: flex; flex-direction: column; gap: 16px; max-width: 720px; }
|
||||
|
||||
/* Card header */
|
||||
.card-header { margin-bottom: 16px; }
|
||||
.card-header.with-actions { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; }
|
||||
.card-header .head-text { min-width: 0; flex: 1; }
|
||||
.card-header h2 {
|
||||
font-family: var(--font-display); font-weight: 600;
|
||||
font-size: 17px; letter-spacing: -0.015em;
|
||||
margin: 6px 0 0 0;
|
||||
}
|
||||
.card-header p { margin: 8px 0 0 0; font-size: 13px; color: var(--text-mute); line-height: 1.5; max-width: 600px; }
|
||||
|
||||
/* Profile tab grid */
|
||||
.profile-grid { display: grid; grid-template-columns: 320px 1fr; gap: 24px; }
|
||||
.photo { display: flex; flex-direction: column; align-items: center; gap: 14px; padding: 8px; }
|
||||
.photo-meta { text-align: center; margin-top: 6px; }
|
||||
.display { font-family: var(--font-display); font-weight: 600; font-size: 18px; }
|
||||
.photo-meta :deep(.mono) { display: block; margin-top: 4px; }
|
||||
|
||||
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||||
|
||||
/* Connections */
|
||||
.conn, .tokens { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 8px; }
|
||||
.conn li, .tokens li {
|
||||
display: flex; align-items: center; gap: 14px;
|
||||
padding: 14px;
|
||||
background: var(--surface); border: 1px solid var(--border); border-radius: 6px;
|
||||
}
|
||||
.tokens li { padding: 12px; gap: 12px; }
|
||||
.conn-tile {
|
||||
width: 36px; height: 36px; border-radius: 8px;
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
color: #fff; font-family: var(--font-mono); font-weight: 700; font-size: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.conn-text { flex: 1; min-width: 0; }
|
||||
.conn-name { display: flex; align-items: center; gap: 8px; font-size: 14px; font-weight: 500; }
|
||||
.conn-text :deep(.mono) { display: block; margin-top: 2px; }
|
||||
.conn-actions { display: flex; gap: 4px; }
|
||||
|
||||
/* OOO */
|
||||
.ooo { display: flex; flex-direction: column; gap: 14px; transition: opacity 0.2s; }
|
||||
.ooo[data-on='false'] { opacity: 0.5; pointer-events: none; }
|
||||
.ooo textarea {
|
||||
width: 100%; min-height: 120px; padding: 12px;
|
||||
background: var(--surface); border: 1px solid var(--border); border-radius: 6px;
|
||||
font-size: 13px; color: var(--text); font-family: inherit; resize: vertical; line-height: 1.55;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.redirect-row {
|
||||
padding: 12px; background: var(--bg); border: 1px solid var(--border); border-radius: 6px;
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 16px;
|
||||
}
|
||||
.rr-l { font-size: 13px; font-weight: 500; }
|
||||
|
||||
/* Appearance */
|
||||
.appearance { display: flex; flex-direction: column; gap: 16px; }
|
||||
.theme-tiles { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; max-width: 460px; }
|
||||
.theme-tile {
|
||||
padding: 14px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border); border-radius: 8px;
|
||||
display: flex; flex-direction: column; gap: 10px; align-items: center;
|
||||
cursor: pointer; font-family: inherit; color: var(--text); font-size: 12px; font-weight: 500;
|
||||
}
|
||||
.theme-tile.active { border-color: var(--text); }
|
||||
.theme-swatch { width: 100%; height: 56px; border-radius: 6px; border: 1px solid var(--border); }
|
||||
|
||||
.density {
|
||||
display: flex; gap: 0;
|
||||
border: 1px solid var(--border); border-radius: 6px; padding: 2px;
|
||||
width: fit-content;
|
||||
}
|
||||
.density button {
|
||||
padding: 6px 14px; border: none; border-radius: 4px;
|
||||
background: transparent; color: var(--text);
|
||||
font-size: 12px; font-weight: 500; cursor: pointer; font-family: inherit;
|
||||
}
|
||||
.density button.active { background: var(--text); color: var(--bg); }
|
||||
|
||||
.row-toggle { display: inline-flex; align-items: center; gap: 10px; font-size: 12px; color: var(--text-mute); }
|
||||
|
||||
/* Signature */
|
||||
.sig-grid { display: grid; grid-template-columns: 1.4fr 1fr; gap: 16px; }
|
||||
.sig-side { display: flex; flex-direction: column; gap: 12px; }
|
||||
.toolbar { display: flex; align-items: center; gap: 6px; margin-bottom: 10px; flex-wrap: wrap; }
|
||||
.tb {
|
||||
height: 30px; padding: 0 10px;
|
||||
background: var(--surface); border: 1px solid var(--border); border-radius: 5px;
|
||||
color: var(--text); cursor: pointer; font-family: var(--font-sans); font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.tb.b { font-weight: 700; }
|
||||
.tb.i { font-style: italic; }
|
||||
.tb.u { text-decoration: underline; }
|
||||
.tb.mono { font-family: var(--font-mono); font-size: 11px; }
|
||||
.tb:hover { background: var(--row-hover); }
|
||||
|
||||
.sig-editor {
|
||||
min-height: 220px; padding: 18px;
|
||||
background: var(--surface); border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
font-family: var(--font-sans); font-size: 14px; color: var(--text);
|
||||
line-height: 1.55; outline: none;
|
||||
}
|
||||
.sig-line.bold { font-weight: 600; }
|
||||
.sig-line.dim { color: var(--text-dim); }
|
||||
.sig-line.mute { margin-top: 8px; color: var(--text-mute); font-size: 13px; }
|
||||
.sig-foot {
|
||||
margin-top: 14px; padding-top: 12px;
|
||||
border-top: 1px dashed var(--border);
|
||||
color: var(--text-mute); font-size: 12px;
|
||||
}
|
||||
.sig-foot .mono { font-family: var(--font-mono); }
|
||||
|
||||
.vars {
|
||||
margin-top: 16px; padding: 14px;
|
||||
background: var(--bg); border: 1px solid var(--border); border-radius: 6px;
|
||||
font-size: 12px; color: var(--text-mute); line-height: 1.6;
|
||||
}
|
||||
|
||||
.apply-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 8px; }
|
||||
.apply-list label { display: flex; gap: 8px; align-items: center; font-size: 13px; cursor: pointer; }
|
||||
.apply-list input { accent-color: var(--text); }
|
||||
|
||||
/* Notifications matrix */
|
||||
.notif-head { padding: 16px 20px; border-bottom: 1px solid var(--border); }
|
||||
.notif-title { font-family: var(--font-display); font-weight: 600; font-size: 17px; margin-top: 4px; }
|
||||
.notif { width: 100%; border-collapse: collapse; }
|
||||
.notif thead tr { border-bottom: 1px solid var(--border); }
|
||||
.notif thead th {
|
||||
padding: 10px 14px;
|
||||
text-align: center;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-mute);
|
||||
font-weight: 500;
|
||||
width: 110px;
|
||||
}
|
||||
.notif thead th.lhs, .notif tbody td.lhs { text-align: left; padding-left: 20px; width: auto; letter-spacing: 0.12em; }
|
||||
.notif tbody tr { border-bottom: 1px solid var(--border); }
|
||||
.notif tbody tr:last-child { border-bottom: none; }
|
||||
.notif tbody td { padding: 12px 14px; text-align: center; font-size: 13px; }
|
||||
.notif tbody td.lhs { padding-left: 20px; padding-right: 14px; font-weight: 500; }
|
||||
.notif input[type='checkbox'] { width: 16px; height: 16px; accent-color: var(--text); }
|
||||
|
||||
.notif-foot {
|
||||
padding: 14px 20px;
|
||||
background: var(--bg);
|
||||
border-top: 1px solid var(--border);
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
}
|
||||
|
||||
/* Modal · photo upload */
|
||||
.photo-modal { display: flex; flex-direction: column; gap: 16px; }
|
||||
.photo-upload {
|
||||
padding: 48px 24px;
|
||||
background: var(--bg); border: 2px dashed var(--border-hi); border-radius: 10px;
|
||||
display: flex; flex-direction: column; align-items: center; gap: 14px;
|
||||
cursor: pointer; text-align: center;
|
||||
font-family: inherit;
|
||||
transition: background 0.12s, border-color 0.12s;
|
||||
}
|
||||
.photo-upload:hover { background: var(--row-hover); border-color: var(--text); }
|
||||
.pu-l { font-size: 14px; font-weight: 500; color: var(--text); }
|
||||
.pu-text { text-align: center; }
|
||||
|
||||
.crop-row { display: flex; gap: 20px; align-items: flex-start; }
|
||||
.crop-main { flex: 1; }
|
||||
.crop-stage {
|
||||
position: relative; width: 100%; aspect-ratio: 1 / 1;
|
||||
background: linear-gradient(135deg, #D4FF3A, #5B8C5A);
|
||||
border-radius: 8px; overflow: hidden;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.crop-mask {
|
||||
position: absolute; inset: 20%;
|
||||
border: 2px solid #fff; border-radius: 50%;
|
||||
box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
.crop-letter {
|
||||
color: #fff; font-family: var(--font-display); font-weight: 700; font-size: 80px;
|
||||
}
|
||||
.crop-side { width: 140px; display: flex; flex-direction: column; gap: 10px; align-items: center; }
|
||||
.circle {
|
||||
background: linear-gradient(135deg, #D4FF3A, #5B8C5A);
|
||||
border-radius: 999px; overflow: hidden;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.circle span { color: #fff; font-family: var(--font-display); font-weight: 700; }
|
||||
.circle.large { width: 88px; height: 88px; }
|
||||
.circle.large span { font-size: 36px; }
|
||||
.circle.small { width: 32px; height: 32px; }
|
||||
.circle.small span { font-size: 14px; }
|
||||
|
||||
.def-block { padding: 12px; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; font-size: 12px; color: var(--text-mute); line-height: 1.55; }
|
||||
.def-body { margin-top: 6px; color: var(--text-dim); }
|
||||
|
||||
/* Quiet hours modal */
|
||||
.quiet-stack { display: flex; flex-direction: column; gap: 16px; }
|
||||
.callout-info {
|
||||
padding: 12px;
|
||||
background: var(--bg); border: 1px solid var(--border); border-radius: 6px;
|
||||
display: flex; gap: 10px; align-items: flex-start;
|
||||
font-size: 12px; color: var(--text-dim); line-height: 1.55;
|
||||
}
|
||||
.callout-info :deep(svg) { color: var(--text-mute); margin-top: 2px; flex-shrink: 0; }
|
||||
.weekend-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }
|
||||
.inline-check { display: inline-flex; align-items: center; gap: 8px; cursor: pointer; font-size: 12px; color: var(--text-mute); }
|
||||
.inline-check input { accent-color: var(--text); }
|
||||
|
||||
.quiet-rules { padding: 12px; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; display: flex; flex-direction: column; gap: 12px; }
|
||||
.qr-row { display: flex; align-items: center; justify-content: space-between; gap: 14px; }
|
||||
.qr-row + .qr-row { border-top: 1px solid var(--border); padding-top: 12px; }
|
||||
.qr-l { font-size: 13px; font-weight: 500; }
|
||||
|
||||
/* Override modal */
|
||||
.override-stack { display: flex; flex-direction: column; gap: 14px; }
|
||||
.scope-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
|
||||
.scope-card {
|
||||
padding: 12px; border-radius: 6px;
|
||||
background: var(--surface); border: 1px solid var(--border);
|
||||
cursor: pointer; font-family: inherit; color: var(--text); text-align: left;
|
||||
}
|
||||
.scope-card.active { border-color: var(--text); background: var(--bg); }
|
||||
.sc-l { font-size: 13px; font-weight: 500; }
|
||||
.scope-card :deep(.mono) { display: block; margin-top: 4px; }
|
||||
|
||||
.domain-input {
|
||||
display: flex; align-items: center; gap: 0;
|
||||
background: var(--surface); border: 1px solid var(--border); border-radius: 6px;
|
||||
padding: 0 12px; height: 36px;
|
||||
}
|
||||
.domain-input span { font-family: var(--font-mono); font-size: 12px; color: var(--text-mute); }
|
||||
.domain-input input {
|
||||
flex: 1; border: none; outline: none; background: transparent;
|
||||
font-size: 13px; color: var(--text); font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.override-stack textarea {
|
||||
width: 100%; min-height: 140px; padding: 12px;
|
||||
background: var(--surface); border: 1px solid var(--border); border-radius: 6px;
|
||||
font-size: 13px; color: var(--text); font-family: var(--font-sans); resize: vertical; line-height: 1.55;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,947 @@
|
||||
<script setup lang="ts">
|
||||
// Security · password / two-factor / recovery codes / sign-in history.
|
||||
// Faithfully ports project/platform-enduser.jsx `SecurityEndUserScreen`
|
||||
// (lines 352–971) and its sub-modals.
|
||||
|
||||
|
||||
import { mfaMethods, recoveryCodes, signInHistory } from '~/data/enduser'
|
||||
|
||||
const toast = useToast()
|
||||
const tab = ref('password')
|
||||
|
||||
// --- Password ---
|
||||
const pwd = reactive({ current: '••••••••••••', next: 'northern-coffee-bridge-april', confirm: 'northern-coffee-bridge-april' })
|
||||
const showPwd = reactive({ current: false, next: false, confirm: false })
|
||||
const savedFlash = ref(false)
|
||||
const strength = computed(() => {
|
||||
const v = pwd.next
|
||||
if (!v) return 0
|
||||
let s = Math.min(100, v.length * 8)
|
||||
if (/[A-Z]/.test(v)) s += 8
|
||||
if (/[0-9]/.test(v)) s += 8
|
||||
if (/[^A-Za-z0-9]/.test(v)) s += 10
|
||||
return Math.min(100, s)
|
||||
})
|
||||
const strengthLabel = computed(() => {
|
||||
const s = strength.value
|
||||
if (s > 80) return 'excellent'
|
||||
if (s > 60) return 'strong'
|
||||
if (s > 30) return 'fair'
|
||||
return 'weak'
|
||||
})
|
||||
const strengthColor = computed(() => {
|
||||
const s = strength.value
|
||||
if (s > 60) return 'var(--ok)'
|
||||
if (s > 30) return 'var(--warn)'
|
||||
return 'var(--bad)'
|
||||
})
|
||||
|
||||
// Criteria list matches source's 4 fixed entries (all marked passing).
|
||||
const criteria = [
|
||||
{ id: 'len', label: 'At least 14 characters', ok: true },
|
||||
{ id: 'mix', label: 'Mix of letters, numbers, or words', ok: true },
|
||||
{ id: 'breach', label: 'Not in known breach lists', ok: true },
|
||||
{ id: 'rotate', label: 'Different from your last 5 passwords', ok: true },
|
||||
]
|
||||
|
||||
const canSubmitPwd = computed(() => !!pwd.next && pwd.next === pwd.confirm)
|
||||
|
||||
function submitPwd() {
|
||||
if (!canSubmitPwd.value) return
|
||||
savedFlash.value = true
|
||||
setTimeout(() => (savedFlash.value = false), 3000)
|
||||
}
|
||||
function resetPwd() {
|
||||
pwd.next = 'northern-coffee-bridge-april'
|
||||
pwd.confirm = 'northern-coffee-bridge-april'
|
||||
savedFlash.value = false
|
||||
}
|
||||
|
||||
const backupEmail = ref('anne@gmail.com')
|
||||
const verifyOpen = ref(false)
|
||||
const verifyCode = ref('')
|
||||
|
||||
// --- MFA ---
|
||||
const renameMfa = ref<typeof mfaMethods[number] | null>(null)
|
||||
const renameMfaValue = ref('')
|
||||
const removeMfa = ref<typeof mfaMethods[number] | null>(null)
|
||||
const mfaSetup = ref<'webauthn' | 'totp' | null>(null)
|
||||
const mfaSetupStep = ref(1)
|
||||
|
||||
function openSetup(kind: 'webauthn' | 'totp') {
|
||||
mfaSetup.value = kind
|
||||
mfaSetupStep.value = 1
|
||||
}
|
||||
|
||||
const mfaMenuFor = ref<string | null>(null)
|
||||
const mfaMenuPos = ref({ top: 0, right: 0 })
|
||||
const mfaTriggerRefs = ref<Record<string, HTMLElement | null>>({})
|
||||
|
||||
function openMfaMenu(id: string, e: MouseEvent) {
|
||||
e.stopPropagation()
|
||||
const btn = mfaTriggerRefs.value[id]
|
||||
if (btn) {
|
||||
const r = btn.getBoundingClientRect()
|
||||
mfaMenuPos.value = { top: r.bottom + 4, right: window.innerWidth - r.right }
|
||||
}
|
||||
mfaMenuFor.value = mfaMenuFor.value === id ? null : id
|
||||
}
|
||||
function setMfaTriggerRef(id: string, el: any) {
|
||||
mfaTriggerRefs.value[id] = el as HTMLElement | null
|
||||
}
|
||||
onMounted(() => {
|
||||
const close = () => (mfaMenuFor.value = null)
|
||||
const onScroll = () => (mfaMenuFor.value = null)
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') mfaMenuFor.value = null }
|
||||
document.addEventListener('mousedown', close)
|
||||
document.addEventListener('keydown', onKey)
|
||||
window.addEventListener('scroll', onScroll, true)
|
||||
window.addEventListener('resize', onScroll)
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('mousedown', close)
|
||||
document.removeEventListener('keydown', onKey)
|
||||
window.removeEventListener('scroll', onScroll, true)
|
||||
window.removeEventListener('resize', onScroll)
|
||||
})
|
||||
})
|
||||
|
||||
// --- Recovery codes ---
|
||||
const showCodesOpen = ref(false)
|
||||
const regenOpen = ref(false)
|
||||
const regenAck = ref(false)
|
||||
const copyFlash = ref(false)
|
||||
const downloadFlash = ref(false)
|
||||
|
||||
// Source marks first 2 codes as "used" via opacity/strikethrough.
|
||||
const usedCount = 2
|
||||
|
||||
function copyCodes() {
|
||||
const text = recoveryCodes
|
||||
.map((c, i) => `${String(i + 1).padStart(2, '0')} ${c}`)
|
||||
.join('\n')
|
||||
if (navigator.clipboard) navigator.clipboard.writeText(text).catch(() => {})
|
||||
copyFlash.value = true
|
||||
setTimeout(() => (copyFlash.value = false), 1800)
|
||||
}
|
||||
function downloadCodes() {
|
||||
downloadFlash.value = true
|
||||
setTimeout(() => (downloadFlash.value = false), 1800)
|
||||
}
|
||||
|
||||
// --- Sign-in history ---
|
||||
const exportOpen = ref(false)
|
||||
const exportPeriod = ref<'30d' | '90d' | '12mo' | 'all'>('90d')
|
||||
const exportFormat = ref<'csv' | 'json'>('csv')
|
||||
const exportIncludeFailed = ref(true)
|
||||
const exportDelivery = ref<'download' | 'email'>('download')
|
||||
|
||||
const exportRowEst = computed(() => {
|
||||
if (exportPeriod.value === '30d') return 14
|
||||
if (exportPeriod.value === '90d') return 42
|
||||
if (exportPeriod.value === '12mo') return 186
|
||||
return 312
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<PageHeader
|
||||
eyebrow="Account"
|
||||
title="Security"
|
||||
subtitle="Password, multi-factor methods, recovery codes, and a complete history of sign-ins on your account."
|
||||
/>
|
||||
|
||||
<div class="tabs-wrap">
|
||||
<Tabs
|
||||
v-model="tab"
|
||||
:items="[
|
||||
{ value: 'password', label: 'Password' },
|
||||
{ value: 'mfa', label: 'Two-factor', count: mfaMethods.length },
|
||||
{ value: 'recovery', label: 'Recovery codes' },
|
||||
{ value: 'history', label: 'Sign-in history', count: signInHistory.length },
|
||||
]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<!-- Password tab -->
|
||||
<section v-if="tab === 'password'" class="stack">
|
||||
<Card>
|
||||
<header class="card-header">
|
||||
<Eyebrow>Change password</Eyebrow>
|
||||
<h2>Choose a strong password</h2>
|
||||
<p>At least 14 characters · we recommend a passphrase like 'wide-lemon-tunnel-corn' or use your password manager.</p>
|
||||
</header>
|
||||
<div class="pwd-form">
|
||||
<EnduserFormField label="Current password">
|
||||
<div class="pwd-input">
|
||||
<input :type="showPwd.current ? 'text' : 'password'" v-model="pwd.current" />
|
||||
<button type="button" class="eye" @click="showPwd.current = !showPwd.current">
|
||||
<UiIcon name="key" :size="14" />
|
||||
</button>
|
||||
</div>
|
||||
</EnduserFormField>
|
||||
<EnduserFormField label="New password">
|
||||
<div class="pwd-input">
|
||||
<input :type="showPwd.next ? 'text' : 'password'" v-model="pwd.next" />
|
||||
<button type="button" class="eye" @click="showPwd.next = !showPwd.next">
|
||||
<UiIcon :name="showPwd.next ? 'x' : 'key'" :size="14" />
|
||||
</button>
|
||||
</div>
|
||||
</EnduserFormField>
|
||||
<div class="strength">
|
||||
<div class="strength-head">
|
||||
<Mono dim>STRENGTH</Mono>
|
||||
<Mono :style="{ color: strengthColor }">{{ strengthLabel }}</Mono>
|
||||
</div>
|
||||
<div class="bar"><span :style="{ width: strength + '%', background: strengthColor }" /></div>
|
||||
<ul class="criteria">
|
||||
<li v-for="c in criteria" :key="c.id" :data-ok="c.ok">
|
||||
<UiIcon :name="c.ok ? 'check' : 'x'" :size="11" :stroke-width="2.4" />
|
||||
<span>{{ c.label }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<EnduserFormField label="Confirm new password">
|
||||
<div class="pwd-input">
|
||||
<input :type="showPwd.confirm ? 'text' : 'password'" v-model="pwd.confirm" />
|
||||
<button type="button" class="eye" @click="showPwd.confirm = !showPwd.confirm">
|
||||
<UiIcon :name="showPwd.confirm ? 'x' : 'key'" :size="14" />
|
||||
</button>
|
||||
</div>
|
||||
</EnduserFormField>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<span v-if="savedFlash" class="flash-ok">
|
||||
<UiIcon name="check" :size="13" :stroke-width="2.5" />
|
||||
Password updated · confirmation email sent
|
||||
</span>
|
||||
<div style="flex: 1;" />
|
||||
<UiButton variant="ghost" @click="resetPwd">Cancel</UiButton>
|
||||
<UiButton variant="primary" :disabled="!canSubmitPwd" @click="submitPwd">
|
||||
<template #leading><UiIcon name="check" :size="13" /></template>
|
||||
Update password
|
||||
</UiButton>
|
||||
</div>
|
||||
<p class="footer-note">
|
||||
<Mono>// after you change</Mono> we'll sign you out everywhere except this device. You'll get an email confirmation. If you didn't make this change, contact your admin immediately.
|
||||
</p>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<header class="card-header with-actions">
|
||||
<div class="head-text">
|
||||
<Eyebrow>Account recovery</Eyebrow>
|
||||
<h2>Backup recovery email</h2>
|
||||
<p>If you lose access to dezky, we can send a recovery link to this address. Use a personal address, not a work one.</p>
|
||||
</div>
|
||||
<Badge tone="ok" dot>verified</Badge>
|
||||
</header>
|
||||
<div class="recovery">
|
||||
<div class="email-input">
|
||||
<UiIcon name="mail" :size="14" stroke="var(--text-mute)" />
|
||||
<input v-model="backupEmail" />
|
||||
</div>
|
||||
<UiButton size="sm" variant="secondary" @click="verifyOpen = true">Verify new address</UiButton>
|
||||
</div>
|
||||
<div class="callout-info">
|
||||
<UiIcon name="shield" :size="14" />
|
||||
<span>We'll never use this address for marketing or workspace announcements — only account recovery and security alerts. Verified <Mono>14 Jan 2026</Mono> · last reachability check <Mono>2 days ago</Mono>.</span>
|
||||
</div>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
<!-- MFA tab -->
|
||||
<section v-else-if="tab === 'mfa'" class="stack">
|
||||
<Card>
|
||||
<div class="mfa-on">
|
||||
<span class="mfa-shield"><UiIcon name="shield" :size="20" /></span>
|
||||
<div>
|
||||
<h3>Two-factor is on</h3>
|
||||
<p>You have <b>{{ mfaMethods.length }} methods</b> set up · workspace requires MFA for admins.</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card :pad="0">
|
||||
<header class="mfa-head">
|
||||
<Eyebrow>Active methods</Eyebrow>
|
||||
<Mono dim>{{ mfaMethods.length }}</Mono>
|
||||
</header>
|
||||
<ul class="mfa-list">
|
||||
<li v-for="m in mfaMethods" :key="m.id">
|
||||
<span class="mfa-icon">
|
||||
<UiIcon :name="m.kind === 'webauthn' ? 'key' : 'device'" :size="16" />
|
||||
</span>
|
||||
<div class="mfa-text">
|
||||
<div class="mfa-row">
|
||||
<span class="mfa-name">{{ m.label }}</span>
|
||||
<Badge v-if="m.primary" tone="invert">primary</Badge>
|
||||
<Badge tone="neutral">{{ m.kind === 'webauthn' ? 'hardware key' : 'authenticator app' }}</Badge>
|
||||
</div>
|
||||
<Mono dim>added {{ m.enrolledOn }} · last used {{ m.lastUsed }}</Mono>
|
||||
</div>
|
||||
<span :ref="(el) => setMfaTriggerRef(m.id, el)" class="mfa-trigger">
|
||||
<UiButton size="sm" variant="ghost" @click="openMfaMenu(m.id, $event)">
|
||||
<UiIcon name="more" :size="14" />
|
||||
</UiButton>
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="mfa-add">
|
||||
<UiButton variant="secondary" @click="openSetup('webauthn')">
|
||||
<template #leading><UiIcon name="key" :size="13" /></template>
|
||||
Add security key (WebAuthn)
|
||||
</UiButton>
|
||||
<UiButton variant="secondary" @click="openSetup('totp')">
|
||||
<template #leading><UiIcon name="device" :size="13" /></template>
|
||||
Add authenticator app (TOTP)
|
||||
</UiButton>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<!-- Portaled MFA method action menus -->
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="mfaMenuFor"
|
||||
class="mfa-menu"
|
||||
:style="{ top: mfaMenuPos.top + 'px', right: mfaMenuPos.right + 'px' }"
|
||||
@mousedown.stop
|
||||
>
|
||||
<template v-for="m in mfaMethods" :key="m.id">
|
||||
<template v-if="m.id === mfaMenuFor">
|
||||
<button @click="renameMfa = m; renameMfaValue = m.label; mfaMenuFor = null">
|
||||
<UiIcon name="brush" :size="14" />
|
||||
<span>Rename method</span>
|
||||
</button>
|
||||
<button :disabled="m.primary" @click="!m.primary && toast.ok(`${m.label} is now primary`); mfaMenuFor = null">
|
||||
<UiIcon name="shield" :size="14" />
|
||||
<span>{{ m.primary ? 'Already primary' : 'Set as primary' }}</span>
|
||||
</button>
|
||||
<button @click="toast.info(`Testing ${m.label}`); mfaMenuFor = null">
|
||||
<UiIcon name="refresh" :size="14" />
|
||||
<span>Test this method</span>
|
||||
</button>
|
||||
<span class="sep" />
|
||||
<button class="danger" :disabled="m.primary" @click="!m.primary && (removeMfa = m); mfaMenuFor = null">
|
||||
<UiIcon name="trash" :size="14" />
|
||||
<span>{{ m.primary ? 'Remove · pick a new primary first' : 'Remove method' }}</span>
|
||||
</button>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</Teleport>
|
||||
</section>
|
||||
|
||||
<!-- Recovery codes tab -->
|
||||
<section v-else-if="tab === 'recovery'" class="stack">
|
||||
<Card>
|
||||
<header class="card-header">
|
||||
<Eyebrow>Recovery codes</Eyebrow>
|
||||
<h2>One-time codes for when you lose access</h2>
|
||||
<p>If you lose your phone and security key, these codes get you back in. Treat them like passwords — store offline.</p>
|
||||
</header>
|
||||
<div class="codes-summary">
|
||||
<span class="codes-tile"><UiIcon name="shield" :size="18" /></span>
|
||||
<div class="codes-meta">
|
||||
<div class="codes-title">{{ recoveryCodes.length }} codes generated · {{ recoveryCodes.length - usedCount }} unused</div>
|
||||
<Mono dim>last regenerated · 14 Jan 2026 · {{ usedCount }} used</Mono>
|
||||
</div>
|
||||
<UiButton variant="secondary" @click="showCodesOpen = true">
|
||||
<template #leading><UiIcon name="key" :size="13" /></template>
|
||||
View codes
|
||||
</UiButton>
|
||||
</div>
|
||||
<div class="callout-warn">
|
||||
<UiIcon name="bell" :size="14" />
|
||||
<div class="cw-body">
|
||||
<b>Keep these somewhere safe</b> — printed, password manager, or offline note. Each code works once. If you suspect they've been seen by someone else, regenerate immediately.
|
||||
</div>
|
||||
<UiButton size="sm" variant="ghost" @click="regenOpen = true; regenAck = false">Regenerate</UiButton>
|
||||
</div>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
<!-- Sign-in history tab -->
|
||||
<section v-else-if="tab === 'history'" class="stack">
|
||||
<div class="hist-bar">
|
||||
<div class="hist-intro">
|
||||
Every sign-in attempt on your account, successful or not. If you see something you don't recognize, change your password and revoke unknown sessions.
|
||||
</div>
|
||||
<UiButton variant="secondary" @click="exportOpen = true">
|
||||
<template #leading><UiIcon name="download" :size="13" /></template>
|
||||
Export
|
||||
</UiButton>
|
||||
</div>
|
||||
|
||||
<Card :pad="0">
|
||||
<table class="history">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Time</th>
|
||||
<th>IP</th>
|
||||
<th>Location</th>
|
||||
<th>Client</th>
|
||||
<th>Method</th>
|
||||
<th>Result</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="r in signInHistory" :key="r.id">
|
||||
<td><Mono>{{ r.when }}</Mono></td>
|
||||
<td><Mono>{{ r.ip }}</Mono></td>
|
||||
<td><span :class="{ unknown: r.location === 'Unknown' }">{{ r.location }}</span></td>
|
||||
<td><Mono dim>{{ r.ua }}</Mono></td>
|
||||
<td><Mono>{{ r.method }}</Mono></td>
|
||||
<td>
|
||||
<Badge :tone="r.result === 'ok' ? 'ok' : 'bad'" dot>
|
||||
{{ r.result === 'ok' ? 'success' : 'failed' }}
|
||||
</Badge>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</Card>
|
||||
|
||||
<div class="callout-bad-strong">
|
||||
<UiIcon name="shield" :size="14" />
|
||||
<div class="cb-text">
|
||||
<div class="cb-title">3 failed attempts from 203.0.113.4 yesterday at 18:02</div>
|
||||
<Mono dim>Unknown location. Your password is still safe, but consider rotating it.</Mono>
|
||||
</div>
|
||||
<UiButton size="sm" variant="primary" @click="tab = 'password'">Change password</UiButton>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- Verify backup email modal -->
|
||||
<Modal :open="verifyOpen" eyebrow="Account recovery" title="Verify backup email" size="sm" @close="verifyOpen = false">
|
||||
<p class="modal-body">
|
||||
We sent a 6-digit code to <Mono>{{ backupEmail }}</Mono>. Paste it below to confirm you control the inbox.
|
||||
</p>
|
||||
<EnduserFormField label="Verification code">
|
||||
<input v-model="verifyCode" maxlength="6" placeholder="6-digit code" />
|
||||
</EnduserFormField>
|
||||
<Mono dim style="display: block; text-align: center; margin-top: 12px;">
|
||||
didn't get it? <a href="#" @click.prevent="toast.info('Code resent')">resend</a> · expires in 10 min
|
||||
</Mono>
|
||||
<template #footer>
|
||||
<UiButton variant="ghost" @click="verifyOpen = false">Cancel</UiButton>
|
||||
<UiButton variant="primary" :disabled="verifyCode.length < 6" @click="verifyOpen = false; toast.ok('Backup email verified')">
|
||||
<template #leading><UiIcon name="check" :size="13" /></template>
|
||||
Verify
|
||||
</UiButton>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
<!-- Rename MFA modal -->
|
||||
<Modal :open="renameMfa !== null" eyebrow="Two-factor · rename" :title="renameMfa ? `Rename ${renameMfa.label}` : ''" size="sm" @close="renameMfa = null">
|
||||
<EnduserFormField label="Method name">
|
||||
<input v-model="renameMfaValue" placeholder="e.g. YubiKey 5C · work laptop" />
|
||||
</EnduserFormField>
|
||||
<Mono dim style="display: block; margin-top: 12px;">helps you tell methods apart when signing in or removing one</Mono>
|
||||
<template #footer>
|
||||
<UiButton variant="ghost" @click="renameMfa = null">Cancel</UiButton>
|
||||
<UiButton variant="primary" :disabled="!renameMfaValue.trim()" @click="toast.ok(`Renamed to ${renameMfaValue}`); renameMfa = null">
|
||||
<template #leading><UiIcon name="check" :size="13" /></template>
|
||||
Save name
|
||||
</UiButton>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
<!-- Remove MFA method modal (matches source DefList layout) -->
|
||||
<Modal :open="removeMfa !== null" eyebrow="Two-factor · remove" :title="removeMfa ? `Remove ${removeMfa.label}?` : ''" size="md" @close="removeMfa = null">
|
||||
<div class="modal-stack">
|
||||
<div class="callout-bad">
|
||||
<UiIcon name="shield" :size="16" />
|
||||
<div>You'll be required to re-enter your password to confirm. We'll send a confirmation email — if you didn't do this, contact your admin immediately.</div>
|
||||
</div>
|
||||
<div class="def-block">
|
||||
<dl>
|
||||
<div><dt>Method</dt><dd>{{ removeMfa?.label }}</dd></div>
|
||||
<div><dt>Kind</dt><dd>{{ removeMfa?.kind === 'webauthn' ? 'hardware security key' : 'authenticator app (TOTP)' }}</dd></div>
|
||||
<div><dt>Added</dt><dd>{{ removeMfa?.enrolledOn }}</dd></div>
|
||||
<div><dt>Last used</dt><dd>{{ removeMfa?.lastUsed }}</dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
<Mono dim>at least one MFA method must remain — workspace requires MFA for admins</Mono>
|
||||
</div>
|
||||
<template #footer>
|
||||
<UiButton variant="ghost" @click="removeMfa = null">Cancel</UiButton>
|
||||
<UiButton variant="danger" @click="toast.warn(`${removeMfa?.label} removed`); removeMfa = null">
|
||||
<template #leading><UiIcon name="trash" :size="13" /></template>
|
||||
Remove method
|
||||
</UiButton>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
<!-- MFA setup wizard -->
|
||||
<Modal :open="mfaSetup !== null" :eyebrow="`Step ${mfaSetupStep} of 3`" :title="mfaSetup === 'webauthn' ? 'Add a security key' : 'Add an authenticator app'" size="md" @close="mfaSetup = null">
|
||||
<div v-if="mfaSetupStep === 1">
|
||||
<p class="modal-body">
|
||||
<template v-if="mfaSetup === 'webauthn'">Security keys are the strongest form of MFA. Use a hardware token (YubiKey) or your laptop's built-in Touch ID / Windows Hello.</template>
|
||||
<template v-else>You'll scan a QR code with an authenticator app like 1Password, Bitwarden, Authy, or Google Authenticator.</template>
|
||||
</p>
|
||||
<EnduserFormField label="Name this method">
|
||||
<input :value="mfaSetup === 'webauthn' ? 'MacBook Pro · Touch ID' : '1Password · main vault'" />
|
||||
</EnduserFormField>
|
||||
</div>
|
||||
<div v-else-if="mfaSetupStep === 2 && mfaSetup === 'webauthn'" class="wait-key">
|
||||
<div class="key-circle"><UiIcon name="key" :size="48" :stroke-width="1.4" /></div>
|
||||
<div class="key-title">Waiting for your key…</div>
|
||||
<Mono dim style="margin-top: 8px; display: block;">// touch your YubiKey or use Touch ID</Mono>
|
||||
</div>
|
||||
<div v-else-if="mfaSetupStep === 2 && mfaSetup === 'totp'">
|
||||
<p class="modal-body">
|
||||
Scan this code with your authenticator app. Or copy the setup key and add it manually.
|
||||
</p>
|
||||
<div class="qr-setup">
|
||||
<div class="qr-big">
|
||||
<span v-for="n in 441" :key="n" :style="{ background: (n * 7919 % 100) < 48 ? '#0A0A0A' : 'transparent' }" />
|
||||
</div>
|
||||
<div class="qr-meta">
|
||||
<Mono dim>SETUP KEY</Mono>
|
||||
<div class="qr-key">JBSW Y3DP EHPK 3PXP</div>
|
||||
<UiButton size="sm" variant="ghost">
|
||||
<template #leading><UiIcon name="copy" :size="13" /></template>
|
||||
Copy key
|
||||
</UiButton>
|
||||
</div>
|
||||
</div>
|
||||
<EnduserFormField label="Enter the 6-digit code from your app">
|
||||
<input value="481" />
|
||||
</EnduserFormField>
|
||||
</div>
|
||||
<div v-else-if="mfaSetupStep === 3">
|
||||
<div class="success">
|
||||
<span class="success-tile"><UiIcon name="check" :size="22" :stroke-width="2.5" /></span>
|
||||
<div>
|
||||
<h3>{{ mfaSetup === 'webauthn' ? 'Security key added' : 'App registered' }}</h3>
|
||||
<Mono dim>verified · ready to use on next sign-in</Mono>
|
||||
</div>
|
||||
</div>
|
||||
<div class="success-note">
|
||||
<Mono>// next time you sign in</Mono><br />
|
||||
We'll ask you to use this method as your second factor. Make sure you have recovery codes saved somewhere safe in case you lose access to your authenticator.
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<UiButton variant="ghost" @click="mfaSetup = null">Cancel</UiButton>
|
||||
<div style="flex: 1;" />
|
||||
<UiButton v-if="mfaSetupStep > 1" variant="secondary" @click="mfaSetupStep--">Back</UiButton>
|
||||
<UiButton v-if="mfaSetupStep < 3" variant="primary" @click="mfaSetupStep++">Continue</UiButton>
|
||||
<UiButton v-else variant="primary" @click="toast.ok('Method added'); mfaSetup = null">
|
||||
<template #leading><UiIcon name="check" :size="13" /></template>
|
||||
Done · use this method
|
||||
</UiButton>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
<!-- Recovery codes modal -->
|
||||
<Modal :open="showCodesOpen" eyebrow="One-time use · keep safe" title="Your recovery codes" size="md" @close="showCodesOpen = false">
|
||||
<p class="modal-body">
|
||||
Save these somewhere offline. Each code works <b>once</b>. We'll show you any codes you haven't used yet.
|
||||
</p>
|
||||
<div class="codes-grid">
|
||||
<div
|
||||
v-for="(c, i) in recoveryCodes"
|
||||
:key="c"
|
||||
class="code"
|
||||
:class="{ used: i < usedCount }"
|
||||
>
|
||||
<span class="code-idx">{{ i + 1 < 10 ? '0' + (i + 1) : i + 1 }}</span>
|
||||
<span class="code-val">{{ c }}</span>
|
||||
<Mono v-if="i < usedCount" dim>used</Mono>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<UiButton variant="ghost" @click="showCodesOpen = false">Close</UiButton>
|
||||
<div style="flex: 1;" />
|
||||
<UiButton variant="secondary" @click="copyCodes">
|
||||
<template #leading><UiIcon :name="copyFlash ? 'check' : 'copy'" :size="13" /></template>
|
||||
{{ copyFlash ? 'Copied · paste somewhere safe' : 'Copy all' }}
|
||||
</UiButton>
|
||||
<UiButton variant="primary" @click="downloadCodes">
|
||||
<template #leading><UiIcon :name="downloadFlash ? 'check' : 'download'" :size="13" /></template>
|
||||
{{ downloadFlash ? 'Downloaded' : 'Download .txt' }}
|
||||
</UiButton>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
<!-- Regenerate recovery codes modal -->
|
||||
<Modal :open="regenOpen" eyebrow="Destructive · invalidates existing codes" title="Regenerate recovery codes?" size="md" @close="regenOpen = false">
|
||||
<div class="modal-stack">
|
||||
<div class="callout-bad">
|
||||
<UiIcon name="shield" :size="16" />
|
||||
<div>Your <b>10 existing codes</b> ({{ recoveryCodes.length - usedCount }} still unused) will be invalidated immediately. Anyone holding a printed copy will no longer be able to use them.</div>
|
||||
</div>
|
||||
<div class="def-block">
|
||||
<Mono dim>// when to do this</Mono>
|
||||
<div class="def-body">You're seeing this option to <b>regenerate</b> because either you used a code recently, or you suspect someone else has seen your saved codes. If neither, you can keep the existing ones.</div>
|
||||
</div>
|
||||
<label class="check-row">
|
||||
<input type="checkbox" v-model="regenAck" />
|
||||
I understand the old codes will stop working
|
||||
</label>
|
||||
</div>
|
||||
<template #footer>
|
||||
<UiButton variant="ghost" @click="regenOpen = false">Cancel</UiButton>
|
||||
<UiButton variant="danger" :disabled="!regenAck" @click="regenOpen = false; showCodesOpen = true; toast.ok('10 new codes generated')">
|
||||
<template #leading><UiIcon name="refresh" :size="13" /></template>
|
||||
Generate 10 new codes
|
||||
</UiButton>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
<!-- Export sign-in history modal -->
|
||||
<Modal :open="exportOpen" eyebrow="Sign-in history · export" title="Export sign-in history" size="md" @close="exportOpen = false">
|
||||
<div class="modal-stack">
|
||||
<div>
|
||||
<Eyebrow style="display: block; margin-bottom: 8px;">Period</Eyebrow>
|
||||
<div class="period">
|
||||
<button
|
||||
v-for="p in [
|
||||
{ v: '30d', l: '30 days' },
|
||||
{ v: '90d', l: '90 days' },
|
||||
{ v: '12mo', l: '12 months' },
|
||||
{ v: 'all', l: 'All time' },
|
||||
]"
|
||||
:key="p.v"
|
||||
:class="{ active: exportPeriod === p.v }"
|
||||
@click="exportPeriod = p.v as typeof exportPeriod"
|
||||
>{{ p.l }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<EnduserFormField label="Format">
|
||||
<div class="seg">
|
||||
<button :class="{ active: exportFormat === 'csv' }" @click="exportFormat = 'csv'">CSV</button>
|
||||
<button :class="{ active: exportFormat === 'json' }" @click="exportFormat = 'json'">JSON</button>
|
||||
</div>
|
||||
</EnduserFormField>
|
||||
|
||||
<label class="check-row">
|
||||
<input type="checkbox" v-model="exportIncludeFailed" />
|
||||
Include failed attempts
|
||||
</label>
|
||||
|
||||
<div>
|
||||
<Eyebrow style="display: block; margin-bottom: 8px;">Delivery</Eyebrow>
|
||||
<div class="delivery">
|
||||
<button
|
||||
:class="{ active: exportDelivery === 'download' }"
|
||||
@click="exportDelivery = 'download'"
|
||||
>
|
||||
<UiIcon name="download" :size="15" />
|
||||
<div class="d-text">
|
||||
<div class="d-l">Download now</div>
|
||||
<Mono dim>browser download</Mono>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
:class="{ active: exportDelivery === 'email' }"
|
||||
@click="exportDelivery = 'email'"
|
||||
>
|
||||
<UiIcon name="mail" :size="15" />
|
||||
<div class="d-text">
|
||||
<div class="d-l">Email to me</div>
|
||||
<Mono dim>sent to anne@dezky.com</Mono>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="def-block">
|
||||
<Mono dim>// estimated</Mono>
|
||||
<div class="def-body">~{{ exportRowEst }} rows · {{ exportIncludeFailed ? 'includes failed attempts' : 'successes only' }} · {{ exportFormat.toUpperCase() }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<UiButton variant="ghost" @click="exportOpen = false">Cancel</UiButton>
|
||||
<UiButton variant="primary" @click="exportOpen = false; toast.ok(`Exported · ${exportFormat.toUpperCase()}`)">
|
||||
<template #leading><UiIcon :name="exportDelivery === 'email' ? 'mail' : 'download'" :size="13" /></template>
|
||||
{{ exportDelivery === 'email' ? 'Email export' : 'Download' }}
|
||||
</UiButton>
|
||||
</template>
|
||||
</Modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.tabs-wrap { padding: 0 40px; margin-top: 16px; }
|
||||
.content { padding: 20px 40px 64px 40px; max-width: 900px; }
|
||||
.stack { display: flex; flex-direction: column; gap: 16px; }
|
||||
|
||||
.card-header { margin-bottom: 16px; }
|
||||
.card-header.with-actions { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; }
|
||||
.card-header .head-text { min-width: 0; flex: 1; }
|
||||
.card-header h2 { font-family: var(--font-display); font-weight: 600; font-size: 17px; margin: 6px 0 0 0; letter-spacing: -0.015em; }
|
||||
.card-header p { margin: 8px 0 0 0; font-size: 13px; color: var(--text-mute); line-height: 1.55; max-width: 600px; }
|
||||
|
||||
/* Password form */
|
||||
.pwd-form { display: flex; flex-direction: column; gap: 14px; max-width: 480px; }
|
||||
.pwd-input { position: relative; }
|
||||
.pwd-input input { width: 100%; height: 36px; padding: 0 36px 0 12px; background: var(--surface); border: 1px solid var(--border); border-radius: 6px; font-size: 13px; font-family: inherit; color: var(--text); outline: none; box-sizing: border-box; }
|
||||
.pwd-input input:focus { border-color: var(--text); background: var(--bg); }
|
||||
.eye { position: absolute; right: 6px; top: 50%; transform: translateY(-50%); background: transparent; border: none; cursor: pointer; padding: 6px; color: var(--text-mute); }
|
||||
.eye:hover { color: var(--text); }
|
||||
|
||||
.strength { padding: 0; }
|
||||
.strength-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; }
|
||||
.bar { height: 4px; background: var(--border); border-radius: 999px; overflow: hidden; }
|
||||
.bar span { display: block; height: 100%; border-radius: 999px; transition: width 0.18s, background 0.18s; }
|
||||
.criteria { list-style: none; padding: 0; margin: 10px 0 0 0; display: flex; flex-direction: column; gap: 6px; }
|
||||
.criteria li { display: flex; align-items: center; gap: 8px; font-size: 12px; color: var(--text-mute); }
|
||||
.criteria li[data-ok='true'] :deep(svg) { color: var(--ok); }
|
||||
.criteria li[data-ok='false'] :deep(svg) { color: var(--bad); opacity: 0.55; }
|
||||
|
||||
.actions { display: flex; align-items: center; gap: 8px; margin-top: 20px; padding-top: 16px; border-top: 1px solid var(--border); }
|
||||
.flash-ok { display: inline-flex; align-items: center; gap: 6px; color: var(--ok); font-size: 13px; }
|
||||
.footer-note { margin-top: 14px; font-size: 12px; color: var(--text-mute); line-height: 1.6; }
|
||||
|
||||
/* Backup recovery email */
|
||||
.recovery { display: flex; align-items: center; gap: 12px; }
|
||||
.email-input {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
height: 36px; padding: 0 12px;
|
||||
background: var(--surface); border: 1px solid var(--border); border-radius: 6px;
|
||||
flex: 1; max-width: 360px;
|
||||
}
|
||||
.email-input input {
|
||||
flex: 1; border: none; outline: none; background: transparent;
|
||||
font-size: 13px; color: var(--text); font-family: inherit;
|
||||
}
|
||||
.email-input :deep(svg) { color: var(--text-mute); }
|
||||
|
||||
/* Callouts */
|
||||
.callout-info {
|
||||
margin-top: 14px;
|
||||
padding: 12px;
|
||||
background: var(--bg); border: 1px solid var(--border); border-radius: 6px;
|
||||
font-size: 12px; color: var(--text-dim); line-height: 1.55;
|
||||
display: flex; gap: 10px; align-items: flex-start;
|
||||
}
|
||||
.callout-info :deep(svg) { color: var(--text-mute); margin-top: 2px; flex-shrink: 0; }
|
||||
.callout-bad {
|
||||
padding: 14px;
|
||||
background: rgba(226, 48, 48, 0.06);
|
||||
border: 1px solid rgba(226, 48, 48, 0.20);
|
||||
border-radius: 6px;
|
||||
display: flex; gap: 10px;
|
||||
font-size: 13px; color: var(--text-dim); line-height: 1.5;
|
||||
}
|
||||
.callout-bad :deep(svg) { color: var(--bad); margin-top: 2px; flex-shrink: 0; }
|
||||
.callout-warn {
|
||||
padding: 14px;
|
||||
background: rgba(232, 154, 31, 0.06);
|
||||
border: 1px solid rgba(232, 154, 31, 0.24);
|
||||
border-left: 3px solid var(--warn);
|
||||
border-radius: 6px;
|
||||
display: flex; gap: 12px; align-items: flex-start;
|
||||
font-size: 13px; color: var(--text-dim); line-height: 1.6;
|
||||
}
|
||||
.callout-warn :deep(svg) { color: var(--warn); margin-top: 2px; flex-shrink: 0; }
|
||||
.cw-body { flex: 1; }
|
||||
.cw-body b { color: var(--text); }
|
||||
|
||||
.callout-bad-strong {
|
||||
display: flex; align-items: flex-start; gap: 12px;
|
||||
padding: 14px;
|
||||
background: rgba(226, 48, 48, 0.04);
|
||||
border: 1px solid rgba(226, 48, 48, 0.18);
|
||||
border-left: 3px solid var(--bad);
|
||||
border-radius: 6px;
|
||||
}
|
||||
.callout-bad-strong :deep(svg) { color: var(--bad); flex-shrink: 0; margin-top: 2px; }
|
||||
.cb-text { flex: 1; font-size: 13px; }
|
||||
.cb-title { font-weight: 600; }
|
||||
.cb-text :deep(.mono) { color: var(--text-mute); margin-top: 4px; }
|
||||
|
||||
/* MFA */
|
||||
.mfa-on { display: flex; align-items: center; gap: 14px; }
|
||||
.mfa-shield { width: 44px; height: 44px; border-radius: 10px; background: rgba(31, 138, 91, 0.12); color: var(--ok); display: inline-flex; align-items: center; justify-content: center; flex-shrink: 0; }
|
||||
.mfa-on h3 { font-family: var(--font-display); font-weight: 600; font-size: 16px; margin: 0; }
|
||||
.mfa-on p { margin: 4px 0 0 0; font-size: 13px; color: var(--text-mute); }
|
||||
|
||||
.mfa-head { display: flex; align-items: center; justify-content: space-between; padding: 14px 20px; border-bottom: 1px solid var(--border); }
|
||||
.mfa-list { list-style: none; padding: 0; margin: 0; }
|
||||
.mfa-list > li {
|
||||
display: flex; align-items: center; gap: 14px;
|
||||
padding: 14px 20px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.mfa-list > li:last-child { border-bottom: none; }
|
||||
.mfa-icon {
|
||||
width: 36px; height: 36px; border-radius: 8px;
|
||||
background: var(--bg); border: 1px solid var(--border);
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
color: var(--text-dim); flex-shrink: 0;
|
||||
}
|
||||
.mfa-text { flex: 1; min-width: 0; }
|
||||
.mfa-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
.mfa-name { font-size: 13px; font-weight: 500; }
|
||||
.mfa-trigger { display: inline-flex; }
|
||||
|
||||
.mfa-add {
|
||||
padding: 16px 20px;
|
||||
background: var(--bg);
|
||||
display: flex; gap: 8px;
|
||||
}
|
||||
|
||||
/* Portaled MFA action menu */
|
||||
.mfa-menu {
|
||||
position: fixed;
|
||||
min-width: 240px;
|
||||
padding: 4px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.18);
|
||||
z-index: 100;
|
||||
}
|
||||
.mfa-menu button {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
border-radius: 5px;
|
||||
background: transparent; border: none;
|
||||
cursor: pointer;
|
||||
font-family: inherit; font-size: 13px; text-align: left;
|
||||
color: var(--text);
|
||||
}
|
||||
.mfa-menu button:hover:not(:disabled) { background: var(--row-hover); }
|
||||
.mfa-menu button:disabled { opacity: 0.5; cursor: not-allowed; color: var(--text-mute); }
|
||||
.mfa-menu .danger { color: var(--bad); }
|
||||
.mfa-menu .sep { display: block; height: 1px; background: var(--border); margin: 4px 0; }
|
||||
|
||||
/* Recovery codes */
|
||||
.codes-summary {
|
||||
display: flex; align-items: center; gap: 18px;
|
||||
padding: 16px;
|
||||
background: var(--bg); border: 1px solid var(--border); border-radius: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.codes-tile {
|
||||
width: 40px; height: 40px; border-radius: 8px;
|
||||
background: var(--surface);
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
color: var(--text-dim); flex-shrink: 0;
|
||||
}
|
||||
.codes-meta { flex: 1; }
|
||||
.codes-title { font-size: 14px; font-weight: 500; }
|
||||
|
||||
.codes-grid {
|
||||
display: grid; grid-template-columns: repeat(2, 1fr); gap: 8px;
|
||||
margin-top: 18px;
|
||||
padding: 18px;
|
||||
background: var(--bg); border: 1px solid var(--border); border-radius: 8px;
|
||||
}
|
||||
.code {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 8px 12px;
|
||||
background: var(--surface); border: 1px solid var(--border);
|
||||
border-radius: 5px;
|
||||
font-family: var(--font-mono); font-size: 14px; font-weight: 600;
|
||||
}
|
||||
.code.used { opacity: 0.35; text-decoration: line-through; }
|
||||
.code-idx { font-size: 10px; color: var(--text-mute); min-width: 16px; }
|
||||
.code-val { flex: 1; letter-spacing: 0.04em; }
|
||||
|
||||
/* History */
|
||||
.hist-bar { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
|
||||
.hist-intro { font-size: 13px; color: var(--text-mute); max-width: 540px; line-height: 1.5; }
|
||||
.history { width: 100%; border-collapse: collapse; }
|
||||
.history thead th {
|
||||
text-align: left;
|
||||
padding: 12px 22px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-mute);
|
||||
font-weight: 500;
|
||||
background: var(--bg);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.history tbody td {
|
||||
padding: 12px 22px;
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.history tbody tr:last-child td { border-bottom: none; }
|
||||
.history .unknown { color: var(--warn); }
|
||||
|
||||
/* Modal body */
|
||||
.modal-body { margin: 0 0 14px 0; font-size: 13px; line-height: 1.6; color: var(--text-dim); }
|
||||
.modal-stack { display: flex; flex-direction: column; gap: 14px; }
|
||||
.def-block { padding: 12px; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; font-size: 12px; color: var(--text-dim); line-height: 1.55; }
|
||||
.def-body { margin-top: 6px; }
|
||||
.def-block dl { margin: 0; display: flex; flex-direction: column; gap: 10px; }
|
||||
.def-block dl > div { display: flex; gap: 12px; }
|
||||
.def-block dt { font-family: var(--font-mono); font-size: 10px; text-transform: uppercase; letter-spacing: 0.12em; color: var(--text-mute); width: 110px; flex-shrink: 0; }
|
||||
.def-block dd { margin: 0; font-size: 13px; color: var(--text); }
|
||||
|
||||
/* MFA setup wizard inner */
|
||||
.wait-key { text-align: center; padding: 12px 0; }
|
||||
.key-circle {
|
||||
width: 120px; height: 120px; border-radius: 999px;
|
||||
background: rgba(212, 255, 58, 0.10);
|
||||
border: 2px solid var(--accent);
|
||||
margin: 0 auto 18px auto;
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
animation: keyPulse 1.4s ease-in-out infinite;
|
||||
}
|
||||
.key-title { font-family: var(--font-display); font-weight: 600; font-size: 18px; }
|
||||
@keyframes keyPulse { 0%, 100% { transform: scale(1); opacity: 1; } 50% { transform: scale(1.04); opacity: 0.85; } }
|
||||
|
||||
.qr-setup { display: flex; gap: 18px; align-items: center; padding: 20px; background: var(--bg); border-radius: 8px; margin-top: 8px; margin-bottom: 14px; }
|
||||
.qr-big {
|
||||
width: 84px; height: 84px;
|
||||
display: grid; grid-template-columns: repeat(21, 1fr); gap: 0;
|
||||
background: #fff; padding: 6px; border-radius: 6px; flex-shrink: 0;
|
||||
}
|
||||
.qr-big span { display: block; aspect-ratio: 1; }
|
||||
.qr-meta { flex: 1; }
|
||||
.qr-key { font-family: var(--font-mono); font-size: 14px; font-weight: 600; letter-spacing: 0.04em; margin: 6px 0 10px 0; }
|
||||
|
||||
.success { display: flex; align-items: center; gap: 14px; }
|
||||
.success-tile { width: 44px; height: 44px; border-radius: 10px; background: var(--accent); color: var(--accent-fg); display: inline-flex; align-items: center; justify-content: center; flex-shrink: 0; }
|
||||
.success h3 { font-family: var(--font-display); font-weight: 600; font-size: 18px; margin: 0; }
|
||||
.success-note { margin-top: 20px; padding: 14px; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; font-size: 13px; color: var(--text-dim); line-height: 1.6; }
|
||||
|
||||
.check-row { display: flex; align-items: center; gap: 10px; font-size: 13px; cursor: pointer; }
|
||||
.check-row input { width: 16px; height: 16px; accent-color: var(--text); }
|
||||
|
||||
/* Period / format chip groups */
|
||||
.period { display: grid; grid-template-columns: repeat(4, 1fr); gap: 6px; }
|
||||
.period button {
|
||||
padding: 8px 0; border-radius: 6px; border: 1px solid var(--border);
|
||||
background: var(--surface); color: var(--text);
|
||||
font-family: inherit; font-size: 12px; font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
.period button.active { background: var(--text); color: var(--bg); border-color: var(--text); }
|
||||
|
||||
.seg { display: flex; border: 1px solid var(--border); border-radius: 6px; padding: 2px; background: var(--surface); }
|
||||
.seg button {
|
||||
flex: 1; padding: 6px 0; border: none; border-radius: 4px;
|
||||
background: transparent; color: var(--text);
|
||||
font-family: inherit; font-size: 12px; font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
.seg button.active { background: var(--text); color: var(--bg); }
|
||||
|
||||
.delivery { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
|
||||
.delivery button {
|
||||
padding: 12px; border-radius: 6px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface); color: var(--text);
|
||||
font-family: inherit; cursor: pointer;
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
text-align: left;
|
||||
}
|
||||
.delivery button.active { border-color: var(--text); background: var(--bg); }
|
||||
.d-text { flex: 1; }
|
||||
.d-l { font-size: 13px; font-weight: 500; }
|
||||
</style>
|
||||
@@ -0,0 +1,57 @@
|
||||
<script setup lang="ts">
|
||||
// Sign-out landing. /api/auth/sign-out cleared the local session and bounced
|
||||
// through Authentik's end-session endpoint, which ended the IdP session.
|
||||
// At this point the user has no portal session and no Authentik session —
|
||||
// visiting / will prompt for fresh credentials. (The hidden iframe trick is
|
||||
// no longer needed; full redirect through Authentik handles everything.)
|
||||
|
||||
definePageMeta({ layout: 'blank', auth: false, oidcAuth: { enabled: false } })
|
||||
|
||||
function signInAgain() {
|
||||
// External navigation so the OIDC handler runs on the server and issues
|
||||
// the meta-refresh into Authentik's authorize URL. router.push('/') would
|
||||
// bounce through /auth/login first, adding an extra hop.
|
||||
return navigateTo('/auth/oidc/login', { external: true })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AuthShell>
|
||||
<div class="badge">
|
||||
<UiIcon name="check" :size="28" />
|
||||
</div>
|
||||
<AuthHeading
|
||||
eyebrow="See you soon"
|
||||
title="You're signed out"
|
||||
body="Your session has been ended. Sign in again whenever you're ready — your work is right where you left it."
|
||||
/>
|
||||
|
||||
<AuthButton variant="primary" @click="signInAgain">Sign in again</AuthButton>
|
||||
|
||||
<AuthFooterLink>
|
||||
Closing the tab? Your data stays put on
|
||||
<span class="mono">app.dezky.local</span>.
|
||||
</AuthFooterLink>
|
||||
</AuthShell>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.badge {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 14px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0 0 18px 0;
|
||||
background: rgba(31, 138, 91, 0.1);
|
||||
color: var(--ok);
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
</style>
|
||||
Reference in New Issue
Block a user