feat(operator): partner-style tenant provisioning wizard + admin invite
ci / tc_portal (push) Has been skipped
ci / changes (push) Successful in 4s
ci / tc_booking (push) Has been skipped
ci / tc_website (push) Has been skipped
ci / tc_platform_api (push) Successful in 22s
ci / tc_operator (push) Successful in 24s
ci / build_portal (push) Has been skipped
ci / build_booking (push) Has been skipped
ci / test_platform_api (push) Successful in 32s
ci / build_operator (push) Successful in 31s
ci / build_platform_api (push) Successful in 15s
ci / deploy (push) Successful in 41s

The minimal create modal silently dropped adminName/adminEmail — the invite
only existed in the partner wizard's server path. Operator now gets the
same 5-step wizard UX (organization, domain, first admin, plan with live
price catalog, review) composed client-side: POST /tenants creates +
provisions, then POST /users/invite-tenant-admin (new, operator-only —
lives in UsersModule because UsersModule already imports TenantsModule and
the reverse would be circular) runs the same inviteTenantAdmin flow the
partner gets, and the result view hands over the single-use recovery link
or temp password. Tenant detail page gains an Invite admin action for
retries/successors. PLATFORM_TENANT_SLUG back to 'dezky' (the recreated
company tenant) + config-rev bump to roll platform-api.
This commit is contained in:
Ronni Baslund
2026-06-10 21:22:14 +02:00
parent fb4ff48617
commit 2bc302c082
10 changed files with 1093 additions and 158 deletions
+16
View File
@@ -81,6 +81,10 @@ const INTEGRATION_TONE = {
const INTEGRATIONS = ['authentik', 'stalwart', 'ocis'] as const
type IntegrationKey = (typeof INTEGRATIONS)[number]
// Invite (or re-invite) the tenant's admin — same flow the create wizard
// runs; reusable whenever the first invite failed or someone new takes over.
const inviteAdminOpen = ref(false)
// ── Danger-zone state ─────────────────────────────────────────────────────
const dangerAction = ref<'suspend' | 'resume' | 'delete' | 'purge' | null>(null)
const dangerBusy = ref(false)
@@ -141,6 +145,10 @@ async function reconcile() {
<template #actions>
<Badge :tone="STATUS_TONE[tenant.status]" dot>{{ tenant.status }}</Badge>
<Badge tone="neutral">{{ tenant.plan }}</Badge>
<UiButton variant="secondary" @click="inviteAdminOpen = true">
<template #leading><UiIcon name="plus" :size="13" /></template>
Invite admin
</UiButton>
<UiButton variant="secondary" @click="impersonate.open(tenant)">
<template #leading><UiIcon name="key" :size="13" /></template>
Impersonate
@@ -392,6 +400,14 @@ async function reconcile() {
</p>
<p v-if="dangerError" class="danger-err">{{ dangerError }}</p>
</ConfirmDialog>
<InviteTenantAdminModal
v-if="tenant"
:open="inviteAdminOpen"
:tenant-slug="tenant.slug"
:tenant-name="tenant.name"
@close="inviteAdminOpen = false"
/>
</div>
</template>
+6 -156
View File
@@ -37,72 +37,16 @@ function navTo(t: Tenant) {
return navigateTo(`/tenants/${t.slug}`)
}
// ── Create modal ──────────────────────────────────────────────────────────
// Operator-created tenants are DIRECT customers (no partnerId — partner-owned
// tenants are created through the partner portal wizard instead). Attach to a
// partner later from the tenant detail page if needed.
// ── Create wizard ─────────────────────────────────────────────────────────
// Full partner-style provisioning wizard (TenantCreateWizard) — collects org,
// domain, first admin and plan, then creates + invites in one flow and shows
// the admin credential to share. Operator-created tenants are DIRECT
// customers (no partnerId); attach a partner later from the tenant page.
const createOpen = ref(false)
const createBusy = ref(false)
const createError = ref<string | null>(null)
const form = reactive({
slug: '',
name: '',
plan: 'mvp' as 'mvp' | 'pro' | 'enterprise',
cycle: 'monthly' as 'monthly' | 'quarterly' | 'yearly',
currency: 'DKK' as 'DKK' | 'EUR' | 'USD',
seats: 5,
domain: '',
adminName: '',
adminEmail: '',
})
function openCreate() {
Object.assign(form, {
slug: '',
name: '',
plan: 'mvp',
cycle: 'monthly',
currency: 'DKK',
seats: 5,
domain: '',
adminName: '',
adminEmail: '',
})
createError.value = null
createOpen.value = true
}
async function submitCreate() {
createBusy.value = true
createError.value = null
try {
const domain = form.domain.trim().toLowerCase()
const created = await $fetch<Tenant>('/api/tenants', {
method: 'POST',
body: {
slug: form.slug.trim(),
name: form.name.trim(),
plan: form.plan,
cycle: form.cycle,
currency: form.currency,
seats: form.seats,
...(domain ? { domains: [domain] } : {}),
...(form.adminName.trim() && form.adminEmail.trim()
? { adminName: form.adminName.trim(), adminEmail: form.adminEmail.trim() }
: {}),
},
})
createOpen.value = false
await refresh()
await navigateTo(`/tenants/${created.slug}`)
} catch (err: unknown) {
const e = err as { data?: { data?: { message?: string | string[] }; message?: string } }
const msg = e.data?.data?.message ?? e.data?.message ?? String(err)
createError.value = Array.isArray(msg) ? msg.join(' · ') : msg
} finally {
createBusy.value = false
}
}
</script>
<template>
@@ -195,69 +139,7 @@ async function submitCreate() {
</Card>
</div>
<ConfirmDialog
:open="createOpen"
eyebrow="New tenant"
title="Create direct customer"
confirm-label="Create"
:busy="createBusy"
@close="createOpen = false"
@confirm="submitCreate"
>
<form class="form" @submit.prevent="submitCreate">
<label>
<span>Slug · URL-safe id</span>
<input v-model="form.slug" placeholder="e.g. dezky" autocomplete="off" required />
</label>
<label>
<span>Display name</span>
<input v-model="form.name" placeholder="e.g. Dezky ApS" required />
</label>
<div class="form-row">
<label>
<span>Plan</span>
<select v-model="form.plan">
<option value="mvp">mvp</option>
<option value="pro">pro</option>
<option value="enterprise">enterprise</option>
</select>
</label>
<label>
<span>Cycle</span>
<select v-model="form.cycle">
<option value="monthly">monthly</option>
<option value="quarterly">quarterly</option>
<option value="yearly">yearly</option>
</select>
</label>
<label>
<span>Currency</span>
<select v-model="form.currency">
<option value="DKK">DKK</option>
<option value="EUR">EUR</option>
<option value="USD">USD</option>
</select>
</label>
<label>
<span>Seats</span>
<input v-model.number="form.seats" type="number" min="0" max="10000" />
</label>
</div>
<label>
<span>Primary mail domain · optional</span>
<input v-model="form.domain" placeholder="e.g. dezky.eu" autocomplete="off" />
</label>
<label>
<span>First admin name · optional</span>
<input v-model="form.adminName" placeholder="e.g. Ronni Baslund" />
</label>
<label>
<span>First admin email · optional</span>
<input v-model="form.adminEmail" type="email" placeholder="e.g. ronni@dezky.eu" />
</label>
</form>
<p v-if="createError" class="err">{{ createError }}</p>
</ConfirmDialog>
<TenantCreateWizard :open="createOpen" @close="createOpen = false" @done="refresh()" />
</div>
</template>
@@ -388,36 +270,4 @@ td.td-right { text-align: right; }
.prov-error { background: var(--bad); }
.prov-pending { background: var(--warn); }
.form { display: flex; flex-direction: column; gap: 12px; }
.form-row { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; }
.form label { display: flex; flex-direction: column; gap: 6px; }
.form label span {
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--text-mute);
font-weight: 500;
}
.form input,
.form select {
height: 34px;
padding: 0 12px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text);
font-family: inherit;
font-size: 13px;
outline: none;
}
.form input:focus,
.form select:focus { border-color: var(--accent); }
.err {
margin: 12px 0 0 0;
color: var(--bad);
font-family: var(--font-mono);
font-size: 12px;
}
</style>