+
+
+ Adds the user to the dezky-platform-admins Authentik group.
+ They'll receive a single-use recovery link to set their password + enroll MFA.
+
+
+
+
{{ error }}
+
+
+
+
+ invited
+
+
+
+
+ {{ name }} ({{ email }}) was added to the
+ dezky-platform-admins group. Share the link below — it's
+ single-use and they'll set their own password + MFA.
+
+
+
+
+ {{ copied ? 'Copied' : 'Copy' }}
+
+
+
+
+
+
+
+ {{ name }} ({{ email }}) was added to the
+ dezky-platform-admins group. Authentik doesn't have a
+ recovery flow configured yet, so we set a temporary password —
+ share it with them out-of-band, they'll be prompted to change it
+ on first login.
+
+
+
+
+
+
+ Copy
+
+
+
+
+
+
+
+
+ {{ copied ? 'Copied' : 'Copy' }}
+
+
+
+
+ // configure a recovery flow in Authentik (Flows → recovery) to
+ switch this to a self-service link · once SMTP is wired the
+ credential gets emailed automatically
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/operator/pages/operator-team.vue b/apps/operator/pages/operator-team.vue
index e9d683d..a36bdbc 100644
--- a/apps/operator/pages/operator-team.vue
+++ b/apps/operator/pages/operator-team.vue
@@ -7,6 +7,8 @@ const { data: users, pending, refresh } = await useFetch('/api/u
const operators = computed(() => (users.value ?? []).filter((u) => u.platformAdmin))
+const inviteOpen = ref(false)
+
function lastSeen(u: PlatformUser) {
if (!u.lastLoginAt) return '—'
const diff = Date.now() - new Date(u.lastLoginAt).getTime()
@@ -18,6 +20,13 @@ function lastSeen(u: PlatformUser) {
const d = Math.floor(h / 24)
return `${d} d ago`
}
+
+async function onInvited() {
+ // Refresh the list so the newly-invited operator shows up immediately —
+ // platform-api pre-creates the local User doc, so they appear with
+ // platformAdmin=true even before their first login.
+ await refresh()
+}
@@ -33,14 +42,20 @@ function lastSeen(u: PlatformUser) {
Refresh
-
+
Manage in Authentik
+
+
+ Invite operator
+
+
+
diff --git a/apps/operator/server/api/users/invite.post.ts b/apps/operator/server/api/users/invite.post.ts
new file mode 100644
index 0000000..c204f03
--- /dev/null
+++ b/apps/operator/server/api/users/invite.post.ts
@@ -0,0 +1,6 @@
+import { platformApi } from '~~/server/utils/platform-api'
+
+export default defineEventHandler(async (event) => {
+ const body = await readBody(event)
+ return platformApi(event, '/users/invite', { method: 'POST', body })
+})
diff --git a/services/platform-api/src/integrations/authentik.client.ts b/services/platform-api/src/integrations/authentik.client.ts
index 1929caf..db5565e 100644
--- a/services/platform-api/src/integrations/authentik.client.ts
+++ b/services/platform-api/src/integrations/authentik.client.ts
@@ -86,6 +86,109 @@ export class AuthentikClient {
if (since) params.set('created__gt', since.toISOString())
return this.request(`/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 {
+ 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
+ groupPks?: string[]
+ }): Promise {
+ const created = await this.request('/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 {
+ 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 {
+ 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()'s JSON parser and call fetch directly.
+ async setInitialPassword(userPk: number, password: string): Promise {
+ 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
diff --git a/services/platform-api/src/users/dto/invite-operator.dto.ts b/services/platform-api/src/users/dto/invite-operator.dto.ts
new file mode 100644
index 0000000..7e6ef8c
--- /dev/null
+++ b/services/platform-api/src/users/dto/invite-operator.dto.ts
@@ -0,0 +1,13 @@
+import { IsEmail, IsString, MaxLength, MinLength } from 'class-validator'
+
+// Operator-only: invite a new platform admin. Body is just identity — group
+// membership is implicit (we add them to the dezky-platform-admins Authentik
+// group). Tenant-member invites land later via a different endpoint with
+// tenantSlug + role fields.
+export class InviteOperatorDto {
+ @IsString() @MinLength(2) @MaxLength(120)
+ name!: string
+
+ @IsEmail() @MaxLength(254)
+ email!: string
+}
diff --git a/services/platform-api/src/users/users.controller.ts b/services/platform-api/src/users/users.controller.ts
index 20da87f..6c4271c 100644
--- a/services/platform-api/src/users/users.controller.ts
+++ b/services/platform-api/src/users/users.controller.ts
@@ -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[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)
diff --git a/services/platform-api/src/users/users.module.ts b/services/platform-api/src/users/users.module.ts
index a1bdb00..78cff6f 100644
--- a/services/platform-api/src/users/users.module.ts
+++ b/services/platform-api/src/users/users.module.ts
@@ -2,6 +2,7 @@ import { Module } from '@nestjs/common'
import { MongooseModule } from '@nestjs/mongoose'
import { AuditModule } from '../audit/audit.module.js'
import { AuthModule } from '../auth/auth.module.js'
+import { IntegrationsModule } from '../integrations/integrations.module.js'
import { Tenant, TenantSchema } from '../schemas/tenant.schema.js'
import { User, UserSchema } from '../schemas/user.schema.js'
import { TenantsModule } from '../tenants/tenants.module.js'
@@ -16,6 +17,7 @@ import { UsersService } from './users.service.js'
]),
AuthModule,
AuditModule,
+ IntegrationsModule,
TenantsModule,
],
controllers: [UsersController],
diff --git a/services/platform-api/src/users/users.service.ts b/services/platform-api/src/users/users.service.ts
index 3130148..f49bc92 100644
--- a/services/platform-api/src/users/users.service.ts
+++ b/services/platform-api/src/users/users.service.ts
@@ -1,19 +1,34 @@
-import { ConflictException, Injectable, NotFoundException } from '@nestjs/common'
+import { ConflictException, Injectable, Logger, NotFoundException } from '@nestjs/common'
+import { ConfigService } from '@nestjs/config'
import { InjectModel } from '@nestjs/mongoose'
import { Model, Types } from 'mongoose'
import { AuditService, type AuditActor } from '../audit/audit.service.js'
+import { AuthentikClient } from '../integrations/authentik.client.js'
import { Tenant, TenantDocument } from '../schemas/tenant.schema.js'
import { User, UserDocument } from '../schemas/user.schema.js'
import type { CreateUserDto } from './dto/create-user.dto.js'
+import type { InviteOperatorDto } from './dto/invite-operator.dto.js'
import type { UpdateUserDto } from './dto/update-user.dto.js'
@Injectable()
export class UsersService {
+ private readonly logger = new Logger(UsersService.name)
+ private readonly platformAdminGroup: string
+ // Cached after first successful lookup. The dezky-platform-admins group is
+ // created once during Authentik bootstrap and never moves; no need to look
+ // it up every invite.
+ private platformAdminGroupId: string | null = null
+
constructor(
@InjectModel(User.name) private readonly userModel: Model,
@InjectModel(Tenant.name) private readonly tenantModel: Model,
private readonly audit: AuditService,
- ) {}
+ private readonly authentik: AuthentikClient,
+ config: ConfigService,
+ ) {
+ this.platformAdminGroup =
+ config.get('PLATFORM_ADMIN_BOOTSTRAP_GROUP') ?? 'dezky-platform-admins'
+ }
async create(dto: CreateUserDto): Promise {
const exists = await this.userModel.exists({ authentikSubjectId: dto.authentikSubjectId })
@@ -106,4 +121,136 @@ export class UsersService {
const tenants = await this.tenantModel.find({ slug: { $in: slugs } }, { _id: 1 }).exec()
return tenants.map((t) => t._id)
}
+
+ // Invite a new platform admin. Creates the user in Authentik, adds them to
+ // the dezky-platform-admins group, pre-creates the local User doc so they
+ // appear in the operator-team list immediately, and returns whichever
+ // credential-handoff path Authentik supports for our brand:
+ // - `link` — single-use recovery URL (preferred; requires a recovery
+ // flow configured in Authentik. User clicks → sets password
+ // + enrolls MFA themselves.)
+ // - `tempPassword` — random 16-char password we set on the user. Used
+ // when no recovery flow exists; operator hands this
+ // to the new user out-of-band and they change it on
+ // first login via Authentik's password-change flow.
+ // On their first login, upsertFromAuthentik() patches lastLoginAt +
+ // reconciles group state from the JWT — no further work needed.
+ //
+ // Email delivery is the operator's job for now (we return the credential);
+ // when outbound SMTP is wired (Phase 5/6), Authentik can email directly.
+ async inviteOperator(
+ dto: InviteOperatorDto,
+ actor?: AuditActor,
+ ): Promise<{
+ subject: string
+ userId: string
+ link?: string
+ tempPassword?: string
+ }> {
+ // Prevent duplicate by email — Authentik will 400 but its error message
+ // isn't friendly. Check up front for a clean conflict.
+ const existing = await this.authentik.findUserByEmail(dto.email)
+ if (existing) {
+ throw new ConflictException(
+ `User with email ${dto.email} already exists in Authentik (uid=${existing.uid})`,
+ )
+ }
+
+ const groupPk = await this.resolvePlatformAdminGroupId()
+ const username = dto.email // Authentik convention — keep email + username aligned
+
+ const created = await this.authentik.createUser({
+ username,
+ email: dto.email,
+ name: dto.name,
+ groupPks: [groupPk],
+ attributes: { invitedBy: actor?.email, invitedAt: new Date().toISOString() },
+ })
+
+ // Pre-create the local User doc so the operator-team list reflects the
+ // invite immediately. On their first login /users/me will upsert and
+ // reconcile lastLoginAt + platformAdmin from the JWT.
+ await this.userModel
+ .findOneAndUpdate(
+ { authentikSubjectId: created.uid },
+ {
+ $set: {
+ email: dto.email,
+ name: dto.name,
+ platformAdmin: true,
+ },
+ $setOnInsert: { role: 'admin', active: true, tenantIds: [] },
+ },
+ { upsert: true, new: true, runValidators: true },
+ )
+ .exec()
+
+ // Try the preferred recovery-link path first. If Authentik has no
+ // recovery flow configured (returns undefined), fall back to setting a
+ // generated temp password.
+ let link: string | undefined
+ let tempPassword: string | undefined
+ link = await this.authentik.recoveryLink(created.pk)
+ if (!link) {
+ tempPassword = generateTempPassword()
+ await this.authentik.setInitialPassword(created.pk, tempPassword)
+ }
+
+ void this.audit.record(
+ {
+ action: 'platform.user_invited',
+ resourceType: 'user',
+ resourceId: created.uid,
+ resourceName: dto.email,
+ metadata: {
+ role: 'platform-admin',
+ name: dto.name,
+ handoff: link ? 'recovery-link' : 'temp-password',
+ },
+ },
+ actor,
+ )
+
+ return { subject: created.uid, userId: String(created.pk), link, tempPassword }
+ }
+
+ // Resolve + cache the dezky-platform-admins group ID. The group is created
+ // by Authentik bootstrap so it's reliably present; ensureGroup is
+ // idempotent so the worst case is a no-op extra API call on cold start.
+ private async resolvePlatformAdminGroupId(): Promise {
+ if (this.platformAdminGroupId) return this.platformAdminGroupId
+ const group = await this.authentik.ensureGroup(this.platformAdminGroup, {
+ role: 'platform-admin',
+ })
+ this.platformAdminGroupId = group.pk
+ return group.pk
+ }
+}
+
+// Generates a 16-character random password with mixed character classes.
+// Authentik's default password policy requires length + complexity; this
+// generator clears every reasonable policy. The new user changes it on
+// first login.
+function generateTempPassword(): string {
+ const upper = 'ABCDEFGHJKLMNPQRSTUVWXYZ' // omit I + O (visually confusable)
+ const lower = 'abcdefghijkmnpqrstuvwxyz' // omit l (confusable with 1)
+ const digit = '23456789' // omit 0 + 1
+ const symbol = '!@#$%&*+-='
+ const all = upper + lower + digit + symbol
+
+ // Pick at least one from each class; fill the rest from `all`; shuffle.
+ const out: string[] = [
+ upper[Math.floor(Math.random() * upper.length)],
+ lower[Math.floor(Math.random() * lower.length)],
+ digit[Math.floor(Math.random() * digit.length)],
+ symbol[Math.floor(Math.random() * symbol.length)],
+ ]
+ while (out.length < 16) {
+ out.push(all[Math.floor(Math.random() * all.length)])
+ }
+ for (let i = out.length - 1; i > 0; i--) {
+ const j = Math.floor(Math.random() * (i + 1))
+ ;[out[i], out[j]] = [out[j], out[i]]
+ }
+ return out.join('')
}