feat(operator): invite operator → creates user in Authentik
New "Invite operator" button + modal on /operator-team. Replaces the
bounce-to-Authentik flow with an inline invite that creates the user via
the Authentik API and pre-populates our local User doc so they appear
immediately.
services/platform-api/src/integrations/authentik.client.ts:
- findUserByEmail(): early-conflict check before we attempt the create
- createUser(): POST /core/users/ with username = email, internal type,
is_active, attached to the supplied group PKs
- addUserToGroup(): kept for tenant-member invites later
- recoveryLink(): tries POST /core/users/{pk}/recovery/, returns
undefined when no recovery flow is configured on the Authentik brand
(we soft-fail and the service falls back to setInitialPassword)
- setInitialPassword(): POST /core/users/{pk}/set_password/. Returns 204
No Content so we bypass request<T>'s JSON parser and call fetch
directly with explicit ok check.
services/platform-api/src/users/users.service.ts:
- inviteOperator(dto, actor) orchestrates: dedup by email →
findOrCreate Authentik group → create user in group → pre-create
local User doc with platformAdmin=true so the list reflects them
immediately → try recovery link → fall back to temp password →
record platform.user_invited audit event with handoff method.
- Return type is { subject, userId, link? | tempPassword? } —
exactly one credential mode set depending on Authentik config.
- generateTempPassword(): 16-char with at least one upper/lower/digit/
symbol, shuffled. Confusable chars (I/O/0/1/l) omitted.
- Cached platform-admin group ID after first lookup.
services/platform-api/src/users/users.controller.ts:
- POST /users/invite behind OperatorGuard. Calls the service with
actor + IP from the JWT/request.
apps/operator:
- server/api/users/invite.post.ts: standard platformApi proxy.
- components/InviteOperatorModal.vue: 2-step form. Step 1: name +
email with client-side validation. Step 2: shows whichever
credential the backend returned — recovery link OR username+
temp-password — with copy-to-clipboard buttons and a note about
SMTP/recovery-flow follow-up paths.
- pages/operator-team.vue: "Invite operator" replaces "Manage in
Authentik" as the primary action; Authentik link demoted to
secondary. Refreshes the list on @invited so the new user shows
up without a manual reload.
Verified end-to-end against real Authentik:
- Invite created user pk=7, uid=f22f2bb…, group=dezky-platform-admins,
is_active=true, temp password set. Modal showed both fields with
copy buttons; operator-team count went 1 → 2 immediately. Audit
event recorded (platform.user_invited with handoff='temp-password').
- Recovery link path is preferred but Authentik has no recovery flow
configured on the default brand. AuthentikClient.recoveryLink()
soft-fails on the "No recovery flow set." 400, returns undefined,
and inviteOperator transparently falls back to set_password. Once
a recovery flow is configured (Authentik admin → Flows), the link
path becomes active and the temp-password path stops firing
without any code changes.
Known follow-ups:
- Configure Authentik recovery flow so the link path activates
(one-time admin task, not in code)
- Outbound SMTP wiring (Phase 5/6) → Authentik can email link/temp
directly; modal stops showing the credential
- Deactivate / remove operator from inside the app (currently still
Authentik UI; defensible until proven needed)
- Tenant-member invite — similar flow but adds to tenant group
instead, exposed from /users (global users) or tenant detail
This commit is contained in:
@@ -17,7 +17,9 @@ 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 { UpdateUserDto } from './dto/update-user.dto.js'
|
||||
import { UsersService } from './users.service.js'
|
||||
|
||||
@@ -63,6 +65,25 @@ export class UsersController {
|
||||
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),
|
||||
})
|
||||
}
|
||||
|
||||
@Get()
|
||||
async findAll(@CurrentUser() jwt: AuthentikJwtPayload) {
|
||||
const actor = await this.actor.resolve(jwt)
|
||||
|
||||
Reference in New Issue
Block a user