Files
dezky/services/platform-api/src/users/users.controller.ts
T
Ronni Baslund 2bc302c082
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
feat(operator): partner-style tenant provisioning wizard + admin invite
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.
2026-06-10 21:22:14 +02:00

158 lines
5.4 KiB
TypeScript

import {
Body,
Controller,
Delete,
ForbiddenException,
Get,
HttpCode,
Param,
Patch,
Post,
Req,
UseGuards,
} from '@nestjs/common'
import { ConfigService } from '@nestjs/config'
import { ActorService } from '../auth/actor.service.js'
import { clientIp } from '../auth/client-ip.js'
import { CurrentUser } from '../auth/current-user.decorator.js'
import { JwtAuthGuard } from '../auth/jwt-auth.guard.js'
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'
// Authentik group name that grants platform-wide admin in Dezky. This is the ONLY
// place we look at the JWT's groups claim outside of /users/me — and even here it's
// just for the bootstrap: the resulting platformAdmin boolean on the User doc is
// what every other endpoint reads.
const ADMIN_BOOTSTRAP_GROUP_DEFAULT = 'dezky-platform-admins'
@Controller('users')
@UseGuards(JwtAuthGuard)
export class UsersController {
private readonly adminBootstrapGroup: string
constructor(
private readonly users: UsersService,
private readonly actor: ActorService,
config: ConfigService,
) {
this.adminBootstrapGroup =
config.get<string>('PLATFORM_ADMIN_BOOTSTRAP_GROUP') ?? ADMIN_BOOTSTRAP_GROUP_DEFAULT
}
// The signed-in user's own profile — bootstraps the user record on first call,
// and syncs name/email/tenants/platformAdmin from the JWT on every subsequent call.
// Adds a `partner` field when User.partnerId is set so the portal can decide
// whether to render the partner-admin surface or the end-user surface.
@Get('me')
async me(@CurrentUser() jwt: AuthentikJwtPayload) {
return this.users.meWithPartner({
subject: jwt.sub,
email: jwt.email ?? jwt.preferred_username ?? jwt.sub,
name: jwt.name ?? jwt.preferred_username ?? jwt.email ?? jwt.sub,
tenantSlugs: jwt.groups ?? [],
platformAdmin: jwt.groups?.includes(this.adminBootstrapGroup) ?? false,
})
}
// Partner-scoped endpoints live in PartnerMeController under /me/partner.
// Identity endpoints (above) stay here.
@Post()
async create(@Body() dto: CreateUserDto, @CurrentUser() jwt: AuthentikJwtPayload) {
const actor = await this.actor.resolve(jwt)
if (!actor.platformAdmin) {
throw new ForbiddenException('Only platform admins can create users directly')
}
return this.users.create(dto)
}
// Operator-only: invite a new platform admin. Creates the user in Authentik,
// adds them to the dezky-platform-admins group, returns a recovery link the
// operator shares manually. Once outbound SMTP is wired, Authentik can
// email the link directly and the response link is mostly informational.
@Post('invite')
@UseGuards(OperatorGuard)
async invite(
@Body() dto: InviteOperatorDto,
@CurrentUser() jwt: AuthentikJwtPayload,
@Req() req: Parameters<typeof clientIp>[0],
) {
const actor = await this.actor.resolve(jwt)
return this.users.inviteOperator(dto, {
userId: String(actor._id),
email: actor.email,
ip: clientIp(req),
})
}
// 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)
if (actor.platformAdmin) return this.users.findAll()
return this.users.findAllForTenants(actor.tenantIds)
}
@Get(':subject')
async findOne(@Param('subject') subject: string, @CurrentUser() jwt: AuthentikJwtPayload) {
const actor = await this.actor.resolve(jwt)
if (subject !== jwt.sub && !actor.platformAdmin) {
throw new ForbiddenException('Cannot read other users')
}
return this.users.findOneBySubject(subject)
}
@Patch(':subject')
async update(
@Param('subject') subject: string,
@Body() dto: UpdateUserDto,
@CurrentUser() jwt: AuthentikJwtPayload,
) {
const actor = await this.actor.resolve(jwt)
if (!actor.platformAdmin) {
throw new ForbiddenException('Only platform admins can update users')
}
return this.users.update(subject, dto)
}
@Delete(':subject')
@HttpCode(204)
async deactivate(
@Param('subject') subject: string,
@CurrentUser() jwt: AuthentikJwtPayload,
@Req() req: Parameters<typeof clientIp>[0],
) {
const actor = await this.actor.resolve(jwt)
if (!actor.platformAdmin) {
throw new ForbiddenException('Only platform admins can deactivate users')
}
await this.users.deactivate(subject, {
userId: String(actor._id),
email: actor.email,
ip: clientIp(req),
})
}
}