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 @@ + + + + + diff --git a/apps/operator/components/TenantCreateWizard.vue b/apps/operator/components/TenantCreateWizard.vue new file mode 100644 index 0000000..718672b --- /dev/null +++ b/apps/operator/components/TenantCreateWizard.vue @@ -0,0 +1,692 @@ + + + + + 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() { 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 - } -} @@ -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 },