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,737 @@
|
||||
<script setup lang="ts">
|
||||
// 6-step wizard that walks the partner through provisioning a new customer org.
|
||||
// The actual orchestration (Authentik tenant, Stalwart mailbox, OCIS space …)
|
||||
// happens behind a single "Provision" action. Here we just collect input and
|
||||
// give them a clear review summary.
|
||||
|
||||
defineProps<{ open: boolean }>()
|
||||
const emit = defineEmits<{ close: []; done: [] }>()
|
||||
|
||||
// 5 steps. Branding was dropped because nothing on the backend persists it
|
||||
// yet (no Tenant.branding field, no logo upload pipeline) — partners
|
||||
// configure branding post-provisioning via /partner/branding once that
|
||||
// surface gets wired to a real backend.
|
||||
const STEPS = [
|
||||
{ n: 1, label: 'Organization' },
|
||||
{ n: 2, label: 'Domain' },
|
||||
{ n: 3, label: 'First admin' },
|
||||
{ n: 4, label: 'Plan' },
|
||||
{ n: 5, label: 'Review' },
|
||||
] as const
|
||||
const LAST_STEP = STEPS.length
|
||||
|
||||
const step = ref(1)
|
||||
|
||||
// Default form state. Fields start empty so the partner fills in their
|
||||
// real customer's details rather than editing pre-filled fixture data.
|
||||
// `plan` defaults to Business + `cycle` to Monthly because those are the
|
||||
// usual choice and the radio/dropdown render expects a value; both stay
|
||||
// editable.
|
||||
const form = reactive({
|
||||
legalName: '',
|
||||
displayName: '',
|
||||
cvr: '',
|
||||
country: '',
|
||||
address: '',
|
||||
domain: '',
|
||||
preconfigureDns: true,
|
||||
adminFirst: '',
|
||||
adminLast: '',
|
||||
adminEmail: '',
|
||||
adminPhone: '',
|
||||
sendWelcome: true,
|
||||
plan: 'Business' as PlanLabel,
|
||||
seats: 0,
|
||||
cycle: 'Monthly' as 'Monthly' | 'Quarterly' | 'Yearly',
|
||||
currency: 'DKK' as 'DKK' | 'EUR' | 'USD',
|
||||
})
|
||||
|
||||
// Static plan metadata. Features stay hard-coded (they're product copy, not
|
||||
// catalog data). Prices come from the live /api/prices catalog at render
|
||||
// time — see `visiblePlans` below.
|
||||
const plans = [
|
||||
{ code: 'mvp', name: 'Starter', features: '10 GB mail · 100 GB drive · 5 video rooms' },
|
||||
{ code: 'pro', name: 'Business', features: '50 GB mail · 1 TB drive · unlimited video · MFA', best: true },
|
||||
{ code: 'enterprise', name: 'Enterprise', features: 'Custom quotas · SSO · audit log · 24/7 support' },
|
||||
] as const
|
||||
|
||||
type PlanLabel = (typeof plans)[number]['name']
|
||||
type PlanCode = (typeof plans)[number]['code']
|
||||
|
||||
// Live catalog. We fetch all active price rows once when the wizard mounts
|
||||
// and look them up by (plan, cycle) as the user changes selectors. Empty
|
||||
// catalog (no /api/prices configured) leaves all cards showing "Not set"
|
||||
// — wizard still works, just no number displayed.
|
||||
interface CatalogRow {
|
||||
plan: PlanCode
|
||||
cycle: 'monthly' | 'quarterly' | 'yearly'
|
||||
amounts: { DKK?: number; EUR?: number; USD?: number }
|
||||
active: boolean
|
||||
}
|
||||
const { data: catalog } = await useFetch<CatalogRow[]>('/api/prices', {
|
||||
key: 'wizard-catalog',
|
||||
default: () => [],
|
||||
})
|
||||
|
||||
// Cycle label shown after the slash in "X DKK / seat / mo".
|
||||
const CYCLE_SUFFIX: Record<'monthly' | 'quarterly' | 'yearly', string> = {
|
||||
monthly: 'mo',
|
||||
quarterly: 'quarter',
|
||||
yearly: 'yr',
|
||||
}
|
||||
|
||||
function reset() {
|
||||
step.value = 1
|
||||
// Clearing result here lets the modal reopen on a fresh "step 1" view
|
||||
// rather than landing on the previous provisioning's done state.
|
||||
result.value = null
|
||||
submitError.value = null
|
||||
}
|
||||
|
||||
function close() {
|
||||
emit('close')
|
||||
// reset on next tick so the closing animation isn't disturbed
|
||||
setTimeout(reset, 200)
|
||||
}
|
||||
|
||||
function next() {
|
||||
if (step.value < LAST_STEP) step.value++
|
||||
}
|
||||
function back() {
|
||||
if (step.value > 1) step.value--
|
||||
}
|
||||
|
||||
// ── Provision (real backend call) ────────────────────────────────────────
|
||||
const submitting = ref(false)
|
||||
const submitError = ref<string | null>(null)
|
||||
|
||||
// Slug from display name: lowercase, alphanumeric, hyphen-joined, trimmed
|
||||
// to 40 chars (matches CreateTenantDto.slug regex).
|
||||
function slugFromName(name: string): string {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.normalize('NFKD')
|
||||
.replace(/[̀-ͯ]/g, '')
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 40) || 'tenant'
|
||||
}
|
||||
|
||||
function planCode(label: PlanLabel): PlanCode {
|
||||
return plans.find((p) => p.name === label)!.code
|
||||
}
|
||||
|
||||
function cycleCode(label: 'Monthly' | 'Quarterly' | 'Yearly'): 'monthly' | 'quarterly' | 'yearly' {
|
||||
return label.toLowerCase() as 'monthly' | 'quarterly' | 'yearly'
|
||||
}
|
||||
|
||||
// Combines the static plan list with the live catalog: for each plan card,
|
||||
// look up the price row for the user's currently-selected cycle, then pull
|
||||
// the amount in their selected currency. Renders a friendly string ready to
|
||||
// drop into the template.
|
||||
const visiblePlans = computed(() =>
|
||||
plans.map((p) => {
|
||||
const row = catalog.value.find((r) => r.plan === p.code && r.cycle === cycleCode(form.cycle))
|
||||
const minor = row?.amounts[form.currency]
|
||||
const cycleSuffix = CYCLE_SUFFIX[cycleCode(form.cycle)]
|
||||
let priceLabel: string
|
||||
let available = true
|
||||
if (p.code === 'enterprise' && minor === undefined) {
|
||||
priceLabel = 'Custom'
|
||||
} else if (minor === undefined) {
|
||||
// Catalog row exists but no price in this currency, OR no row at all.
|
||||
priceLabel = `Not sold in ${form.currency}`
|
||||
available = false
|
||||
} else {
|
||||
priceLabel = `${(minor / 100).toLocaleString('da-DK')} ${form.currency} / seat / ${cycleSuffix}`
|
||||
}
|
||||
return { ...p, priceLabel, available }
|
||||
}),
|
||||
)
|
||||
|
||||
// Total per cycle in the chosen currency. Drives the "you'll pay" line.
|
||||
const totalPerCycle = computed(() => {
|
||||
const p = visiblePlans.value.find((x) => x.name === form.plan)
|
||||
if (!p || !p.available) return null
|
||||
const row = catalog.value.find((r) => r.plan === p.code && r.cycle === cycleCode(form.cycle))
|
||||
const minor = row?.amounts[form.currency]
|
||||
if (minor === undefined || !form.seats) return null
|
||||
const total = (minor * form.seats) / 100
|
||||
const cycleSuffix = CYCLE_SUFFIX[cycleCode(form.cycle)]
|
||||
return `${total.toLocaleString('da-DK')} ${form.currency} / ${cycleSuffix}`
|
||||
})
|
||||
|
||||
// Result we hand to the "Provisioned" view after submit succeeds. The
|
||||
// tenant create always succeeds when we get here; the admin invite may
|
||||
// have failed independently (caught server-side and returned as `error`).
|
||||
interface AdminCredentials {
|
||||
link?: string
|
||||
tempPassword?: string
|
||||
attached?: boolean
|
||||
error?: string
|
||||
}
|
||||
const result = ref<{ tenantName: string; adminEmail: string; admin?: AdminCredentials } | null>(null)
|
||||
const copied = ref(false)
|
||||
|
||||
async function copyToClipboard(value: string) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(value)
|
||||
copied.value = true
|
||||
setTimeout(() => (copied.value = false), 2000)
|
||||
} catch {
|
||||
// Non-secure context — user selects the readonly input.
|
||||
}
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
submitError.value = null
|
||||
const displayName = form.displayName.trim()
|
||||
if (!displayName) {
|
||||
submitError.value = 'Display name is required'
|
||||
step.value = 1
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
const adminName = `${form.adminFirst.trim()} ${form.adminLast.trim()}`.trim()
|
||||
const adminEmail = form.adminEmail.trim()
|
||||
const payload = {
|
||||
slug: slugFromName(displayName),
|
||||
name: displayName,
|
||||
plan: planCode(form.plan),
|
||||
cycle: form.cycle.toLowerCase() as 'monthly' | 'quarterly' | 'yearly',
|
||||
currency: form.currency,
|
||||
seats: form.seats,
|
||||
...(form.domain.trim() && { domains: [form.domain.trim()] }),
|
||||
billingInfo: {
|
||||
...(form.legalName.trim() && { companyName: form.legalName.trim() }),
|
||||
...(form.cvr.trim() && { vatId: form.cvr.trim() }),
|
||||
...(form.country && { country: form.country }),
|
||||
...(adminEmail && { contactEmail: adminEmail }),
|
||||
},
|
||||
// Only send admin info when both name + email are present. Backend
|
||||
// skips the invite if either is missing; we mirror that on the
|
||||
// client so the wizard never sends half-filled admin payloads.
|
||||
...(adminName && adminEmail && { adminName, adminEmail }),
|
||||
}
|
||||
const res = await $fetch<{
|
||||
tenant: { name: string }
|
||||
adminInvite?: AdminCredentials | { error: string }
|
||||
}>('/api/partner/tenants', { method: 'POST', body: payload })
|
||||
|
||||
result.value = {
|
||||
tenantName: res.tenant.name,
|
||||
adminEmail,
|
||||
admin: res.adminInvite as AdminCredentials | undefined,
|
||||
}
|
||||
emit('done') // refresh the customers table + sidebar in the background
|
||||
} catch (err: unknown) {
|
||||
const e = err as { data?: { data?: { message?: string }; message?: string; statusMessage?: string } }
|
||||
submitError.value =
|
||||
e.data?.data?.message || e.data?.message || e.data?.statusMessage || 'Provisioning failed'
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function finish() {
|
||||
result.value = null
|
||||
close()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal
|
||||
:open="open"
|
||||
size="lg"
|
||||
:eyebrow="result ? 'Provisioned' : `Step ${step} of ${STEPS.length}`"
|
||||
title="Provision new customer organization"
|
||||
@close="close"
|
||||
>
|
||||
<!-- Step rail -->
|
||||
<div v-if="!result" class="rail">
|
||||
<template v-for="(s, idx) in STEPS" :key="s.n">
|
||||
<div class="rail-step" :class="{ done: s.n < step, active: s.n === step }">
|
||||
<div class="bubble">
|
||||
<UiIcon v-if="s.n < step" name="check" :size="11" :stroke-width="2.6" />
|
||||
<template v-else>{{ s.n }}</template>
|
||||
</div>
|
||||
<span class="lab">{{ s.label }}</span>
|
||||
</div>
|
||||
<div v-if="idx < STEPS.length - 1" class="rail-line" />
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- 1. Organization -->
|
||||
<div v-if="step === 1 && !result" class="form">
|
||||
<label class="field">
|
||||
<Eyebrow>Legal name</Eyebrow>
|
||||
<input v-model="form.legalName" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<Eyebrow>Display name · shown to users</Eyebrow>
|
||||
<input v-model="form.displayName" />
|
||||
</label>
|
||||
<div class="row-2">
|
||||
<label class="field">
|
||||
<Eyebrow>CVR</Eyebrow>
|
||||
<input v-model="form.cvr" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<Eyebrow>Country</Eyebrow>
|
||||
<CountrySelect v-model="form.country" />
|
||||
</label>
|
||||
</div>
|
||||
<label class="field">
|
||||
<Eyebrow>Address</Eyebrow>
|
||||
<input v-model="form.address" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- 2. Domain -->
|
||||
<div v-if="step === 2 && !result" class="form">
|
||||
<label class="field">
|
||||
<Eyebrow>Primary domain</Eyebrow>
|
||||
<input v-model="form.domain" />
|
||||
</label>
|
||||
<div class="info-box">
|
||||
<Eyebrow>DNS verification</Eyebrow>
|
||||
<p>
|
||||
We'll send the customer their DNS records during onboarding. For now we just register
|
||||
the intent. You can pre-fill MX/SPF if they've delegated DNS to you.
|
||||
</p>
|
||||
<label class="cb-row">
|
||||
<input v-model="form.preconfigureDns" type="checkbox" />
|
||||
Pre-configure DNS records on the customer's behalf (NordicMSP manages their DNS)
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 3. First admin -->
|
||||
<div v-if="step === 3 && !result" class="form">
|
||||
<p class="hint">We'll send this person an invitation email. They become the first customer admin.</p>
|
||||
<div class="row-2">
|
||||
<label class="field">
|
||||
<Eyebrow>First name</Eyebrow>
|
||||
<input v-model="form.adminFirst" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<Eyebrow>Last name</Eyebrow>
|
||||
<input v-model="form.adminLast" />
|
||||
</label>
|
||||
</div>
|
||||
<label class="field">
|
||||
<Eyebrow>Email</Eyebrow>
|
||||
<input v-model="form.adminEmail" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<Eyebrow>Phone</Eyebrow>
|
||||
<input v-model="form.adminPhone" />
|
||||
</label>
|
||||
<label class="cb-row">
|
||||
<input v-model="form.sendWelcome" type="checkbox" />
|
||||
Send welcome email immediately upon provisioning
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- 4. Plan -->
|
||||
<div v-if="step === 4 && !result" class="form">
|
||||
<!-- Seats / cycle / currency drive the plan-card prices below — keep
|
||||
them at the top so the user picks them first, then plans update
|
||||
live. -->
|
||||
<div class="row-3">
|
||||
<label class="field">
|
||||
<Eyebrow>Initial seats</Eyebrow>
|
||||
<input v-model.number="form.seats" type="number" min="1" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<Eyebrow>Billing cycle</Eyebrow>
|
||||
<select v-model="form.cycle">
|
||||
<option value="Monthly">Monthly</option>
|
||||
<option value="Quarterly">Quarterly</option>
|
||||
<option value="Yearly">Yearly</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">
|
||||
<Eyebrow>Currency</Eyebrow>
|
||||
<select v-model="form.currency">
|
||||
<option value="DKK">DKK</option>
|
||||
<option value="EUR">EUR</option>
|
||||
<option value="USD">USD</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label
|
||||
v-for="p in visiblePlans"
|
||||
:key="p.name"
|
||||
class="plan"
|
||||
:class="{ selected: form.plan === p.name, disabled: !p.available }"
|
||||
@click="p.available && (form.plan = p.name as any)"
|
||||
>
|
||||
<span v-if="(p as any).best" class="rec">RECOMMENDED</span>
|
||||
<span class="radio" :class="{ on: form.plan === p.name }">
|
||||
<span v-if="form.plan === p.name" class="radio-inner" />
|
||||
</span>
|
||||
<div class="plan-body">
|
||||
<div class="plan-head">
|
||||
<span class="plan-name">{{ p.name }}</span>
|
||||
<Mono dim>{{ p.priceLabel }}</Mono>
|
||||
</div>
|
||||
<Mono dim>{{ p.features }}</Mono>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<p v-if="totalPerCycle" class="total-line">
|
||||
<Mono dim>Total · {{ form.seats }} {{ form.seats === 1 ? 'seat' : 'seats' }}</Mono>
|
||||
<strong>{{ totalPerCycle }}</strong>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 5. Review -->
|
||||
<div v-if="step === 5 && !result" class="form">
|
||||
<div class="review-hero">
|
||||
<Eyebrow>You're provisioning</Eyebrow>
|
||||
<div class="review-name">{{ form.displayName }}</div>
|
||||
<Mono dim>{{ form.domain }} · {{ form.plan }} · {{ form.seats }} seats</Mono>
|
||||
</div>
|
||||
<dl class="def">
|
||||
<div class="def-row"><dt>Admin</dt><dd>{{ form.adminFirst }} {{ form.adminLast }} · {{ form.adminEmail }}</dd></div>
|
||||
<div class="def-row"><dt>Plan</dt><dd>{{ form.plan }} · {{ form.seats }} seats · {{ form.cycle.toLowerCase() }}</dd></div>
|
||||
<div class="def-row"><dt>Branding</dt><dd>Defaults · customize after provisioning</dd></div>
|
||||
<div class="def-row"><dt>Onboarding</dt><dd>Welcome email sent on creation</dd></div>
|
||||
</dl>
|
||||
<div class="prov-note">
|
||||
<Mono dim>// provisioning</Mono>
|
||||
<p>
|
||||
On confirm we'll create the tenant in Dezky and trigger the
|
||||
background provisioner (Authentik tenant, OCIS space, Stalwart
|
||||
mailboxes). {{ form.displayName }} will appear in your portfolio
|
||||
as soon as the database write completes.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="submitError" class="err">{{ submitError }}</p>
|
||||
|
||||
<!-- Provisioned: tenant + admin credentials handoff. Shown after
|
||||
submit() completes, hides the wizard step content. -->
|
||||
<div v-if="result" class="provisioned">
|
||||
<Badge tone="ok" dot>provisioned</Badge>
|
||||
<h3>{{ result.tenantName }} is live</h3>
|
||||
|
||||
<template v-if="result.admin && !result.admin.error">
|
||||
<template v-if="result.admin.attached">
|
||||
<p class="ok-msg">
|
||||
<Mono>{{ result.adminEmail }}</Mono> already existed in Authentik
|
||||
and was attached as an admin on this tenant. They sign in with
|
||||
their existing credentials.
|
||||
</p>
|
||||
</template>
|
||||
<template v-else-if="result.admin.link">
|
||||
<p class="ok-msg">
|
||||
Share this single-use link with the admin — they'll set their
|
||||
own password and enroll MFA.
|
||||
</p>
|
||||
<div class="cred-row">
|
||||
<input :value="result.admin.link" readonly @focus="($event.target as HTMLInputElement).select()" />
|
||||
<UiButton variant="secondary" @click="copyToClipboard(result.admin!.link!)">
|
||||
{{ copied ? 'Copied' : 'Copy' }}
|
||||
</UiButton>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="result.admin.tempPassword">
|
||||
<p class="ok-msg">
|
||||
Authentik has no recovery flow configured, so we set a
|
||||
temporary password — share it with the admin and they'll be
|
||||
prompted to change it on first login.
|
||||
</p>
|
||||
<div class="cred-row">
|
||||
<input :value="result.adminEmail" readonly @focus="($event.target as HTMLInputElement).select()" />
|
||||
<UiButton variant="secondary" @click="copyToClipboard(result.adminEmail)">Copy</UiButton>
|
||||
</div>
|
||||
<div class="cred-row">
|
||||
<input :value="result.admin.tempPassword" readonly @focus="($event.target as HTMLInputElement).select()" />
|
||||
<UiButton variant="secondary" @click="copyToClipboard(result.admin!.tempPassword!)">
|
||||
{{ copied ? 'Copied' : 'Copy' }}
|
||||
</UiButton>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<template v-else-if="result.admin?.error">
|
||||
<p class="warn-msg">
|
||||
Tenant was created, but the admin invite failed:
|
||||
<Mono>{{ result.admin.error }}</Mono>. Retry the invite from
|
||||
<Mono>/partner/customers</Mono>.
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<p class="ok-msg">
|
||||
No first-admin info was provided. Invite an admin later from
|
||||
<Mono>/partner/customers</Mono>.
|
||||
</p>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<template v-if="!result">
|
||||
<UiButton variant="ghost" :disabled="submitting" @click="close">Cancel</UiButton>
|
||||
<div style="flex:1" />
|
||||
<UiButton v-if="step > 1" variant="secondary" :disabled="submitting" @click="back">Back</UiButton>
|
||||
<UiButton v-if="step < LAST_STEP" variant="primary" @click="next">Continue</UiButton>
|
||||
<UiButton v-else variant="primary" :disabled="submitting" @click="submit">
|
||||
<template #leading><UiIcon name="check" :size="14" /></template>
|
||||
{{ submitting ? 'Provisioning…' : 'Provision customer' }}
|
||||
</UiButton>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div style="flex:1" />
|
||||
<UiButton variant="primary" @click="finish">Done</UiButton>
|
||||
</template>
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.rail {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.rail-step {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
opacity: 0.45;
|
||||
}
|
||||
.rail-step.active, .rail-step.done { opacity: 1; }
|
||||
.bubble {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 999px;
|
||||
background: var(--surface);
|
||||
color: var(--text-mute);
|
||||
border: 1px solid var(--border);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.rail-step.done .bubble { background: var(--text); color: var(--bg); border-color: var(--text); }
|
||||
.rail-step.active .bubble { background: var(--accent); color: var(--accent-fg); border-color: var(--accent); }
|
||||
.lab {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--text-mute);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.rail-step.active .lab { color: var(--text); font-weight: 600; }
|
||||
.rail-step.done .lab { color: var(--text); }
|
||||
|
||||
.rail-line {
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
.form { display: flex; flex-direction: column; gap: 14px; }
|
||||
.row-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||||
.row-3 { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 12px; }
|
||||
|
||||
.field { display: flex; flex-direction: column; gap: 6px; }
|
||||
.field input,
|
||||
.field select {
|
||||
padding: 9px 12px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
color: var(--text);
|
||||
}
|
||||
.field select {
|
||||
appearance: none;
|
||||
background-image: linear-gradient(45deg, transparent 50%, var(--text-mute) 50%),
|
||||
linear-gradient(135deg, var(--text-mute) 50%, transparent 50%);
|
||||
background-position: calc(100% - 16px) 50%, calc(100% - 12px) 50%;
|
||||
background-size: 4px 4px;
|
||||
background-repeat: no-repeat;
|
||||
padding-right: 28px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.field input:focus,
|
||||
.field select:focus { outline: none; border-color: var(--border-hi); }
|
||||
|
||||
.hint { font-size: 13px; color: var(--text-dim); margin: 0 0 4px 0; line-height: 1.5; }
|
||||
|
||||
.info-box {
|
||||
padding: 14px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
}
|
||||
.info-box p { font-size: 13px; color: var(--text-dim); margin: 8px 0 12px 0; line-height: 1.55; }
|
||||
|
||||
.cb-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 13px;
|
||||
color: var(--text);
|
||||
}
|
||||
.cb-row input[type='checkbox'] { width: 14px; height: 14px; accent-color: var(--text); }
|
||||
|
||||
.plan {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 16px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: var(--surface);
|
||||
cursor: pointer;
|
||||
}
|
||||
.plan.selected { border-color: var(--text); background: var(--bg); }
|
||||
.plan.disabled { opacity: 0.45; cursor: not-allowed; }
|
||||
.plan.disabled:hover { background: var(--surface); }
|
||||
|
||||
.total-line {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
margin: 4px 0 0;
|
||||
padding: 12px 14px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
}
|
||||
.total-line strong {
|
||||
font-family: var(--font-display);
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
.rec {
|
||||
position: absolute;
|
||||
top: -8px;
|
||||
right: 12px;
|
||||
background: var(--accent);
|
||||
color: var(--accent-fg);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
padding: 2px 8px;
|
||||
border-radius: 3px;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
.radio {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 999px;
|
||||
border: 2px solid var(--border-hi);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.radio.on { border-color: var(--text); }
|
||||
.radio-inner { width: 8px; height: 8px; border-radius: 999px; background: var(--text); }
|
||||
.plan-body { flex: 1; }
|
||||
.plan-head { display: flex; align-items: baseline; gap: 10px; }
|
||||
.plan-name { font-family: var(--font-display); font-size: 17px; font-weight: 600; }
|
||||
|
||||
.brand-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; }
|
||||
|
||||
.def { display: flex; flex-direction: column; gap: 8px; margin: 0; }
|
||||
.def-row { display: grid; grid-template-columns: 160px 1fr; gap: 12px; font-size: 13px; }
|
||||
.def-row dt { color: var(--text-mute); font-family: var(--font-mono); font-size: 11px; padding-top: 1px; }
|
||||
.def-row dd { margin: 0; color: var(--text); }
|
||||
|
||||
.color-row { display: flex; align-items: center; gap: 8px; }
|
||||
.color-swatch { width: 14px; height: 14px; border-radius: 3px; display: inline-block; }
|
||||
|
||||
.review-hero {
|
||||
padding: 16px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.review-name {
|
||||
font-family: var(--font-display);
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.02em;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.prov-note {
|
||||
margin-top: 16px;
|
||||
padding: 14px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
}
|
||||
.prov-note p { font-size: 12px; color: var(--text-dim); line-height: 1.6; margin: 6px 0 0 0; }
|
||||
|
||||
.err {
|
||||
margin: 12px 0 0;
|
||||
padding: 10px 12px;
|
||||
font-size: 12px;
|
||||
color: var(--bad);
|
||||
background: rgba(226, 48, 48, 0.08);
|
||||
border: 1px solid rgba(226, 48, 48, 0.2);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
/* Provisioned view — shown after submit() succeeds. */
|
||||
.provisioned {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
.provisioned h3 {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 600;
|
||||
font-size: 22px;
|
||||
letter-spacing: -0.02em;
|
||||
margin: 0;
|
||||
}
|
||||
.ok-msg,
|
||||
.warn-msg {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: var(--text-dim);
|
||||
line-height: 1.55;
|
||||
}
|
||||
.warn-msg {
|
||||
padding: 10px 12px;
|
||||
background: rgba(232, 154, 31, 0.08);
|
||||
border: 1px solid rgba(232, 154, 31, 0.24);
|
||||
border-radius: 6px;
|
||||
color: var(--warn);
|
||||
}
|
||||
.cred-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.cred-row input {
|
||||
flex: 1;
|
||||
padding: 9px 12px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
color: var(--text);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,246 @@
|
||||
<script setup lang="ts">
|
||||
// Side panel for Escalate / Check in tasks raised from a customer health row.
|
||||
// Pre-fills notes from the health drivers and lets the partner tweak before
|
||||
// creating the task.
|
||||
|
||||
import type { CustomerOrg } from '~/data/customers'
|
||||
|
||||
export interface TaskContext {
|
||||
customer: CustomerOrg
|
||||
score: number
|
||||
mode: 'escalate' | 'checkin'
|
||||
}
|
||||
|
||||
const props = defineProps<{ task: TaskContext | null }>()
|
||||
const emit = defineEmits<{ close: []; save: [t: TaskContext] }>()
|
||||
|
||||
const assignee = ref('Anders Bjerregaard')
|
||||
const due = ref('')
|
||||
const severity = ref<'low' | 'medium' | 'high'>('high')
|
||||
const snapshot = ref(true)
|
||||
const notes = ref('')
|
||||
|
||||
watch(
|
||||
() => props.task,
|
||||
(t) => {
|
||||
if (!t) return
|
||||
assignee.value = 'Anders Bjerregaard'
|
||||
due.value = t.mode === 'escalate' ? '2026-05-26' : '2026-05-31'
|
||||
severity.value = t.mode === 'escalate' ? 'high' : 'medium'
|
||||
snapshot.value = true
|
||||
const drivers: string[] = []
|
||||
if (t.customer.status === 'past_due') drivers.push('Invoice past-due — billing follow-up needed.')
|
||||
if (t.customer.status === 'attention') drivers.push('Account flagged "attention" — investigate root cause.')
|
||||
if (t.customer.seats.used / t.customer.seats.total > 0.85) {
|
||||
drivers.push(`Seat usage at ${Math.round(t.customer.seats.used/t.customer.seats.total*100)}% — upsell opportunity.`)
|
||||
}
|
||||
if (t.mode === 'escalate') {
|
||||
drivers.unshift(`${t.customer.name} dropped below 50 health. Suggested action: schedule a 30-min review with their primary contact this week.`)
|
||||
} else {
|
||||
drivers.unshift(`${t.customer.name} is on the watch list. Suggested action: a brief check-in to renew the relationship.`)
|
||||
}
|
||||
notes.value = drivers.join('\n\n')
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
const isEscalate = computed(() => props.task?.mode === 'escalate')
|
||||
|
||||
function healthColor(h: number) {
|
||||
if (h >= 75) return 'var(--ok)'
|
||||
if (h >= 50) return 'var(--warn)'
|
||||
return 'var(--bad)'
|
||||
}
|
||||
|
||||
function drivers() {
|
||||
if (!props.task) return []
|
||||
const c = props.task.customer
|
||||
return [
|
||||
c.status === 'past_due' && { l: 'Invoice past-due', d: 'INV-2026-04204 · 21 days overdue', tone: 'bad' as const },
|
||||
c.status === 'attention' && { l: 'Status flagged attention', d: 'manual flag · open support ticket', tone: 'warn' as const },
|
||||
c.seats.used / c.seats.total > 0.85 && { l: 'Seat usage high', d: `${c.seats.used}/${c.seats.total} seats — approaching limit`, tone: 'warn' as const },
|
||||
c.plan === 'starter' && { l: 'Plan trending low', d: 'Starter plan · no upgrade in 6 mo', tone: 'info' as const },
|
||||
].filter(Boolean) as Array<{ l: string; d: string; tone: 'bad' | 'warn' | 'info' }>
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SidePanel
|
||||
:open="!!task"
|
||||
width="md"
|
||||
:eyebrow="isEscalate ? 'Customer health · escalate' : 'Customer health · check in'"
|
||||
:title="task ? (isEscalate ? `Escalate ${task.customer.name}` : `Check in with ${task.customer.name}`) : ''"
|
||||
@close="emit('close')"
|
||||
>
|
||||
<div v-if="task">
|
||||
<div class="head-card">
|
||||
<div class="hc-row">
|
||||
<div class="cust-swatch" :style="{ background: task.customer.brandColor }" />
|
||||
<div class="hc-meta">
|
||||
<div class="hc-name">{{ task.customer.name }}</div>
|
||||
<Mono dim>{{ task.customer.domain }} · {{ task.customer.planLabel }}</Mono>
|
||||
</div>
|
||||
<div class="hc-score">
|
||||
<Eyebrow>Health score</Eyebrow>
|
||||
<div class="score-val" :style="{ color: healthColor(task.score) }">{{ task.score }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="drivers-card">
|
||||
<Eyebrow>Drivers · what pulled the score down</Eyebrow>
|
||||
<div class="drivers-list">
|
||||
<div v-for="d in drivers()" :key="d.l" class="driver-row">
|
||||
<Badge :tone="d.tone" dot>{{ d.tone }}</Badge>
|
||||
<span class="dr-label">{{ d.l }}</span>
|
||||
<Mono dim>{{ d.d }}</Mono>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form">
|
||||
<label class="field">
|
||||
<Eyebrow>Assigned to</Eyebrow>
|
||||
<div class="assignee">
|
||||
<Avatar :name="assignee" :size="24" />
|
||||
<span>{{ assignee }}</span>
|
||||
<UiButton size="sm" variant="ghost">Change</UiButton>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<div class="row-2">
|
||||
<label class="field">
|
||||
<Eyebrow>Due date</Eyebrow>
|
||||
<input v-model="due" type="date" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<Eyebrow>Severity</Eyebrow>
|
||||
<div class="seg">
|
||||
<button
|
||||
v-for="s in (['low', 'medium', 'high'] as const)"
|
||||
:key="s"
|
||||
type="button"
|
||||
:class="{ active: severity === s }"
|
||||
@click="severity = s"
|
||||
>{{ s }}</button>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label class="field">
|
||||
<Eyebrow>{{ isEscalate ? 'Escalation notes' : 'Check-in talking points' }}</Eyebrow>
|
||||
<textarea v-model="notes" rows="8" />
|
||||
<Mono dim>pre-filled from the health drivers — edit before saving</Mono>
|
||||
</label>
|
||||
|
||||
<label class="cb-row">
|
||||
<input v-model="snapshot" type="checkbox" />
|
||||
Attach a health snapshot to the task
|
||||
</label>
|
||||
|
||||
<div v-if="isEscalate" class="warn">
|
||||
<UiIcon name="shield" :size="14" />
|
||||
<p>
|
||||
Escalations notify the account owner immediately and appear at the top of their queue. Use sparingly.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<UiButton variant="ghost" @click="emit('close')">Cancel</UiButton>
|
||||
<div style="flex:1" />
|
||||
<UiButton variant="secondary">
|
||||
<template #leading><UiIcon name="mail" :size="14" /></template>
|
||||
Save as draft
|
||||
</UiButton>
|
||||
<UiButton variant="primary" @click="task && emit('save', task); emit('close')">
|
||||
<template #leading><UiIcon name="check" :size="14" /></template>
|
||||
{{ isEscalate ? 'Create escalation' : 'Schedule check-in' }}
|
||||
</UiButton>
|
||||
</template>
|
||||
</SidePanel>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.head-card { margin-bottom: 20px; padding-bottom: 20px; border-bottom: 1px solid var(--border); }
|
||||
.hc-row { display: flex; align-items: center; gap: 14px; }
|
||||
.cust-swatch { width: 44px; height: 44px; border-radius: 8px; flex-shrink: 0; }
|
||||
.hc-meta { flex: 1; min-width: 0; }
|
||||
.hc-name { font-family: var(--font-display); font-weight: 600; font-size: 17px; letter-spacing: -0.015em; }
|
||||
.hc-score { text-align: right; }
|
||||
.score-val { font-family: var(--font-display); font-weight: 600; font-size: 24px; margin-top: 4px; line-height: 1; }
|
||||
|
||||
.drivers-card {
|
||||
margin-top: 14px;
|
||||
padding: 12px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
}
|
||||
.drivers-list { display: flex; flex-direction: column; gap: 6px; margin-top: 8px; }
|
||||
.driver-row { display: flex; align-items: center; gap: 8px; font-size: 12px; }
|
||||
.dr-label { flex: 1; }
|
||||
|
||||
.form { display: flex; flex-direction: column; gap: 14px; }
|
||||
.row-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
|
||||
.field { display: flex; flex-direction: column; gap: 6px; }
|
||||
.field input, .field textarea {
|
||||
padding: 9px 12px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
color: var(--text);
|
||||
}
|
||||
.field textarea { resize: vertical; line-height: 1.55; }
|
||||
.field input:focus, .field textarea:focus { outline: none; border-color: var(--border-hi); }
|
||||
|
||||
.assignee {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 12px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.assignee span { flex: 1; }
|
||||
|
||||
.seg {
|
||||
display: flex;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 2px;
|
||||
background: var(--surface);
|
||||
}
|
||||
.seg button {
|
||||
flex: 1;
|
||||
padding: 6px 0;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
.seg button.active { background: var(--text); color: var(--bg); }
|
||||
|
||||
.cb-row { display: flex; align-items: center; gap: 10px; font-size: 13px; }
|
||||
.cb-row input[type='checkbox'] { width: 14px; height: 14px; accent-color: var(--text); }
|
||||
|
||||
.warn {
|
||||
padding: 12px;
|
||||
background: rgba(226, 48, 48, 0.06);
|
||||
border: 1px solid rgba(226, 48, 48, 0.22);
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
.warn :deep(svg) { color: var(--bad); flex-shrink: 0; margin-top: 2px; }
|
||||
.warn p { font-size: 12px; color: var(--text-dim); line-height: 1.55; margin: 0; }
|
||||
</style>
|
||||
@@ -0,0 +1,194 @@
|
||||
<script setup lang="ts">
|
||||
// Edit modal for the partner's own brand identity. Includes a small live
|
||||
// preview of how the partner topbar/header will look with the picked
|
||||
// primary color + display name.
|
||||
|
||||
defineProps<{ open: boolean }>()
|
||||
const emit = defineEmits<{ close: [] }>()
|
||||
|
||||
const name = ref('NordicMSP')
|
||||
const color = ref('#3F6BFF')
|
||||
const supportEmail = ref('support@nordicmsp.dk')
|
||||
const supportPhone = ref('+45 70 70 12 34')
|
||||
const website = ref('nordicmsp.dk')
|
||||
const replyTo = ref('no-reply@nordicmsp.dk')
|
||||
|
||||
const SWATCHES = ['#3F6BFF', '#0A2540', '#0066CC', '#5B8C5A', '#D4FF3A']
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal
|
||||
:open="open"
|
||||
eyebrow="Partner · identity"
|
||||
title="Edit NordicMSP identity"
|
||||
size="md"
|
||||
@close="emit('close')"
|
||||
>
|
||||
<div class="form">
|
||||
<div class="info">
|
||||
<UiIcon name="shield" :size="14" />
|
||||
<p>
|
||||
This identity appears in the partner console and on emails sent by your team. It is
|
||||
<b>not</b> what your customers see — they see their own branding (or the defaults you set below).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<label class="field">
|
||||
<Eyebrow>Display name</Eyebrow>
|
||||
<input v-model="name" />
|
||||
</label>
|
||||
|
||||
<div>
|
||||
<Eyebrow>Logo & mark</Eyebrow>
|
||||
<div class="upload-grid">
|
||||
<div class="upload-row">
|
||||
<div class="upload-pv" :style="{ background: color }">{{ name[0]?.toLowerCase() || 'n' }}</div>
|
||||
<div class="upload-meta">
|
||||
<div class="upload-l">Full logo</div>
|
||||
<Mono dim>nordic-logo.svg · 24 KB</Mono>
|
||||
</div>
|
||||
<UiButton size="sm" variant="ghost">Replace</UiButton>
|
||||
</div>
|
||||
<div class="upload-row">
|
||||
<div class="upload-pv" :style="{ background: color }">{{ name[0]?.toLowerCase() || 'n' }}</div>
|
||||
<div class="upload-meta">
|
||||
<div class="upload-l">Square mark</div>
|
||||
<Mono dim>nordic-mark.svg · 8 KB</Mono>
|
||||
</div>
|
||||
<UiButton size="sm" variant="ghost">Replace</UiButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<Eyebrow>Primary color</Eyebrow>
|
||||
<div class="color-row">
|
||||
<div class="swatches">
|
||||
<button
|
||||
v-for="c in SWATCHES"
|
||||
:key="c"
|
||||
type="button"
|
||||
class="sw"
|
||||
:class="{ selected: color === c }"
|
||||
:style="{ background: c }"
|
||||
@click="color = c"
|
||||
/>
|
||||
</div>
|
||||
<input v-model="color" class="hex" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row-2">
|
||||
<label class="field"><Eyebrow>Support email</Eyebrow><input v-model="supportEmail" /></label>
|
||||
<label class="field"><Eyebrow>Support phone</Eyebrow><input v-model="supportPhone" /></label>
|
||||
<label class="field"><Eyebrow>Website</Eyebrow><input v-model="website" /></label>
|
||||
<label class="field"><Eyebrow>Reply-to address</Eyebrow><input v-model="replyTo" /></label>
|
||||
</div>
|
||||
|
||||
<div class="preview">
|
||||
<div class="pv-mark" :style="{ background: color }">{{ name[0]?.toLowerCase() || 'n' }}</div>
|
||||
<div class="pv-meta">
|
||||
<div class="pv-name">{{ name }}</div>
|
||||
<Mono dim>preview · partner console header + email signature</Mono>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<UiButton variant="ghost" @click="emit('close')">Cancel</UiButton>
|
||||
<UiButton variant="primary" @click="emit('close')">
|
||||
<template #leading><UiIcon name="check" :size="14" /></template>
|
||||
Save identity
|
||||
</UiButton>
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.form { display: flex; flex-direction: column; gap: 16px; }
|
||||
|
||||
.info {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
}
|
||||
.info p { font-size: 12px; color: var(--text-dim); margin: 0; line-height: 1.55; }
|
||||
.info :deep(svg) { color: var(--text-mute); margin-top: 2px; flex-shrink: 0; }
|
||||
|
||||
.field { display: flex; flex-direction: column; gap: 6px; }
|
||||
.row-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||||
|
||||
.field input, .hex {
|
||||
padding: 9px 12px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
color: var(--text);
|
||||
}
|
||||
.field input:focus, .hex:focus { outline: none; border-color: var(--border-hi); }
|
||||
|
||||
.upload-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 8px; }
|
||||
.upload-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
}
|
||||
.upload-pv {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 6px;
|
||||
color: #fff;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: var(--font-mono);
|
||||
font-weight: 700;
|
||||
font-size: 16px;
|
||||
}
|
||||
.upload-meta { flex: 1; min-width: 0; }
|
||||
.upload-l { font-size: 13px; font-weight: 500; }
|
||||
|
||||
.color-row { display: flex; align-items: center; gap: 10px; }
|
||||
.swatches { display: flex; gap: 8px; }
|
||||
.sw {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border);
|
||||
cursor: pointer;
|
||||
}
|
||||
.sw.selected { border: 2px solid var(--text); }
|
||||
.hex { flex: 1; font-family: var(--font-mono); }
|
||||
|
||||
.preview {
|
||||
padding: 12px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.pv-mark {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 6px;
|
||||
color: #fff;
|
||||
font-family: var(--font-mono);
|
||||
font-weight: 700;
|
||||
font-size: 13px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.pv-name { font-size: 13px; font-weight: 500; }
|
||||
</style>
|
||||
@@ -0,0 +1,204 @@
|
||||
<script setup lang="ts">
|
||||
// Side-panel template editor. Subject + body + merge tags + live HTML preview
|
||||
// wrapped in the partner's brand color. Used from the partner branding page
|
||||
// "Customer email templates" list.
|
||||
|
||||
export interface EmailTemplate {
|
||||
id: string
|
||||
name: string
|
||||
subject: string
|
||||
body: string
|
||||
edited: string
|
||||
}
|
||||
|
||||
const props = defineProps<{ template: EmailTemplate | null; brandColor: string; brandName: string }>()
|
||||
const emit = defineEmits<{ close: []; save: [t: EmailTemplate] }>()
|
||||
|
||||
const subject = ref('')
|
||||
const body = ref('')
|
||||
|
||||
watch(
|
||||
() => props.template?.id,
|
||||
() => {
|
||||
if (props.template) {
|
||||
subject.value = props.template.subject
|
||||
body.value = props.template.body
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
const MERGE_TAGS = [
|
||||
'{{user.first_name}}',
|
||||
'{{workspace.name}}',
|
||||
'{{partner.name}}',
|
||||
'{{plan.name}}',
|
||||
'{{invoice.id}}',
|
||||
'{{support.email}}',
|
||||
]
|
||||
|
||||
function insertTag(t: string) {
|
||||
body.value += (body.value.endsWith(' ') || body.value === '' ? '' : ' ') + t
|
||||
}
|
||||
|
||||
function onSave() {
|
||||
if (!props.template) return
|
||||
emit('save', { ...props.template, subject: subject.value, body: body.value })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SidePanel
|
||||
:open="!!template"
|
||||
width="lg"
|
||||
eyebrow="Email template"
|
||||
:title="template?.name || 'Edit template'"
|
||||
@close="emit('close')"
|
||||
>
|
||||
<div v-if="template" class="grid">
|
||||
<div class="editor">
|
||||
<label class="field">
|
||||
<Eyebrow>Subject</Eyebrow>
|
||||
<input v-model="subject" />
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<Eyebrow>Body</Eyebrow>
|
||||
<textarea v-model="body" rows="14" />
|
||||
</label>
|
||||
|
||||
<div class="tags">
|
||||
<Eyebrow>Merge tags · click to insert</Eyebrow>
|
||||
<div class="tag-chips">
|
||||
<button v-for="t in MERGE_TAGS" :key="t" type="button" @click="insertTag(t)">
|
||||
<Mono>{{ t }}</Mono>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="preview-wrap">
|
||||
<Eyebrow>Live preview</Eyebrow>
|
||||
<div class="preview">
|
||||
<div class="pv-header" :style="{ background: brandColor }">
|
||||
<div class="pv-mark">{{ brandName[0]?.toLowerCase() }}</div>
|
||||
<span class="pv-brand">{{ brandName }}</span>
|
||||
</div>
|
||||
<div class="pv-body">
|
||||
<div class="pv-subject">{{ subject || '(empty subject)' }}</div>
|
||||
<div class="pv-body-text">{{ body || '(empty body)' }}</div>
|
||||
<div class="pv-cta-wrap">
|
||||
<a class="pv-cta" :style="{ background: brandColor }">Open workspace</a>
|
||||
</div>
|
||||
<div class="pv-foot">
|
||||
Sent by {{ brandName }} · support@nordicmsp.dk
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<UiButton variant="ghost" @click="emit('close')">Cancel</UiButton>
|
||||
<div style="flex:1" />
|
||||
<UiButton variant="secondary">Send test email</UiButton>
|
||||
<UiButton variant="primary" @click="onSave">
|
||||
<template #leading><UiIcon name="check" :size="14" /></template>
|
||||
Save template
|
||||
</UiButton>
|
||||
</template>
|
||||
</SidePanel>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.editor { display: flex; flex-direction: column; gap: 14px; }
|
||||
|
||||
.field { display: flex; flex-direction: column; gap: 6px; }
|
||||
.field input, .field textarea {
|
||||
padding: 10px 12px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
color: var(--text);
|
||||
}
|
||||
.field textarea { font-family: var(--font-mono); font-size: 12px; resize: vertical; line-height: 1.6; }
|
||||
.field input:focus, .field textarea:focus { outline: none; border-color: var(--border-hi); }
|
||||
|
||||
.tags { display: flex; flex-direction: column; gap: 8px; }
|
||||
.tag-chips { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.tag-chips button {
|
||||
padding: 4px 8px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
.tag-chips button:hover { background: var(--bg); }
|
||||
|
||||
.preview-wrap { display: flex; flex-direction: column; gap: 10px; position: sticky; top: 0; }
|
||||
|
||||
.preview {
|
||||
background: #fff;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
color: #111;
|
||||
}
|
||||
.pv-header {
|
||||
padding: 14px 18px;
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.pv-mark {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 6px;
|
||||
background: rgba(255, 255, 255, 0.16);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: var(--font-mono);
|
||||
font-weight: 700;
|
||||
font-size: 14px;
|
||||
}
|
||||
.pv-brand { font-family: var(--font-display); font-size: 16px; font-weight: 600; letter-spacing: -0.015em; }
|
||||
|
||||
.pv-body { padding: 20px 18px; }
|
||||
.pv-subject {
|
||||
font-family: var(--font-display);
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 12px;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.pv-body-text { font-size: 13px; line-height: 1.65; white-space: pre-wrap; color: #333; }
|
||||
.pv-cta-wrap { margin-top: 18px; }
|
||||
.pv-cta {
|
||||
display: inline-block;
|
||||
padding: 9px 14px;
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
border-radius: 6px;
|
||||
text-decoration: none;
|
||||
}
|
||||
.pv-foot {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
color: #888;
|
||||
margin-top: 22px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid #eee;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,116 @@
|
||||
<script setup lang="ts">
|
||||
// Shown when a partner clicks "Enter customer" anywhere in the partner UI.
|
||||
// Forces the partner to acknowledge that every action they take inside the
|
||||
// customer org will be logged under their partner identity, and prompts for
|
||||
// an optional (but recommended) reason — captured into the customer audit log.
|
||||
|
||||
import type { CustomerOrg } from '~/data/customers'
|
||||
|
||||
const props = defineProps<{ customer: CustomerOrg | null }>()
|
||||
const emit = defineEmits<{ close: []; confirm: [reason: string] }>()
|
||||
|
||||
const reason = ref('Quarterly account review')
|
||||
|
||||
watch(
|
||||
() => props.customer?.id,
|
||||
() => {
|
||||
reason.value = 'Quarterly account review'
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal
|
||||
:open="!!customer"
|
||||
eyebrow="Partner action"
|
||||
:title="customer ? `Enter ${customer.name} as partner` : 'Enter customer'"
|
||||
size="sm"
|
||||
@close="emit('close')"
|
||||
>
|
||||
<template v-if="customer">
|
||||
<div class="cust-card">
|
||||
<div class="swatch" :style="{ background: customer.brandColor }" />
|
||||
<div class="cust-meta">
|
||||
<div class="cust-name">{{ customer.name }}</div>
|
||||
<Mono dim>{{ customer.domain }} · {{ customer.planLabel }}</Mono>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="note">
|
||||
You'll see this customer's admin console exactly as their own admins do. Any change
|
||||
you make is logged as a <b>partner action</b>, visible in their audit log with your
|
||||
name attached.
|
||||
</p>
|
||||
|
||||
<label class="field">
|
||||
<Eyebrow>Reason for entering · recommended</Eyebrow>
|
||||
<textarea
|
||||
v-model="reason"
|
||||
placeholder="e.g. Investigating support ticket #841"
|
||||
rows="3"
|
||||
/>
|
||||
</label>
|
||||
</template>
|
||||
|
||||
<template #footer>
|
||||
<UiButton variant="ghost" @click="emit('close')">Cancel</UiButton>
|
||||
<UiButton variant="primary" @click="emit('confirm', reason)">
|
||||
<template #leading><UiIcon name="arrowRight" :size="14" /></template>
|
||||
Enter customer
|
||||
</UiButton>
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.cust-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 14px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.swatch {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.cust-meta { min-width: 0; }
|
||||
.cust-name {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
letter-spacing: -0.015em;
|
||||
}
|
||||
|
||||
.note {
|
||||
font-size: 13px;
|
||||
color: var(--text-dim);
|
||||
line-height: 1.6;
|
||||
margin: 0 0 16px 0;
|
||||
}
|
||||
|
||||
.field { display: flex; flex-direction: column; gap: 8px; }
|
||||
|
||||
textarea {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
color: var(--text);
|
||||
resize: vertical;
|
||||
min-height: 60px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
textarea:focus { outline: none; border-color: var(--border-hi); }
|
||||
</style>
|
||||
@@ -0,0 +1,295 @@
|
||||
<script setup lang="ts">
|
||||
// Invite a teammate to the partner organization. Role + customer-access
|
||||
// scoping + require-MFA toggle + optional personal note. Invitations expire
|
||||
// after 7 days — the design surfaces that explicitly.
|
||||
|
||||
import { customers } from '~/data/customers'
|
||||
|
||||
defineProps<{ open: boolean }>()
|
||||
const emit = defineEmits<{ close: []; sent: [payload: { email: string; role: string }] }>()
|
||||
|
||||
const name = ref('')
|
||||
const email = ref('')
|
||||
const role = ref<'Partner admin' | 'Sales' | 'Support' | 'Billing'>('Sales')
|
||||
const access = ref<'all' | 'specific' | 'none'>('all')
|
||||
const specific = ref<string[]>([])
|
||||
const requireMfa = ref(true)
|
||||
const message = ref('')
|
||||
|
||||
const ROLE_OPTS = [
|
||||
{ v: 'Partner admin', d: 'Full access · billing · settings · all customers' },
|
||||
{ v: 'Sales', d: 'Customer orgs · provisioning · plan changes' },
|
||||
{ v: 'Support', d: 'Enter customers · view tickets · no billing' },
|
||||
{ v: 'Billing', d: 'Invoices · payouts · cannot enter customers' },
|
||||
] as const
|
||||
|
||||
const ACCESS_OPTS = [
|
||||
{ v: 'all', l: 'All customers', d: 'Including new ones added later' },
|
||||
{ v: 'specific', l: 'Specific customers', d: 'Pick from the list below' },
|
||||
{ v: 'none', l: 'No customer access', d: 'Partner-only console (for Billing role)' },
|
||||
] as const
|
||||
|
||||
function toggleCustomer(id: string) {
|
||||
if (specific.value.includes(id)) specific.value = specific.value.filter((x) => x !== id)
|
||||
else specific.value = [...specific.value, id]
|
||||
}
|
||||
|
||||
function planBadgeTone(p: string) {
|
||||
return p === 'enterprise' ? 'invert' : 'neutral'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal
|
||||
:open="open"
|
||||
eyebrow="Partner team · invite"
|
||||
title="Invite teammate"
|
||||
size="md"
|
||||
@close="emit('close')"
|
||||
>
|
||||
<div class="form">
|
||||
<div class="row-2">
|
||||
<label class="field">
|
||||
<Eyebrow>Full name</Eyebrow>
|
||||
<input v-model="name" placeholder="Anne Baslund" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<Eyebrow>Email</Eyebrow>
|
||||
<input v-model="email" placeholder="name@nordicmsp.dk" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Eyebrow>Role</Eyebrow>
|
||||
<div class="role-grid">
|
||||
<button
|
||||
v-for="o in ROLE_OPTS"
|
||||
:key="o.v"
|
||||
type="button"
|
||||
class="role-card"
|
||||
:class="{ selected: role === o.v }"
|
||||
@click="role = o.v as any"
|
||||
>
|
||||
<div class="rc-top">
|
||||
<span class="rc-name">{{ o.v }}</span>
|
||||
<Badge v-if="o.v === 'Partner admin'" tone="invert">all access</Badge>
|
||||
</div>
|
||||
<Mono dim>{{ o.d }}</Mono>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Eyebrow>Customer access</Eyebrow>
|
||||
<div class="access-list">
|
||||
<button
|
||||
v-for="o in ACCESS_OPTS"
|
||||
:key="o.v"
|
||||
type="button"
|
||||
class="access-row"
|
||||
:class="{ selected: access === o.v }"
|
||||
@click="access = o.v as any"
|
||||
>
|
||||
<span class="radio" :class="{ on: access === o.v }">
|
||||
<span v-if="access === o.v" class="radio-inner" />
|
||||
</span>
|
||||
<div class="ar-meta">
|
||||
<div class="ar-label">{{ o.l }}</div>
|
||||
<Mono dim>{{ o.d }}</Mono>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="access === 'specific'" class="picker">
|
||||
<div class="picker-head">
|
||||
<Mono dim>{{ specific.length }} of {{ customers.length }} selected</Mono>
|
||||
</div>
|
||||
<div class="picker-list">
|
||||
<label v-for="c in customers" :key="c.id" class="picker-row">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="specific.includes(c.id)"
|
||||
@change="toggleCustomer(c.id)"
|
||||
/>
|
||||
<div class="cust-swatch" :style="{ background: c.brandColor }" />
|
||||
<span class="cust-name">{{ c.name }}</span>
|
||||
<Badge :tone="planBadgeTone(c.plan)">{{ c.planLabel }}</Badge>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mfa-row">
|
||||
<div>
|
||||
<div class="mfa-label">Require MFA on first sign-in</div>
|
||||
<Mono dim>recommended for any partner role with customer access</Mono>
|
||||
</div>
|
||||
<button class="switch" :class="{ on: requireMfa }" @click="requireMfa = !requireMfa">
|
||||
<span class="thumb" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<label class="field">
|
||||
<Eyebrow>Personal note · optional</Eyebrow>
|
||||
<textarea
|
||||
v-model="message"
|
||||
rows="3"
|
||||
placeholder="Welcome to the team — looking forward to working together."
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div class="warn">
|
||||
<UiIcon name="shield" :size="14" />
|
||||
<p>
|
||||
Invitations expire after <b>7 days</b>. The teammate will create their own password and
|
||||
complete MFA enrolment before getting access.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<UiButton variant="ghost" @click="emit('close')">Cancel</UiButton>
|
||||
<UiButton
|
||||
variant="primary"
|
||||
:disabled="!email"
|
||||
@click="emit('sent', { email, role }); emit('close')"
|
||||
>
|
||||
<template #leading><UiIcon name="mail" :size="14" /></template>
|
||||
Send invitation
|
||||
</UiButton>
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.form { display: flex; flex-direction: column; gap: 16px; }
|
||||
.row-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||||
.field { display: flex; flex-direction: column; gap: 6px; }
|
||||
.field input, .field textarea {
|
||||
padding: 9px 12px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
color: var(--text);
|
||||
}
|
||||
.field textarea { resize: vertical; line-height: 1.55; }
|
||||
.field input:focus, .field textarea:focus { outline: none; border-color: var(--border-hi); }
|
||||
|
||||
.role-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.role-card {
|
||||
padding: 12px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
.role-card.selected { border-color: var(--text); background: var(--bg); }
|
||||
.rc-top { display: flex; justify-content: space-between; align-items: center; margin-bottom: 4px; }
|
||||
.rc-name { font-size: 13px; font-weight: 500; }
|
||||
|
||||
.access-list { display: flex; flex-direction: column; gap: 8px; margin-top: 8px; }
|
||||
.access-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
.access-row.selected { border-color: var(--text); background: var(--bg); }
|
||||
.radio {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 999px;
|
||||
border: 1.5px solid var(--border-hi);
|
||||
background: var(--bg);
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.radio.on { border: 4px solid var(--text); }
|
||||
.ar-meta { flex: 1; }
|
||||
.ar-label { font-size: 13px; font-weight: 500; }
|
||||
|
||||
.picker {
|
||||
margin-top: 10px;
|
||||
padding: 12px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
max-height: 240px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.picker-head { margin-bottom: 8px; }
|
||||
.picker-list { display: flex; flex-direction: column; gap: 6px; }
|
||||
.picker-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
}
|
||||
.picker-row input[type='checkbox'] { width: 14px; height: 14px; accent-color: var(--text); }
|
||||
.cust-swatch { width: 16px; height: 16px; border-radius: 3px; flex-shrink: 0; }
|
||||
.cust-name { flex: 1; }
|
||||
|
||||
.mfa-row {
|
||||
padding: 12px 14px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
.mfa-label { font-size: 13px; font-weight: 500; }
|
||||
|
||||
.switch {
|
||||
width: 36px;
|
||||
height: 20px;
|
||||
border-radius: 999px;
|
||||
background: var(--border);
|
||||
border: none;
|
||||
padding: 2px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
transition: background 150ms;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.switch.on { background: var(--text); }
|
||||
.thumb {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 999px;
|
||||
background: var(--bg);
|
||||
transition: transform 150ms;
|
||||
}
|
||||
.switch.on .thumb { transform: translateX(16px); }
|
||||
|
||||
.warn {
|
||||
padding: 12px;
|
||||
background: rgba(232, 154, 31, 0.08);
|
||||
border: 1px solid rgba(232, 154, 31, 0.24);
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
.warn :deep(svg) { color: var(--warn); margin-top: 2px; flex-shrink: 0; }
|
||||
.warn p { font-size: 12px; color: var(--text-dim); line-height: 1.55; margin: 0; }
|
||||
</style>
|
||||
@@ -0,0 +1,297 @@
|
||||
<script setup lang="ts">
|
||||
// Two-column modal for building a partner custom report. Left: name +
|
||||
// description + metric picker + filters + group-by. Right: schedule cards +
|
||||
// recipients + format + live summary.
|
||||
|
||||
defineProps<{ open: boolean }>()
|
||||
const emit = defineEmits<{ close: []; created: [name: string] }>()
|
||||
|
||||
const METRICS = [
|
||||
{ id: 'mrr', label: 'MRR', group: 'Revenue' },
|
||||
{ id: 'arr', label: 'ARR', group: 'Revenue' },
|
||||
{ id: 'margin', label: 'Partner margin', group: 'Revenue' },
|
||||
{ id: 'arpu', label: 'ARPU', group: 'Revenue' },
|
||||
{ id: 'health', label: 'Avg health score', group: 'Health' },
|
||||
{ id: 'nps', label: 'NPS', group: 'Health' },
|
||||
{ id: 'seats', label: 'Seats used', group: 'Usage' },
|
||||
{ id: 'storage', label: 'Storage used', group: 'Usage' },
|
||||
{ id: 'tickets', label: 'Tickets opened', group: 'Usage' },
|
||||
{ id: 'churn', label: 'Churn rate', group: 'Retention' },
|
||||
{ id: 'retention', label: 'Net retention', group: 'Retention' },
|
||||
{ id: 'tenure', label: 'Avg tenure', group: 'Retention' },
|
||||
] as const
|
||||
|
||||
const SCHEDULES = [
|
||||
{ v: 'weekly', l: 'Weekly', d: 'Mondays · 09:00 CET' },
|
||||
{ v: 'monthly', l: 'Monthly', d: '1st of the month · 09:00 CET' },
|
||||
{ v: 'quarterly', l: 'Quarterly', d: '1st of Jan / Apr / Jul / Oct' },
|
||||
{ v: 'ondemand', l: 'On-demand', d: 'No automatic schedule' },
|
||||
] as const
|
||||
|
||||
const name = ref('Quarterly board — Q3 2026')
|
||||
const description = ref('')
|
||||
const metrics = ref<string[]>(['mrr', 'margin', 'churn', 'health'])
|
||||
const filterPlan = ref('all')
|
||||
const filterStatus = ref('all')
|
||||
const groupBy = ref<'plan' | 'region' | 'owner' | 'none'>('plan')
|
||||
const schedule = ref<'weekly' | 'monthly' | 'quarterly' | 'ondemand'>('quarterly')
|
||||
const recipients = ref('board@dezky.com')
|
||||
const format = ref<'pdf' | 'csv' | 'xlsx'>('pdf')
|
||||
|
||||
const grouped = computed(() => {
|
||||
const out: Record<string, typeof METRICS[number][]> = {}
|
||||
for (const m of METRICS) {
|
||||
out[m.group] = out[m.group] || []
|
||||
out[m.group].push(m)
|
||||
}
|
||||
return out
|
||||
})
|
||||
|
||||
function toggle(id: string) {
|
||||
if (metrics.value.includes(id)) metrics.value = metrics.value.filter((x) => x !== id)
|
||||
else metrics.value = [...metrics.value, id]
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal
|
||||
:open="open"
|
||||
eyebrow="Partner reports · custom"
|
||||
title="New custom report"
|
||||
size="lg"
|
||||
@close="emit('close')"
|
||||
>
|
||||
<div class="grid">
|
||||
<!-- Left -->
|
||||
<div class="col">
|
||||
<label class="field">
|
||||
<Eyebrow>Report name</Eyebrow>
|
||||
<input v-model="name" />
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<Eyebrow>Description · optional</Eyebrow>
|
||||
<input v-model="description" placeholder="What's this report for?" />
|
||||
</label>
|
||||
|
||||
<div>
|
||||
<Eyebrow>Metrics · pick what to include</Eyebrow>
|
||||
<div class="metric-card">
|
||||
<div v-for="(items, group) in grouped" :key="group" class="metric-group">
|
||||
<Mono dim>{{ group }}</Mono>
|
||||
<div class="chips">
|
||||
<button
|
||||
v-for="m in items"
|
||||
:key="m.id"
|
||||
type="button"
|
||||
class="chip"
|
||||
:class="{ on: metrics.includes(m.id) }"
|
||||
@click="toggle(m.id)"
|
||||
>
|
||||
<UiIcon v-if="metrics.includes(m.id)" name="check" :size="11" :stroke-width="2.6" />
|
||||
{{ m.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row-2">
|
||||
<label class="field">
|
||||
<Eyebrow>Filter · plan</Eyebrow>
|
||||
<select v-model="filterPlan">
|
||||
<option value="all">All plans</option>
|
||||
<option value="starter">Starter</option>
|
||||
<option value="business">Business</option>
|
||||
<option value="enterprise">Enterprise</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">
|
||||
<Eyebrow>Filter · status</Eyebrow>
|
||||
<select v-model="filterStatus">
|
||||
<option value="all">All statuses</option>
|
||||
<option value="healthy">Healthy</option>
|
||||
<option value="attention">Attention</option>
|
||||
<option value="past_due">Past-due</option>
|
||||
<option value="trial">Trial</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Eyebrow>Group by</Eyebrow>
|
||||
<div class="seg">
|
||||
<button v-for="o in ['plan','region','owner','none'] as const" :key="o" :class="{ active: groupBy === o }" @click="groupBy = o">
|
||||
{{ o === 'owner' ? 'Account owner' : o }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right -->
|
||||
<div class="col">
|
||||
<div>
|
||||
<Eyebrow>Schedule</Eyebrow>
|
||||
<div class="schedule-list">
|
||||
<button
|
||||
v-for="o in SCHEDULES"
|
||||
:key="o.v"
|
||||
type="button"
|
||||
class="sched-card"
|
||||
:class="{ selected: schedule === o.v }"
|
||||
@click="schedule = o.v as any"
|
||||
>
|
||||
<span class="radio" :class="{ on: schedule === o.v }" />
|
||||
<div>
|
||||
<div class="sc-label">{{ o.l }}</div>
|
||||
<Mono dim>{{ o.d }}</Mono>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label v-if="schedule !== 'ondemand'" class="field">
|
||||
<Eyebrow>Email to</Eyebrow>
|
||||
<input v-model="recipients" placeholder="email, email, …" />
|
||||
<Mono dim>comma-separated</Mono>
|
||||
</label>
|
||||
|
||||
<div>
|
||||
<Eyebrow>Format</Eyebrow>
|
||||
<div class="seg">
|
||||
<button v-for="f in ['pdf','csv','xlsx'] as const" :key="f" :class="{ active: format === f }" @click="format = f">
|
||||
{{ f.toUpperCase() }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="summary">
|
||||
<Eyebrow>Summary</Eyebrow>
|
||||
<dl>
|
||||
<div><Mono dim>name</Mono><span>{{ name || '—' }}</span></div>
|
||||
<div><Mono dim>metrics</Mono><span>{{ metrics.length }} selected</span></div>
|
||||
<div><Mono dim>grouped by</Mono><span>{{ groupBy }}</span></div>
|
||||
<div><Mono dim>delivery</Mono><span>{{ schedule }} · {{ format.toUpperCase() }}</span></div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<UiButton variant="ghost" @click="emit('close')">Cancel</UiButton>
|
||||
<div style="flex:1" />
|
||||
<UiButton variant="secondary">Save as draft</UiButton>
|
||||
<UiButton
|
||||
variant="primary"
|
||||
:disabled="!name || metrics.length === 0"
|
||||
@click="emit('created', name); emit('close')"
|
||||
>
|
||||
<template #leading><UiIcon name="check" :size="14" /></template>
|
||||
Create report
|
||||
</UiButton>
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.grid { display: grid; grid-template-columns: 1.4fr 1fr; gap: 20px; }
|
||||
.col { display: flex; flex-direction: column; gap: 16px; }
|
||||
|
||||
.field { display: flex; flex-direction: column; gap: 6px; }
|
||||
.field input, .field select {
|
||||
padding: 9px 12px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
color: var(--text);
|
||||
}
|
||||
.field input:focus, .field select:focus { outline: none; border-color: var(--border-hi); }
|
||||
|
||||
.row-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
|
||||
|
||||
.metric-card {
|
||||
margin-top: 8px;
|
||||
padding: 12px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.metric-group { display: flex; flex-direction: column; gap: 6px; }
|
||||
.chips { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 4px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.chip.on { background: var(--text); color: var(--bg); border-color: var(--text); }
|
||||
|
||||
.seg {
|
||||
display: flex;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 2px;
|
||||
background: var(--surface);
|
||||
}
|
||||
.seg button {
|
||||
flex: 1;
|
||||
padding: 6px 0;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
.seg button.active { background: var(--text); color: var(--bg); }
|
||||
|
||||
.schedule-list { display: flex; flex-direction: column; gap: 6px; margin-top: 8px; }
|
||||
.sched-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
text-align: left;
|
||||
}
|
||||
.sched-card.selected { border-color: var(--text); background: var(--bg); }
|
||||
.radio {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 999px;
|
||||
border: 1.5px solid var(--border-hi);
|
||||
background: var(--bg);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.radio.on { border: 4px solid var(--text); }
|
||||
.sc-label { font-size: 13px; font-weight: 500; }
|
||||
|
||||
.summary {
|
||||
padding: 12px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
}
|
||||
.summary dl { display: flex; flex-direction: column; gap: 6px; margin: 8px 0 0; }
|
||||
.summary dl div { display: flex; justify-content: space-between; font-size: 12px; }
|
||||
.summary dl span { color: var(--text); }
|
||||
</style>
|
||||
@@ -0,0 +1,50 @@
|
||||
<script setup lang="ts">
|
||||
// Tiny inline SVG sparkline. Takes a series of numbers and renders a stroked
|
||||
// polyline plus a faint area fill underneath. Used on the partner dashboard
|
||||
// (90-day MRR trend) and on the reports/revenue tab.
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
values: number[]
|
||||
width?: number
|
||||
height?: number
|
||||
stroke?: string
|
||||
fill?: string
|
||||
strokeWidth?: number
|
||||
showDot?: boolean
|
||||
}>(),
|
||||
{
|
||||
width: 420,
|
||||
height: 64,
|
||||
stroke: 'var(--text)',
|
||||
fill: 'var(--row-hover)',
|
||||
strokeWidth: 1.4,
|
||||
showDot: true,
|
||||
},
|
||||
)
|
||||
|
||||
const geometry = computed(() => {
|
||||
const data = props.values
|
||||
if (!data.length) return { line: '', area: '', last: { x: 0, y: 0 }, min: 0, max: 0 }
|
||||
const min = Math.min(...data)
|
||||
const max = Math.max(...data)
|
||||
const range = max - min || 1
|
||||
const pts = data.map((v, i) => {
|
||||
const x = (i / (data.length - 1)) * props.width
|
||||
const y = props.height - ((v - min) / range) * (props.height - 6) - 3
|
||||
return [x, y] as const
|
||||
})
|
||||
const line = pts.map(([x, y], i) => `${i === 0 ? 'M' : 'L'} ${x.toFixed(1)} ${y.toFixed(1)}`).join(' ')
|
||||
const area = `${line} L ${props.width} ${props.height} L 0 ${props.height} Z`
|
||||
const last = { x: pts[pts.length - 1][0], y: pts[pts.length - 1][1] }
|
||||
return { line, area, last, min, max }
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg :width="width" :height="height" :viewBox="`0 0 ${width} ${height}`" preserveAspectRatio="none" style="display:block">
|
||||
<path :d="geometry.area" :fill="fill" />
|
||||
<path :d="geometry.line" fill="none" :stroke="stroke" :stroke-width="strokeWidth" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<circle v-if="showDot" :cx="geometry.last.x" :cy="geometry.last.y" :r="3" :fill="stroke" />
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,358 @@
|
||||
<script setup lang="ts">
|
||||
// Right-side panel with full detail on a partner teammate. Three tabs:
|
||||
// • Access & role — what they can do, which customers they can enter
|
||||
// • Activity — last 5 partner actions with timestamps + IPs
|
||||
// • Security — MFA card, active sessions, API tokens, suspend callout
|
||||
|
||||
import { customers } from '~/data/customers'
|
||||
|
||||
export interface TeamMember {
|
||||
id: string
|
||||
name: string
|
||||
email: string
|
||||
role: string
|
||||
access: 'all' | 'specific' | 'none' | string
|
||||
mfa: string
|
||||
lastSeen: string
|
||||
isOwner?: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<{ member: TeamMember | null }>()
|
||||
const emit = defineEmits<{ close: [] }>()
|
||||
|
||||
const tab = ref<'access' | 'activity' | 'security'>('access')
|
||||
|
||||
watch(
|
||||
() => props.member?.id,
|
||||
() => { tab.value = 'access' },
|
||||
)
|
||||
|
||||
const tabs = computed(() => [
|
||||
{ value: 'access', label: 'Access & role' },
|
||||
{ value: 'activity', label: 'Activity', count: 5 },
|
||||
{ value: 'security', label: 'Security' },
|
||||
])
|
||||
|
||||
const recentActions = [
|
||||
{ when: '12 min ago', action: 'entered customer', target: 'Acme Industries', ip: '92.43.118.4 · København' },
|
||||
{ when: '1 h ago', action: 'invited user', target: 'magnus@acme.dk', ip: '92.43.118.4 · København' },
|
||||
{ when: 'Yesterday', action: 'changed plan', target: 'Bygherre · Business → Business+', ip: '92.43.118.4 · København' },
|
||||
{ when: '3 days ago', action: 'signed in', target: 'partner console', ip: '78.32.4.91 · København' },
|
||||
{ when: '1 week ago', action: 'provisioned', target: 'Henriksen Revision · new customer', ip: '92.43.118.4 · København' },
|
||||
]
|
||||
|
||||
function permissionsFor(role: string) {
|
||||
return [
|
||||
{ l: 'View customer dashboards', allowed: true },
|
||||
{ l: 'Enter customer as partner', allowed: role !== 'Billing' },
|
||||
{ l: 'Provision new customers', allowed: role === 'Partner admin' || role === 'Sales' },
|
||||
{ l: 'Change customer plans', allowed: role === 'Partner admin' || role === 'Sales' },
|
||||
{ l: 'Manage partner billing', allowed: role === 'Partner admin' || role === 'Billing' },
|
||||
{ l: 'Manage partner team', allowed: role === 'Partner admin' },
|
||||
{ l: 'Edit partner branding', allowed: role === 'Partner admin' },
|
||||
]
|
||||
}
|
||||
|
||||
const isOwner = computed(() => !!props.member?.isOwner)
|
||||
|
||||
const accessText = computed(() => {
|
||||
if (!props.member) return ''
|
||||
if (props.member.access === 'all') return `all (${customers.length})`
|
||||
if (props.member.access === 'none') return 'no access'
|
||||
// Specific: just say first N customers
|
||||
return `${customers.length - 5} of ${customers.length}`
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SidePanel
|
||||
:open="!!member"
|
||||
width="lg"
|
||||
eyebrow="Partner teammate"
|
||||
:title="member?.name || ''"
|
||||
@close="emit('close')"
|
||||
>
|
||||
<template #header>
|
||||
<!-- header handled by SidePanel slot defaults -->
|
||||
</template>
|
||||
|
||||
<div v-if="member" class="profile-head">
|
||||
<Avatar :name="member.name" :size="48" />
|
||||
<div class="ph-meta">
|
||||
<div class="ph-name">{{ member.name }}</div>
|
||||
<Mono dim>{{ member.email }}</Mono>
|
||||
</div>
|
||||
<Badge :tone="member.role === 'Partner admin' ? 'invert' : 'neutral'">{{ member.role }}</Badge>
|
||||
</div>
|
||||
|
||||
<div v-if="member" class="profile-stats">
|
||||
<div>
|
||||
<Eyebrow>Customer access</Eyebrow>
|
||||
<div class="ps-val">{{ accessText }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<Eyebrow>MFA</Eyebrow>
|
||||
<div class="ps-val"><Badge tone="ok" dot>enabled</Badge></div>
|
||||
</div>
|
||||
<div>
|
||||
<Eyebrow>Last seen</Eyebrow>
|
||||
<div class="ps-val">{{ member.lastSeen }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tabs-wrap">
|
||||
<Tabs v-model="tab" :items="tabs" />
|
||||
</div>
|
||||
|
||||
<div v-if="member && tab === 'access'" class="tab-body">
|
||||
<div class="field">
|
||||
<Eyebrow>Role</Eyebrow>
|
||||
<div class="role-grid">
|
||||
<div
|
||||
v-for="r in ['Partner admin', 'Sales', 'Support', 'Billing']"
|
||||
:key="r"
|
||||
class="role-card"
|
||||
:class="{ selected: member.role === r }"
|
||||
>
|
||||
<span>{{ r }}</span>
|
||||
<Badge v-if="member.role === r" tone="invert">current</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Eyebrow>Customer access</Eyebrow>
|
||||
<div class="access-card">
|
||||
<div class="ac-head">
|
||||
<Mono dim>{{ accessText }}</Mono>
|
||||
<UiButton size="sm" variant="ghost">Change</UiButton>
|
||||
</div>
|
||||
<div class="ac-list">
|
||||
<div
|
||||
v-for="c in customers.slice(0, member.access === 'all' ? customers.length : 3)"
|
||||
:key="c.id"
|
||||
class="ac-row"
|
||||
>
|
||||
<UiIcon name="check" :size="11" :stroke-width="2.5" />
|
||||
<div class="cust-swatch" :style="{ background: c.brandColor }" />
|
||||
<span class="cust-name">{{ c.name }}</span>
|
||||
<Mono dim>{{ c.planLabel }}</Mono>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Eyebrow>Permissions in {{ member.role }}</Eyebrow>
|
||||
<div class="perm-list">
|
||||
<div v-for="p in permissionsFor(member.role)" :key="p.l" class="perm-row">
|
||||
<UiIcon :name="p.allowed ? 'check' : 'x'" :size="12" :stroke-width="p.allowed ? 2.5 : 2" />
|
||||
<span :class="{ muted: !p.allowed }">{{ p.l }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="member && tab === 'activity'" class="tab-body">
|
||||
<div class="activity-list">
|
||||
<div v-for="(a, i) in recentActions" :key="i" class="activity-row">
|
||||
<div class="activity-icon">
|
||||
<UiIcon
|
||||
:name="a.action.startsWith('signed') ? 'shield' : a.action.startsWith('entered') ? 'arrowRight' : a.action.startsWith('invited') ? 'users' : a.action.startsWith('provisioned') ? 'plus' : 'brush'"
|
||||
:size="12"
|
||||
/>
|
||||
</div>
|
||||
<div class="activity-meta">
|
||||
<div class="ar-top">
|
||||
<Mono dim>{{ a.action }}</Mono>
|
||||
<span>{{ a.target }}</span>
|
||||
</div>
|
||||
<Mono dim>{{ a.ip }}</Mono>
|
||||
</div>
|
||||
<Mono dim>{{ a.when }}</Mono>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="member && tab === 'security'" class="tab-body">
|
||||
<div class="sec-row">
|
||||
<UiIcon name="shield" :size="16" />
|
||||
<div class="sec-meta">
|
||||
<div class="sec-label">MFA enabled</div>
|
||||
<Mono dim>TOTP · enrolled 12 Jan 2026</Mono>
|
||||
</div>
|
||||
<UiButton size="sm" variant="ghost">Reset</UiButton>
|
||||
</div>
|
||||
<div class="sec-row">
|
||||
<UiIcon name="device" :size="16" />
|
||||
<div class="sec-meta">
|
||||
<div class="sec-label">3 active sessions</div>
|
||||
<Mono dim>Chrome · macOS · København</Mono>
|
||||
</div>
|
||||
<UiButton size="sm" variant="ghost">View · sign out</UiButton>
|
||||
</div>
|
||||
<div class="sec-row">
|
||||
<UiIcon name="key" :size="16" />
|
||||
<div class="sec-meta">
|
||||
<div class="sec-label">API tokens</div>
|
||||
<Mono dim>1 personal token · last used 2 d ago</Mono>
|
||||
</div>
|
||||
<UiButton size="sm" variant="ghost">Manage</UiButton>
|
||||
</div>
|
||||
|
||||
<div class="danger-callout">
|
||||
<UiIcon name="shield" :size="14" />
|
||||
<div class="dc-meta">
|
||||
<div class="dc-label">Suspend account</div>
|
||||
<p>Immediately revoke access. Sessions are terminated and the teammate cannot sign back in. Reversible.</p>
|
||||
</div>
|
||||
<UiButton size="sm" variant="secondary" :disabled="isOwner">Suspend</UiButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<UiButton variant="danger" :disabled="isOwner">
|
||||
<template #leading><UiIcon name="trash" :size="14" /></template>
|
||||
Remove from team
|
||||
</UiButton>
|
||||
<div style="flex:1" />
|
||||
<UiButton variant="secondary">
|
||||
<template #leading><UiIcon name="refresh" :size="14" /></template>
|
||||
Reset password
|
||||
</UiButton>
|
||||
<UiButton variant="primary" @click="emit('close')">
|
||||
<template #leading><UiIcon name="check" :size="14" /></template>
|
||||
Save
|
||||
</UiButton>
|
||||
</template>
|
||||
</SidePanel>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.profile-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.ph-meta { flex: 1; min-width: 0; }
|
||||
.ph-name {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 600;
|
||||
font-size: 18px;
|
||||
letter-spacing: -0.015em;
|
||||
}
|
||||
|
||||
.profile-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 16px;
|
||||
padding-bottom: 22px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.ps-val { font-size: 13px; font-weight: 500; margin-top: 4px; }
|
||||
|
||||
.tabs-wrap { margin: -2px -24px 0; padding: 0 24px; border-bottom: 1px solid var(--border); }
|
||||
|
||||
.tab-body { padding-top: 22px; display: flex; flex-direction: column; gap: 16px; }
|
||||
|
||||
.field { display: flex; flex-direction: column; gap: 8px; }
|
||||
|
||||
.role-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
|
||||
.role-card {
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--surface);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.role-card.selected { border-color: var(--text); background: var(--bg); }
|
||||
|
||||
.access-card {
|
||||
padding: 12px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.ac-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; }
|
||||
.ac-list { display: flex; flex-direction: column; gap: 6px; }
|
||||
.ac-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.ac-row :deep(svg) { color: var(--ok); }
|
||||
.cust-swatch { width: 14px; height: 14px; border-radius: 3px; flex-shrink: 0; }
|
||||
.cust-name { flex: 1; }
|
||||
|
||||
.perm-list { display: flex; flex-direction: column; gap: 4px; margin-top: 8px; }
|
||||
.perm-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 6px 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.perm-row :deep(svg) { color: var(--ok); }
|
||||
.perm-row .muted { color: var(--text-mute); }
|
||||
.perm-row :deep(svg.muted) { color: var(--text-mute); }
|
||||
|
||||
.activity-list { display: flex; flex-direction: column; gap: 8px; }
|
||||
.activity-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
}
|
||||
.activity-icon {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 999px;
|
||||
background: var(--bg);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-mute);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.activity-meta { flex: 1; min-width: 0; }
|
||||
.ar-top { display: flex; gap: 8px; align-items: baseline; flex-wrap: wrap; }
|
||||
.ar-top span { font-size: 13px; }
|
||||
|
||||
.sec-row {
|
||||
padding: 12px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.sec-row :deep(svg) { color: var(--text-mute); flex-shrink: 0; }
|
||||
.sec-meta { flex: 1; }
|
||||
.sec-label { font-size: 13px; font-weight: 500; }
|
||||
|
||||
.danger-callout {
|
||||
margin-top: 8px;
|
||||
padding: 14px;
|
||||
background: rgba(226, 48, 48, 0.06);
|
||||
border: 1px solid rgba(226, 48, 48, 0.22);
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.danger-callout :deep(svg) { color: var(--bad); margin-top: 2px; flex-shrink: 0; }
|
||||
.dc-meta { flex: 1; }
|
||||
.dc-label { font-size: 13px; font-weight: 600; color: var(--bad); }
|
||||
.dc-meta p { font-size: 12px; color: var(--text-dim); line-height: 1.5; margin: 4px 0 0; }
|
||||
</style>
|
||||
Reference in New Issue
Block a user