0bd4e5498e
- 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
761 lines
26 KiB
Vue
761 lines
26 KiB
Vue
<script setup lang="ts">
|
|
import type { Partner, PartnerStatus } from '~/types/partner'
|
|
import type { Tenant } from '~/types/tenant'
|
|
|
|
const route = useRoute()
|
|
const slug = computed(() => route.params.slug as string)
|
|
|
|
const { data: partner, refresh: refreshPartner } = await useFetch<Partner>(
|
|
() => `/api/partners/${slug.value}`,
|
|
{ watch: [slug] },
|
|
)
|
|
|
|
const { data: customers, refresh: refreshCustomers } = await useFetch<Tenant[]>(
|
|
() => `/api/partners/${slug.value}/tenants`,
|
|
{ default: () => [], watch: [slug] },
|
|
)
|
|
|
|
const STATUS_TONE: Record<PartnerStatus, 'ok' | 'warn' | 'neutral' | 'bad'> = {
|
|
active: 'ok',
|
|
'in-negotiation': 'warn',
|
|
paused: 'neutral',
|
|
terminated: 'bad',
|
|
}
|
|
const STATUSES: PartnerStatus[] = ['in-negotiation', 'active', 'paused', 'terminated']
|
|
|
|
const TENANT_STATUS_TONE = {
|
|
active: 'ok', pending: 'warn', suspended: 'bad', deleted: 'neutral',
|
|
} as const
|
|
|
|
// ── Inline edit mode ──────────────────────────────────────────────────────
|
|
// Cards flip from read-only display to inputs when `editing` is true. The
|
|
// draft object shadows the live partner data; save() PATCHes only the diff
|
|
// so backend audit metadata stays accurate (the partner.updated event lists
|
|
// only fields the operator actually changed). Cancel discards the draft.
|
|
|
|
interface PartnerDraft {
|
|
name: string
|
|
domain: string
|
|
status: PartnerStatus
|
|
marginPct: number
|
|
contactInfo: {
|
|
primaryName: string
|
|
primaryEmail: string
|
|
billingEmail: string
|
|
}
|
|
billingInfo: {
|
|
companyName: string
|
|
vatId: string
|
|
country: string
|
|
contactEmail: string
|
|
}
|
|
}
|
|
|
|
const editing = ref(false)
|
|
const draft = ref<PartnerDraft | null>(null)
|
|
const saving = ref(false)
|
|
const saveError = ref<string | null>(null)
|
|
|
|
function cloneEditable(p: Partner): PartnerDraft {
|
|
return {
|
|
name: p.name,
|
|
domain: p.domain,
|
|
status: p.status,
|
|
marginPct: p.marginPct,
|
|
contactInfo: {
|
|
primaryName: p.contactInfo?.primaryName ?? '',
|
|
primaryEmail: p.contactInfo?.primaryEmail ?? '',
|
|
billingEmail: p.contactInfo?.billingEmail ?? '',
|
|
},
|
|
billingInfo: {
|
|
companyName: p.billingInfo?.companyName ?? '',
|
|
vatId: p.billingInfo?.vatId ?? '',
|
|
country: p.billingInfo?.country ?? '',
|
|
contactEmail: p.billingInfo?.contactEmail ?? '',
|
|
},
|
|
}
|
|
}
|
|
|
|
// Build a PATCH body containing only fields that differ from the server
|
|
// state. Returns null when nothing changed. Nested objects (contactInfo /
|
|
// billingInfo) are included whole when any child key changed — Authentik's
|
|
// backend update path replaces nested objects rather than merging keys, so
|
|
// sending the full subobject is the safe contract.
|
|
function partnerDiff(orig: Partner, next: PartnerDraft): Record<string, unknown> | null {
|
|
const out: Record<string, unknown> = {}
|
|
if (orig.name !== next.name) out.name = next.name
|
|
if (orig.domain !== next.domain) out.domain = next.domain
|
|
if (orig.status !== next.status) out.status = next.status
|
|
if (orig.marginPct !== next.marginPct) out.marginPct = next.marginPct
|
|
const ci = orig.contactInfo ?? {}
|
|
if (
|
|
(ci.primaryName ?? '') !== next.contactInfo.primaryName ||
|
|
(ci.primaryEmail ?? '') !== next.contactInfo.primaryEmail ||
|
|
(ci.billingEmail ?? '') !== next.contactInfo.billingEmail
|
|
) {
|
|
out.contactInfo = next.contactInfo
|
|
}
|
|
const bi = orig.billingInfo ?? {}
|
|
if (
|
|
(bi.companyName ?? '') !== next.billingInfo.companyName ||
|
|
(bi.vatId ?? '') !== next.billingInfo.vatId ||
|
|
(bi.country ?? '') !== next.billingInfo.country ||
|
|
(bi.contactEmail ?? '') !== next.billingInfo.contactEmail
|
|
) {
|
|
out.billingInfo = next.billingInfo
|
|
}
|
|
return Object.keys(out).length ? out : null
|
|
}
|
|
|
|
const dirty = computed(() => {
|
|
if (!editing.value || !draft.value || !partner.value) return false
|
|
return partnerDiff(partner.value, draft.value) !== null
|
|
})
|
|
|
|
// Client-side validation. Backend re-validates via DTO decorators — this is
|
|
// just to keep the Save button honest about whether a submit would succeed.
|
|
const valid = computed(() => {
|
|
if (!draft.value) return false
|
|
const d = draft.value
|
|
if (d.name.length < 2 || d.name.length > 120) return false
|
|
if (d.domain.length < 3 || d.domain.length > 120) return false
|
|
if (d.marginPct < 0 || d.marginPct > 100 || !Number.isInteger(d.marginPct)) return false
|
|
if (d.billingInfo.vatId.length > 40) return false
|
|
if (d.billingInfo.country.length !== 0 && d.billingInfo.country.length !== 2) return false
|
|
return true
|
|
})
|
|
|
|
function startEdit() {
|
|
if (!partner.value) return
|
|
draft.value = cloneEditable(partner.value)
|
|
editing.value = true
|
|
saveError.value = null
|
|
}
|
|
|
|
function cancelEdit() {
|
|
if (dirty.value) {
|
|
const ok = window.confirm('Discard unsaved changes?')
|
|
if (!ok) return
|
|
}
|
|
editing.value = false
|
|
draft.value = null
|
|
saveError.value = null
|
|
}
|
|
|
|
async function save() {
|
|
if (!partner.value || !draft.value) return
|
|
const patch = partnerDiff(partner.value, draft.value)
|
|
if (!patch) return
|
|
saving.value = true
|
|
saveError.value = null
|
|
try {
|
|
await $fetch(`/api/partners/${slug.value}`, { method: 'PATCH', body: patch })
|
|
await refreshPartner()
|
|
editing.value = false
|
|
draft.value = null
|
|
} catch (err: unknown) {
|
|
const e = err as { data?: { data?: { message?: string | string[] }; message?: string } }
|
|
const m = e.data?.data?.message ?? e.data?.message
|
|
saveError.value = Array.isArray(m) ? m.join('; ') : (m ?? 'Save failed')
|
|
} finally {
|
|
saving.value = false
|
|
}
|
|
}
|
|
|
|
// ── Attach tenant modal ───────────────────────────────────────────────────
|
|
const attachOpen = ref(false)
|
|
const attachBusy = ref(false)
|
|
const attachError = ref<string | null>(null)
|
|
const selectedSlug = ref('')
|
|
|
|
const { data: allTenants } = await useFetch<Tenant[]>('/api/tenants', { default: () => [] })
|
|
|
|
const attachable = computed(() => {
|
|
return (allTenants.value ?? []).filter(
|
|
(t) => !t.partnerId && t.status !== 'deleted',
|
|
)
|
|
})
|
|
|
|
function openAttach() {
|
|
selectedSlug.value = attachable.value[0]?.slug ?? ''
|
|
attachError.value = null
|
|
attachOpen.value = true
|
|
}
|
|
|
|
async function submitAttach() {
|
|
if (!selectedSlug.value || !partner.value) return
|
|
attachBusy.value = true
|
|
attachError.value = null
|
|
try {
|
|
await $fetch(`/api/tenants/${selectedSlug.value}`, {
|
|
method: 'PATCH',
|
|
body: { partnerId: partner.value._id },
|
|
})
|
|
attachOpen.value = false
|
|
await Promise.all([refreshPartner(), refreshCustomers()])
|
|
} catch (err: unknown) {
|
|
const e = err as { data?: { data?: { message?: string }; message?: string } }
|
|
attachError.value = e.data?.data?.message ?? e.data?.message ?? String(err)
|
|
} finally {
|
|
attachBusy.value = false
|
|
}
|
|
}
|
|
|
|
// ── Detach confirm ────────────────────────────────────────────────────────
|
|
const detachTarget = ref<Tenant | null>(null)
|
|
const detachBusy = ref(false)
|
|
const detachError = ref<string | null>(null)
|
|
|
|
async function confirmDetach() {
|
|
if (!detachTarget.value) return
|
|
detachBusy.value = true
|
|
detachError.value = null
|
|
try {
|
|
await $fetch(`/api/tenants/${detachTarget.value.slug}`, {
|
|
method: 'PATCH',
|
|
body: { partnerId: null },
|
|
})
|
|
detachTarget.value = null
|
|
await Promise.all([refreshPartner(), refreshCustomers()])
|
|
} catch (err: unknown) {
|
|
const e = err as { data?: { data?: { message?: string }; message?: string } }
|
|
detachError.value = e.data?.data?.message ?? e.data?.message ?? String(err)
|
|
} finally {
|
|
detachBusy.value = false
|
|
}
|
|
}
|
|
|
|
// ── Team (partner users) ──────────────────────────────────────────────────
|
|
// Lists users whose User.partnerId === this partner. Invite flow surfaces a
|
|
// modal that POSTs to /api/partners/:slug/users, which proxies platform-api
|
|
// and creates the Authentik user + group + local User doc atomically.
|
|
|
|
interface PartnerUser {
|
|
_id: string
|
|
authentikSubjectId: string
|
|
email: string
|
|
name: string
|
|
role: string
|
|
active: boolean
|
|
lastLoginAt?: string
|
|
createdAt?: string
|
|
}
|
|
|
|
const { data: team, refresh: refreshTeam } = await useFetch<PartnerUser[]>(
|
|
() => `/api/partners/${slug.value}/users`,
|
|
{ default: () => [], watch: [slug] },
|
|
)
|
|
|
|
const inviteOpen = ref(false)
|
|
|
|
function onInvited() {
|
|
// Don't close the modal — the user needs to see the recovery link / temp
|
|
// password. Just refresh the team list in the background so the new user
|
|
// is visible once they click Done.
|
|
void refreshTeam()
|
|
}
|
|
|
|
// ── Soft-terminate partner ────────────────────────────────────────────────
|
|
const terminateOpen = ref(false)
|
|
const terminateBusy = ref(false)
|
|
const terminateError = ref<string | null>(null)
|
|
|
|
async function confirmTerminate() {
|
|
terminateBusy.value = true
|
|
terminateError.value = null
|
|
try {
|
|
await $fetch(`/api/partners/${slug.value}`, { method: 'DELETE' })
|
|
await navigateTo('/partners')
|
|
} catch (err: unknown) {
|
|
const e = err as { data?: { data?: { message?: string }; message?: string } }
|
|
terminateError.value = e.data?.data?.message ?? e.data?.message ?? String(err)
|
|
terminateBusy.value = false
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<div v-if="partner">
|
|
<PageHeader
|
|
:eyebrow="`Partner · ${partner.slug}`"
|
|
:title="partner.name"
|
|
:subtitle="`${partner.customers} customer(s) · ${partner.marginPct}% revenue share · partner since ${partner.partnershipStartedAt ? new Date(partner.partnershipStartedAt).toISOString().slice(0, 10) : new Date(partner.createdAt).toISOString().slice(0, 10)}`"
|
|
>
|
|
<template #actions>
|
|
<Badge :tone="STATUS_TONE[partner.status]" dot>{{ partner.status }}</Badge>
|
|
<template v-if="!editing">
|
|
<UiButton variant="secondary">
|
|
<template #leading><UiIcon name="external" :size="13" /></template>
|
|
Open {{ partner.domain }}
|
|
</UiButton>
|
|
<UiButton variant="primary" @click="startEdit">
|
|
<template #leading><UiIcon name="more" :size="13" /></template>
|
|
Edit
|
|
</UiButton>
|
|
</template>
|
|
<template v-else>
|
|
<UiButton variant="ghost" :disabled="saving" @click="cancelEdit">Cancel</UiButton>
|
|
<UiButton
|
|
variant="primary"
|
|
:disabled="!dirty || !valid || saving"
|
|
@click="save"
|
|
>
|
|
{{ saving ? 'Saving…' : 'Save changes' }}
|
|
</UiButton>
|
|
</template>
|
|
</template>
|
|
</PageHeader>
|
|
|
|
<div class="stage">
|
|
<div class="grid">
|
|
<Card>
|
|
<h2>Contract</h2>
|
|
<!-- Read-only view -->
|
|
<dl v-if="!editing || !draft">
|
|
<div class="dl-row"><dt>Slug</dt><dd><Mono>{{ partner.slug }}</Mono></dd></div>
|
|
<div class="dl-row"><dt>Name</dt><dd>{{ partner.name }}</dd></div>
|
|
<div class="dl-row"><dt>Domain</dt><dd><Mono>{{ partner.domain }}</Mono></dd></div>
|
|
<div class="dl-row"><dt>Status</dt><dd><Badge :tone="STATUS_TONE[partner.status]" dot>{{ partner.status }}</Badge></dd></div>
|
|
<div class="dl-row"><dt>Margin</dt><dd><Mono>{{ partner.marginPct }}%</Mono></dd></div>
|
|
<div class="dl-row"><dt>Customers</dt><dd>
|
|
<span class="num">{{ partner.customers }}</span>
|
|
<Mono dim>(MRR aggregation ships when Subscription gains pricing)</Mono>
|
|
</dd></div>
|
|
</dl>
|
|
<!-- Edit view -->
|
|
<dl v-else>
|
|
<div class="dl-row"><dt>Slug</dt><dd><Mono>{{ partner.slug }}</Mono><Mono dim>(immutable)</Mono></dd></div>
|
|
<div class="dl-row">
|
|
<dt>Name</dt>
|
|
<dd><input v-model="draft.name" class="field" type="text" maxlength="120" :disabled="saving" /></dd>
|
|
</div>
|
|
<div class="dl-row">
|
|
<dt>Domain</dt>
|
|
<dd><input v-model="draft.domain" class="field" type="text" maxlength="120" :disabled="saving" /></dd>
|
|
</div>
|
|
<div class="dl-row">
|
|
<dt>Status</dt>
|
|
<dd>
|
|
<div class="seg four">
|
|
<button
|
|
v-for="s in STATUSES"
|
|
:key="s"
|
|
type="button"
|
|
:class="{ on: draft.status === s }"
|
|
:disabled="saving"
|
|
@click="draft.status = s"
|
|
>{{ s }}</button>
|
|
</div>
|
|
</dd>
|
|
</div>
|
|
<div class="dl-row">
|
|
<dt>Margin</dt>
|
|
<dd class="slider-cell">
|
|
<input
|
|
v-model.number="draft.marginPct"
|
|
type="range"
|
|
min="0"
|
|
max="100"
|
|
step="1"
|
|
class="slider"
|
|
:disabled="saving"
|
|
/>
|
|
<Mono class="pct">{{ draft.marginPct }}%</Mono>
|
|
</dd>
|
|
</div>
|
|
</dl>
|
|
</Card>
|
|
|
|
<Card>
|
|
<h2>Contact</h2>
|
|
<!-- Read-only view -->
|
|
<dl v-if="!editing || !draft">
|
|
<div class="dl-row"><dt>Primary</dt><dd>{{ partner.contactInfo.primaryName || '—' }}</dd></div>
|
|
<div class="dl-row"><dt>Email</dt><dd><Mono :dim="!partner.contactInfo.primaryEmail">{{ partner.contactInfo.primaryEmail || '—' }}</Mono></dd></div>
|
|
<div class="dl-row"><dt>Billing</dt><dd><Mono :dim="!partner.contactInfo.billingEmail">{{ partner.contactInfo.billingEmail || '—' }}</Mono></dd></div>
|
|
<div class="dl-row"><dt>Company</dt><dd>{{ partner.billingInfo.companyName || '—' }}</dd></div>
|
|
<div class="dl-row"><dt>VAT</dt><dd><Mono :dim="!partner.billingInfo.vatId">{{ partner.billingInfo.vatId || '—' }}</Mono></dd></div>
|
|
<div class="dl-row"><dt>Country</dt><dd><Mono :dim="!partner.billingInfo.country">{{ partner.billingInfo.country || '—' }}</Mono></dd></div>
|
|
</dl>
|
|
<!-- Edit view -->
|
|
<dl v-else>
|
|
<div class="dl-row">
|
|
<dt>Primary</dt>
|
|
<dd><input v-model="draft.contactInfo.primaryName" class="field" type="text" maxlength="200" :disabled="saving" /></dd>
|
|
</div>
|
|
<div class="dl-row">
|
|
<dt>Email</dt>
|
|
<dd><input v-model="draft.contactInfo.primaryEmail" class="field mono" type="email" :disabled="saving" /></dd>
|
|
</div>
|
|
<div class="dl-row">
|
|
<dt>Billing</dt>
|
|
<dd><input v-model="draft.contactInfo.billingEmail" class="field mono" type="email" :disabled="saving" /></dd>
|
|
</div>
|
|
<div class="dl-row">
|
|
<dt>Company</dt>
|
|
<dd><input v-model="draft.billingInfo.companyName" class="field" type="text" maxlength="200" :disabled="saving" /></dd>
|
|
</div>
|
|
<div class="dl-row">
|
|
<dt>VAT</dt>
|
|
<dd><input v-model="draft.billingInfo.vatId" class="field mono" type="text" maxlength="40" :disabled="saving" /></dd>
|
|
</div>
|
|
<div class="dl-row">
|
|
<dt>Country</dt>
|
|
<dd><CountrySelect v-model="draft.billingInfo.country" :disabled="saving" /></dd>
|
|
</div>
|
|
</dl>
|
|
</Card>
|
|
</div>
|
|
|
|
<p v-if="saveError" class="err inline-err">{{ saveError }}</p>
|
|
|
|
<Card :pad="0">
|
|
<div class="card-head padded">
|
|
<div>
|
|
<h2>Customers</h2>
|
|
<p class="hint">Tenants whose <code>partnerId</code> points at this partner.</p>
|
|
</div>
|
|
<UiButton variant="primary" :disabled="attachable.length === 0" @click="openAttach">
|
|
<template #leading><UiIcon name="plus" :size="13" /></template>
|
|
Attach tenant
|
|
</UiButton>
|
|
</div>
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Tenant</th><th>Status</th><th>Plan</th><th>Domains</th><th class="th-right">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-if="(customers ?? []).length === 0" class="empty">
|
|
<td colspan="5">
|
|
<span class="empty-inner">No customers under this partner yet.</span>
|
|
</td>
|
|
</tr>
|
|
<tr v-for="t in (customers ?? [])" :key="t._id">
|
|
<td>
|
|
<NuxtLink class="link" :to="`/tenants/${t.slug}`">
|
|
<div class="cell-name">{{ t.name }}</div>
|
|
<Mono dim>{{ t.slug }}</Mono>
|
|
</NuxtLink>
|
|
</td>
|
|
<td><Badge :tone="TENANT_STATUS_TONE[t.status]" dot>{{ t.status }}</Badge></td>
|
|
<td><Badge tone="neutral">{{ t.plan }}</Badge></td>
|
|
<td><Mono :dim="!t.domains.length">{{ t.domains[0] || '—' }}</Mono></td>
|
|
<td class="td-right">
|
|
<UiButton variant="danger" size="sm" @click="detachTarget = t; detachError = null">Detach</UiButton>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</Card>
|
|
|
|
<Card :pad="0">
|
|
<div class="card-head padded">
|
|
<div>
|
|
<h2>Team</h2>
|
|
<p class="hint">People at <Mono>{{ partner.name }}</Mono> who can sign in. <Mono dim>partnerId</Mono> on the user record points here.</p>
|
|
</div>
|
|
<UiButton variant="primary" @click="inviteOpen = true">
|
|
<template #leading><UiIcon name="plus" :size="13" /></template>
|
|
Invite team member
|
|
</UiButton>
|
|
</div>
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Name</th><th>Email</th><th>Role</th><th>Last login</th><th>Status</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-if="(team ?? []).length === 0" class="empty">
|
|
<td colspan="5">
|
|
<span class="empty-inner">No team members yet. Click <Mono>Invite team member</Mono> to add one.</span>
|
|
</td>
|
|
</tr>
|
|
<tr v-for="u in (team ?? [])" :key="u._id">
|
|
<td>
|
|
<div class="cell-name">{{ u.name }}</div>
|
|
<Mono dim>{{ u.authentikSubjectId }}</Mono>
|
|
</td>
|
|
<td><Mono>{{ u.email }}</Mono></td>
|
|
<td><Badge tone="neutral">{{ u.role }}</Badge></td>
|
|
<td><Mono :dim="!u.lastLoginAt">{{ u.lastLoginAt ? new Date(u.lastLoginAt).toLocaleString() : 'never' }}</Mono></td>
|
|
<td><Badge :tone="u.active ? 'ok' : 'bad'" dot>{{ u.active ? 'active' : 'disabled' }}</Badge></td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</Card>
|
|
|
|
<Card>
|
|
<h2 class="danger">Soft-terminate partner</h2>
|
|
<p>
|
|
Marks the partner as <Mono>terminated</Mono>. Customer tenants keep
|
|
their <Mono>partnerId</Mono> reference — we never hard-delete since
|
|
historical billing depends on it. To re-activate, edit the status
|
|
back to active via the admin API (UI editor lands later).
|
|
</p>
|
|
<UiButton
|
|
variant="danger"
|
|
:disabled="partner.status === 'terminated'"
|
|
@click="terminateOpen = true; terminateError = null"
|
|
>Terminate partner</UiButton>
|
|
</Card>
|
|
</div>
|
|
|
|
<!-- Attach tenant modal -->
|
|
<ConfirmDialog
|
|
:open="attachOpen"
|
|
:eyebrow="`Partner · ${partner.slug}`"
|
|
title="Attach a tenant"
|
|
confirm-label="Attach"
|
|
:busy="attachBusy"
|
|
@close="attachOpen = false"
|
|
@confirm="submitAttach"
|
|
>
|
|
<p>Pick an unattached tenant. Direct customers (those without a partner) show below.</p>
|
|
<select v-if="attachable.length" v-model="selectedSlug" class="select">
|
|
<option v-for="t in attachable" :key="t._id" :value="t.slug">
|
|
{{ t.name }} — {{ t.slug }} · {{ t.plan }}
|
|
</option>
|
|
</select>
|
|
<p v-else class="hint">No unattached tenants. Detach one from its current partner first.</p>
|
|
<p v-if="attachError" class="err">{{ attachError }}</p>
|
|
</ConfirmDialog>
|
|
|
|
<!-- Detach confirm -->
|
|
<ConfirmDialog
|
|
:open="detachTarget !== null"
|
|
:eyebrow="`Partner · ${partner.slug}`"
|
|
title="Detach this tenant?"
|
|
confirm-label="Detach"
|
|
tone="danger"
|
|
:busy="detachBusy"
|
|
@close="detachTarget = null"
|
|
@confirm="confirmDetach"
|
|
>
|
|
<p>
|
|
Remove <strong>{{ detachTarget?.name }}</strong> from <strong>{{ partner.name }}</strong>.
|
|
The tenant becomes a direct customer (no partner). Reversible — re-attach any time.
|
|
</p>
|
|
<p v-if="detachError" class="err">{{ detachError }}</p>
|
|
</ConfirmDialog>
|
|
|
|
<!-- Terminate partner confirm -->
|
|
<ConfirmDialog
|
|
:open="terminateOpen"
|
|
:eyebrow="`Partner · ${partner.slug}`"
|
|
title="Terminate this partner?"
|
|
confirm-label="Terminate"
|
|
tone="danger"
|
|
:busy="terminateBusy"
|
|
@close="terminateOpen = false"
|
|
@confirm="confirmTerminate"
|
|
>
|
|
<p>
|
|
Set <strong>{{ partner.name }}</strong> status to <Mono>terminated</Mono>. Existing
|
|
customer tenants keep their reference. To re-activate later, set status back via the
|
|
admin API.
|
|
</p>
|
|
<p v-if="terminateError" class="err">{{ terminateError }}</p>
|
|
</ConfirmDialog>
|
|
|
|
<!-- Invite partner team-member modal -->
|
|
<InvitePartnerUserModal
|
|
:open="inviteOpen"
|
|
:partner-slug="partner.slug"
|
|
:partner-name="partner.name"
|
|
@close="inviteOpen = false"
|
|
@invited="onInvited"
|
|
/>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.stage {
|
|
padding: 24px 40px 64px 40px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 16px;
|
|
}
|
|
|
|
.grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(360px, 1fr));
|
|
gap: 16px;
|
|
}
|
|
|
|
h2 {
|
|
font-family: var(--font-display);
|
|
font-weight: 600;
|
|
font-size: 16px;
|
|
letter-spacing: -0.01em;
|
|
margin: 0 0 16px 0;
|
|
}
|
|
h2.danger { color: var(--bad); }
|
|
|
|
p {
|
|
margin: 0 0 14px 0;
|
|
color: var(--text-dim);
|
|
font-size: 13px;
|
|
line-height: 1.55;
|
|
}
|
|
p.hint { font-size: 12px; color: var(--text-mute); margin: 4px 0 0 0; }
|
|
p.err { color: var(--bad); font-family: var(--font-mono); font-size: 12px; margin-top: 12px; }
|
|
|
|
code {
|
|
font-family: var(--font-mono);
|
|
font-size: 12px;
|
|
background: rgba(244, 243, 238, 0.06);
|
|
padding: 1px 6px;
|
|
border-radius: 3px;
|
|
}
|
|
|
|
dl { display: flex; flex-direction: column; gap: 10px; }
|
|
.dl-row {
|
|
display: grid;
|
|
grid-template-columns: 120px 1fr;
|
|
gap: 12px;
|
|
font-size: 13px;
|
|
}
|
|
dt {
|
|
font-family: var(--font-mono);
|
|
font-size: 10px;
|
|
letter-spacing: 0.12em;
|
|
text-transform: uppercase;
|
|
color: var(--text-mute);
|
|
align-self: center;
|
|
}
|
|
dd { margin: 0; color: var(--text); display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
|
|
|
|
.num {
|
|
font-family: var(--font-display);
|
|
font-weight: 600;
|
|
font-size: 18px;
|
|
letter-spacing: -0.01em;
|
|
}
|
|
|
|
.card-head {
|
|
display: flex;
|
|
align-items: flex-start;
|
|
justify-content: space-between;
|
|
gap: 12px;
|
|
}
|
|
.card-head.padded { padding: 20px 24px 12px 24px; }
|
|
.card-head h2 { margin: 0; }
|
|
|
|
table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
|
thead tr { border-bottom: 1px solid var(--border); }
|
|
th {
|
|
padding: 12px 16px;
|
|
text-align: left;
|
|
font-family: var(--font-mono);
|
|
font-size: 10px;
|
|
font-weight: 500;
|
|
letter-spacing: 0.12em;
|
|
text-transform: uppercase;
|
|
color: var(--text-mute);
|
|
}
|
|
th.th-right { text-align: right; }
|
|
|
|
tbody tr { border-bottom: 1px solid var(--border); }
|
|
tbody tr:last-child { border-bottom: none; }
|
|
td { padding: 14px 16px; color: var(--text); }
|
|
td.td-right { text-align: right; }
|
|
|
|
.empty td { padding: 36px 16px; text-align: center; }
|
|
.empty-inner { color: var(--text-mute); font-size: 13px; }
|
|
|
|
.link {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 2px;
|
|
text-decoration: none;
|
|
color: var(--text);
|
|
}
|
|
.link:hover .cell-name { text-decoration: underline; }
|
|
.cell-name { font-weight: 500; }
|
|
|
|
.select {
|
|
width: 100%;
|
|
height: 36px;
|
|
padding: 0 10px;
|
|
background: var(--bg);
|
|
border: 1px solid var(--border);
|
|
border-radius: 6px;
|
|
color: var(--text);
|
|
font-family: inherit;
|
|
font-size: 13px;
|
|
outline: none;
|
|
}
|
|
.select:focus { border-color: var(--accent); }
|
|
|
|
/* Inline edit-mode form fields */
|
|
.field {
|
|
flex: 1;
|
|
min-width: 0;
|
|
height: 32px;
|
|
padding: 0 10px;
|
|
background: var(--input-bg);
|
|
border: 1px solid var(--border);
|
|
border-radius: 6px;
|
|
color: var(--text);
|
|
font-family: inherit;
|
|
font-size: 13px;
|
|
outline: 0;
|
|
}
|
|
.field.mono { font-family: var(--font-mono); }
|
|
.field.country { max-width: 80px; text-transform: uppercase; }
|
|
.field:focus { border-color: var(--border-hi); }
|
|
.field:disabled { opacity: 0.6; }
|
|
|
|
/* Status segmented control (mirrors FlagDetail.vue's seg pattern) */
|
|
.seg {
|
|
display: grid;
|
|
width: 100%;
|
|
gap: 2px;
|
|
padding: 2px;
|
|
background: var(--surface);
|
|
border: 1px solid var(--border);
|
|
border-radius: 7px;
|
|
box-sizing: border-box;
|
|
}
|
|
.seg.four { grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
|
.seg button {
|
|
appearance: none;
|
|
border: 0;
|
|
background: transparent;
|
|
color: var(--text-dim);
|
|
font-family: inherit;
|
|
font-size: 11px;
|
|
font-weight: 500;
|
|
padding: 6px 8px;
|
|
border-radius: 5px;
|
|
cursor: pointer;
|
|
min-width: 0;
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
.seg button:hover:not(:disabled) { color: var(--text); }
|
|
.seg button.on { background: var(--text); color: var(--bg); }
|
|
.seg button:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
|
|
/* marginPct range slider (mirrors FlagDetail.vue rollout slider) */
|
|
.slider-cell { display: flex; align-items: center; gap: 14px; width: 100%; }
|
|
.slider { flex: 1; accent-color: var(--accent); }
|
|
.slider:disabled { opacity: 0.6; }
|
|
.pct { font-size: 14px; font-weight: 600; min-width: 50px; text-align: right; }
|
|
|
|
.inline-err {
|
|
margin: 0;
|
|
padding: 10px 14px;
|
|
background: rgba(240, 88, 88, 0.08);
|
|
border: 1px solid rgba(240, 88, 88, 0.24);
|
|
color: var(--bad);
|
|
border-radius: 6px;
|
|
font-size: 12px;
|
|
font-family: var(--font-mono);
|
|
}
|
|
</style>
|