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
@@ -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
}
@@ -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<typeof clientIp>[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)
@@ -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 },