feat(operator): create direct tenants from the operator portal
ci / typecheck (map[dir:apps/booking name:booking]) (push) Successful in 19s
ci / typecheck (map[dir:apps/operator name:operator]) (push) Successful in 21s
ci / typecheck (map[dir:apps/website name:website]) (push) Successful in 18s
ci / build (map[dir:apps/booking name:booking]) (push) Successful in 9s
ci / typecheck (map[dir:apps/portal name:portal]) (push) Successful in 27s
ci / typecheck (map[dir:services/platform-api name:platform-api]) (push) Successful in 21s
ci / test (push) Successful in 29s
ci / build (map[dir:apps/portal name:portal]) (push) Successful in 5s
ci / build (map[dir:services/platform-api name:platform-api]) (push) Successful in 5s
ci / build (map[dir:apps/operator name:operator]) (push) Successful in 29s
ci / deploy (push) Successful in 40s

The operator could list and inspect tenants but had no create flow — tenant
creation only existed as the partner-portal wizard, which always attaches a
partnerId. Platform-api's POST /tenants (platform-admin only, no partner
field) was already built for this; add the missing UI: a New tenant modal on
the tenants page (slug, name, plan/cycle/currency/seats, optional primary
mail domain + first-admin invite) and the server proxy route. Operator-created
tenants are direct customers; attach a partner later if needed.
This commit is contained in:
Ronni Baslund
2026-06-10 13:53:41 +02:00
parent b155e34fe6
commit 83212d7c23
2 changed files with 175 additions and 0 deletions
+169
View File
@@ -36,6 +36,73 @@ const STATUS_TONE: Record<TenantStatus, 'ok' | 'warn' | 'bad' | 'neutral'> = {
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.
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>
@@ -50,6 +117,10 @@ function navTo(t: Tenant) {
<template #leading><UiIcon name="refresh" :size="13" /></template>
Refresh
</UiButton>
<UiButton variant="primary" @click="openCreate">
<template #leading><UiIcon name="plus" :size="13" /></template>
New tenant
</UiButton>
</template>
</PageHeader>
@@ -90,6 +161,7 @@ function navTo(t: Tenant) {
<div class="empty-inner">
<UiIcon name="building" :size="20" stroke="var(--text-mute)" />
<span>No tenants match this filter.</span>
<UiButton v-if="counts.all === 0" variant="ghost" size="sm" @click="openCreate">Create the first one</UiButton>
</div>
</td>
</tr>
@@ -122,6 +194,70 @@ function navTo(t: Tenant) {
</table>
</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>
</div>
</template>
@@ -251,4 +387,37 @@ td.td-right { text-align: right; }
.prov-skipped { background: var(--text-mute); }
.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>