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:
Ronni Baslund
2026-05-24 21:27:46 +02:00
parent 4d9e906ec1
commit 9a97945565
8 changed files with 611 additions and 3 deletions
@@ -86,6 +86,109 @@ export class AuthentikClient {
if (since) params.set('created__gt', since.toISOString())
return this.request<AuthentikEventPage>(`/events/events/?${params}`)
}
// Look up a user by email. Returns undefined if not found. Used by the
// invite flow so we can give a friendly conflict error instead of letting
// Authentik 400.
async findUserByEmail(email: string): Promise<AuthentikUser | undefined> {
const res = await this.request<{ results: AuthentikUser[] }>(
`/core/users/?email=${encodeURIComponent(email)}`,
)
return res.results[0]
}
// Create a user. Authentik's `uid` field becomes the JWT `sub` claim once
// they log in — this is the same value our User.authentikSubjectId is keyed
// on. We set type='internal' (real human user, not service account) and
// is_active=true so the recovery link they receive lets them set a password.
async createUser(input: {
username: string
email: string
name: string
attributes?: Record<string, unknown>
groupPks?: string[]
}): Promise<AuthentikUser> {
const created = await this.request<AuthentikUser>('/core/users/', {
method: 'POST',
body: JSON.stringify({
username: input.username,
email: input.email,
name: input.name,
type: 'internal',
is_active: true,
path: 'users',
groups: input.groupPks ?? [],
attributes: input.attributes ?? {},
}),
})
this.logger.log(`Created Authentik user ${input.email} (pk=${created.pk}, uid=${created.uid})`)
return created
}
// Add an existing user to a group by ID. Idempotent — adding twice is a
// no-op on Authentik's side.
async addUserToGroup(userPk: number, groupId: string): Promise<void> {
await this.request(`/core/groups/${groupId}/add_user/`, {
method: 'POST',
body: JSON.stringify({ pk: userPk }),
})
this.logger.log(`Added user ${userPk} to Authentik group ${groupId}`)
}
// Generate a single-use recovery link the new user clicks to set their
// password + enroll MFA. Requires a "recovery flow" configured on the
// Authentik brand — if not set, returns undefined so callers can fall
// back to setInitialPassword.
async recoveryLink(userPk: number): Promise<string | undefined> {
try {
const res = await this.request<{ link: string }>(
`/core/users/${userPk}/recovery/`,
{ method: 'POST' },
)
return res.link
} catch (err) {
// Authentik returns 400 with "No recovery flow set." when the brand has
// no recovery flow wired. Treat as soft-fail; caller fallback path
// sets an initial password instead.
if (err instanceof Error && err.message.includes('recovery flow')) {
this.logger.warn('Authentik recovery link unavailable — no recovery flow configured')
return undefined
}
throw err
}
}
// Direct set_password — used when no recovery flow is configured. The
// operator hands the temp password to the new user out-of-band; the user
// changes it after first login via Authentik's password-change flow.
// Authentik returns 204 No Content (empty body) on success, so we skip
// request<T>()'s JSON parser and call fetch directly.
async setInitialPassword(userPk: number, password: string): Promise<void> {
const res = await fetch(`${this.base}/core/users/${userPk}/set_password/`, {
method: 'POST',
headers: {
Authorization: `Bearer ${this.token}`,
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify({ password }),
})
if (!res.ok) {
const body = await res.text().catch(() => '')
throw new Error(`Authentik set_password ${userPk}${res.status}: ${body.slice(0, 200)}`)
}
this.logger.log(`Set initial password for Authentik user ${userPk}`)
}
}
export interface AuthentikUser {
pk: number
uid: string // becomes JWT `sub` on first login
username: string
email: string
name?: string
is_active: boolean
groups?: string[]
}
// Shape returned by /events/events/. Only the fields we read; Authentik