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
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:
@@ -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 },
|
||||
|
||||
Reference in New Issue
Block a user