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
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.
158 lines
5.4 KiB
TypeScript
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),
|
|
})
|
|
}
|
|
}
|