From 2bc302c082237b8562fc54f17a74fd5ae147d11a Mon Sep 17 00:00:00 2001
From: Ronni Baslund
Date: Wed, 10 Jun 2026 21:22:14 +0200
Subject: [PATCH] feat(operator): partner-style tenant provisioning wizard +
admin invite
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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.
---
.../components/InviteTenantAdminModal.vue | 311 ++++++++
.../components/TenantCreateWizard.vue | 692 ++++++++++++++++++
apps/operator/pages/tenants/[slug].vue | 16 +
apps/operator/pages/tenants/index.vue | 162 +---
.../api/users/invite-tenant-admin.post.ts | 6 +
.../fleet/apps/platform-api-config.yaml | 2 +-
.../production/fleet/apps/platform-api.yaml | 2 +-
.../src/users/dto/invite-tenant-admin.dto.ts | 17 +
.../src/users/users.controller.ts | 19 +
.../platform-api/src/users/users.service.ts | 24 +
10 files changed, 1093 insertions(+), 158 deletions(-)
create mode 100644 apps/operator/components/InviteTenantAdminModal.vue
create mode 100644 apps/operator/components/TenantCreateWizard.vue
create mode 100644 apps/operator/server/api/users/invite-tenant-admin.post.ts
create mode 100644 services/platform-api/src/users/dto/invite-tenant-admin.dto.ts
diff --git a/apps/operator/components/InviteTenantAdminModal.vue b/apps/operator/components/InviteTenantAdminModal.vue
new file mode 100644
index 0000000..2253fe3
--- /dev/null
+++ b/apps/operator/components/InviteTenantAdminModal.vue
@@ -0,0 +1,311 @@
+
+
+
+
+
+
+
+
+ Tenant · {{ tenantSlug }}
+
Invite admin
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Creates (or attaches) the user in Authentik and adds them to the
+ {{ tenantSlug }} group as this tenant's admin. You'll get a
+ single-use credential to share.
+
+
+
+
{{ error }}
+
+
+
+
+ invited
+
+
+
+
+ {{ email }} already existed in Authentik and was attached as an
+ admin of {{ tenantSlug }}. They sign in with their existing
+ credentials — nothing to share.
+
+
+
+
+
+
+ {{ name }} ({{ email }}) is now an admin of
+ {{ tenantSlug }}. Share the link below — it's single-use
+ and they'll set their own password + MFA.
+
+
+
+
+ {{ copied ? 'Copied' : 'Copy' }}
+
+
+
+
+
+
+
+ {{ name }} ({{ email }}) is now an admin of
+ {{ tenantSlug }}. Authentik doesn't have a recovery flow
+ configured yet, so we set a temporary password — share it with them
+ out-of-band, they'll be prompted to change it on first login.
+
+
+
+
+
+
+ Copy
+
+
+
+
+
+
+
+
+ {{ copied ? 'Copied' : 'Copy' }}
+
+
+
+
+ // configure a recovery flow in Authentik (Flows → recovery) to
+ switch this to a self-service link · once SMTP is wired the
+ credential gets emailed automatically
+
+
+
+ {{ result ? 'Provisioned' : `Step ${step} of ${STEPS.length}` }}
+
Provision new tenant
+
+
+
+
+
+
+
+
+
+
+
+ {{ s.n }}
+
+ {{ s.label }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ DNS verification
+
+ This only records the intent on the tenant. The customer admin adds and
+ verifies the domain on the portal's Domains page, which hands them the
+ exact MX/SPF/DKIM/DMARC records to publish.
+
+
+
+
+
+
+
+ Optional — creates (or attaches) this person as the tenant's first admin and
+ returns a single-use credential to share. Skip it to invite someone later.
+
+ On confirm the tenant is created and provisioned (Authentik group, Stalwart
+ service domain; OCIS once the files tier is live), the subscription is
+ spun up from the price catalog, and the first admin is invited.
+
+
+
+
+
{{ submitError }}
+
+
+
+ provisioned
+
{{ result.tenantName }} is live
+
+
+
+
+ {{ result.adminEmail }} already existed in Authentik and was
+ attached as an admin on this tenant. They sign in with their existing
+ credentials.
+
+
+
+
+ Share this single-use link with the admin — they'll set their own
+ password and enroll MFA.
+
+
+
+
+ {{ copied ? 'Copied' : 'Copy' }}
+
+
+
+
+
+ Authentik has no recovery flow configured, so a temporary password was
+ set — share it with the admin; they'll change it on first login.
+
+
+
+ Copy
+
+
+
+
+ {{ copied ? 'Copied' : 'Copy' }}
+
+
+
+
+
+
+
+ Tenant was created, but the admin invite failed:
+ {{ result.admin.error }}. Retry from the tenant page's
+ "Invite admin" action.
+
+
+
+
+
+ No first-admin info was provided. Invite an admin from the tenant page
+ whenever you're ready.
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/operator/pages/tenants/[slug].vue b/apps/operator/pages/tenants/[slug].vue
index 8fc710f..a35c35d 100644
--- a/apps/operator/pages/tenants/[slug].vue
+++ b/apps/operator/pages/tenants/[slug].vue
@@ -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() {
{{ tenant.status }}{{ tenant.plan }}
+
+
+ Invite admin
+
Impersonate
@@ -392,6 +400,14 @@ async function reconcile() {
{{ dangerError }}
+
+
diff --git a/apps/operator/pages/tenants/index.vue b/apps/operator/pages/tenants/index.vue
index f20a38e..c3a9ac0 100644
--- a/apps/operator/pages/tenants/index.vue
+++ b/apps/operator/pages/tenants/index.vue
@@ -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(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('/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
- }
-}
@@ -195,69 +139,7 @@ async function submitCreate() {
-
-
-
{{ createError }}
-
+
@@ -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;
-}
diff --git a/apps/operator/server/api/users/invite-tenant-admin.post.ts b/apps/operator/server/api/users/invite-tenant-admin.post.ts
new file mode 100644
index 0000000..c3b3a2e
--- /dev/null
+++ b/apps/operator/server/api/users/invite-tenant-admin.post.ts
@@ -0,0 +1,6 @@
+import { platformApi } from '~~/server/utils/platform-api'
+
+export default defineEventHandler(async (event) => {
+ const body = await readBody(event)
+ return platformApi(event, '/users/invite-tenant-admin', { method: 'POST', body })
+})
diff --git a/infrastructure/production/fleet/apps/platform-api-config.yaml b/infrastructure/production/fleet/apps/platform-api-config.yaml
index 7cdd253..c3c906f 100644
--- a/infrastructure/production/fleet/apps/platform-api-config.yaml
+++ b/infrastructure/production/fleet/apps/platform-api-config.yaml
@@ -23,7 +23,7 @@ data:
# (PLATFORM_TENANT_SLUG) may claim the apex; nothing under it can be added
# as a customer domain.
PLATFORM_TENANT_DOMAIN: "dezky.eu"
- PLATFORM_TENANT_SLUG: "dezky-aps"
+ PLATFORM_TENANT_SLUG: "dezky"
# JWT validation for portal/operator-issued access tokens. Public Authentik
# URLs on purpose: the token `iss` claim is the public URL, and the pod can
# hairpin to it through the node's public IP.
diff --git a/infrastructure/production/fleet/apps/platform-api.yaml b/infrastructure/production/fleet/apps/platform-api.yaml
index 95b8e3e..016069d 100644
--- a/infrastructure/production/fleet/apps/platform-api.yaml
+++ b/infrastructure/production/fleet/apps/platform-api.yaml
@@ -21,7 +21,7 @@ spec:
annotations:
# Bump to force a rolling restart when only the ConfigMap changed —
# pods read it as env, which is only resolved at container start.
- dezky.eu/config-rev: "2"
+ dezky.eu/config-rev: "3"
spec:
containers:
- name: platform-api
diff --git a/services/platform-api/src/users/dto/invite-tenant-admin.dto.ts b/services/platform-api/src/users/dto/invite-tenant-admin.dto.ts
new file mode 100644
index 0000000..d15c5d3
--- /dev/null
+++ b/services/platform-api/src/users/dto/invite-tenant-admin.dto.ts
@@ -0,0 +1,17 @@
+import { IsEmail, IsString, Matches, MaxLength, MinLength } from 'class-validator'
+
+// Operator-only: invite (or attach) the first admin of a tenant. Same
+// inviteTenantAdmin flow the partner wizard uses — creates the Authentik user
+// (or attaches an existing one by email), adds them to the tenant group and
+// returns a recovery link / temp password the operator shares manually.
+export class InviteTenantAdminDto {
+ @IsString()
+ @Matches(/^[a-z0-9]([a-z0-9-]{0,38}[a-z0-9])?$/, { message: 'invalid tenant slug' })
+ tenantSlug!: string
+
+ @IsString() @MinLength(2) @MaxLength(120)
+ name!: string
+
+ @IsEmail() @MaxLength(254)
+ email!: string
+}
diff --git a/services/platform-api/src/users/users.controller.ts b/services/platform-api/src/users/users.controller.ts
index 1578dfc..9dcf09f 100644
--- a/services/platform-api/src/users/users.controller.ts
+++ b/services/platform-api/src/users/users.controller.ts
@@ -20,6 +20,7 @@ import type { AuthentikJwtPayload } from '../auth/jwt-payload.interface.js'
import { OperatorGuard } from '../auth/operator.guard.js'
import { CreateUserDto } from './dto/create-user.dto.js'
import { InviteOperatorDto } from './dto/invite-operator.dto.js'
+import { InviteTenantAdminDto } from './dto/invite-tenant-admin.dto.js'
import { UpdateUserDto } from './dto/update-user.dto.js'
import { UsersService } from './users.service.js'
@@ -89,6 +90,24 @@ export class UsersController {
})
}
+ // Operator-only: invite (or attach) a tenant's first admin — the operator
+ // counterpart of the partner wizard's adminName/adminEmail step. Returns
+ // the recovery link / temp password the operator shares manually.
+ @Post('invite-tenant-admin')
+ @UseGuards(OperatorGuard)
+ async inviteTenantAdmin(
+ @Body() dto: InviteTenantAdminDto,
+ @CurrentUser() jwt: AuthentikJwtPayload,
+ @Req() req: Parameters[0],
+ ) {
+ const actor = await this.actor.resolve(jwt)
+ return this.users.inviteTenantAdminBySlug(
+ dto.tenantSlug,
+ { name: dto.name, email: dto.email },
+ { userId: String(actor._id), email: actor.email, ip: clientIp(req) },
+ )
+ }
+
@Get()
async findAll(@CurrentUser() jwt: AuthentikJwtPayload) {
const actor = await this.actor.resolve(jwt)
diff --git a/services/platform-api/src/users/users.service.ts b/services/platform-api/src/users/users.service.ts
index 6c482b7..f2a1fae 100644
--- a/services/platform-api/src/users/users.service.ts
+++ b/services/platform-api/src/users/users.service.ts
@@ -1386,6 +1386,30 @@ export class UsersService {
return { newOwner: freshNewOwner ?? newOwner, previousOwners: freshPrev }
}
+ // Operator-side wrapper: resolve the tenant by slug, then run the same
+ // invite flow the partner wizard uses. Lives here (not TenantsService)
+ // because UsersModule already imports TenantsModule — the reverse would be
+ // circular.
+ async inviteTenantAdminBySlug(
+ slug: string,
+ dto: { name: string; email: string },
+ actor?: AuditActor,
+ ): Promise<{
+ subject: string
+ userId: string
+ attached?: boolean
+ link?: string
+ tempPassword?: string
+ }> {
+ const tenant = await this.tenantModel.findOne({ slug }).exec()
+ if (!tenant) throw new NotFoundException(`Tenant "${slug}" not found`)
+ return this.inviteTenantAdmin(
+ { _id: tenant._id, slug: tenant.slug, authentikGroupId: tenant.authentikGroupId },
+ dto,
+ actor,
+ )
+ }
+
async inviteTenantAdmin(
tenant: { _id: Types.ObjectId; slug: string; authentikGroupId?: string },
dto: { name: string; email: string },