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