98e49bfe34
ci / typecheck (map[dir:apps/booking name:booking]) (push) Has been cancelled
ci / typecheck (map[dir:apps/portal name:portal]) (push) Has been cancelled
ci / typecheck (map[dir:apps/website name:website]) (push) Has been cancelled
ci / typecheck (map[dir:services/platform-api name:platform-api]) (push) Has been cancelled
ci / test (push) Has been cancelled
Rebuild the /admin/users detail drawer from a read-only profile into an editable, Office 365-style panel with four sections: - Username & mail: read-only primary for mailbox users; editable sign-in (Authentik-only) for mailbox-less identities; "Create mailbox" provisions a Stalwart inbox for an external-login admin - Aliases: list/add/remove mailbox aliases (Stalwart), domain-scoped - Role: member/admin toggle with a primary-account lock (owner, mailbox-less bootstrap admin, self) and a last-admin guard - Contact information: display name, first/last name, phone, alternative email — mirrored best-effort to Authentik attributes + mailbox name Ownership transfer: "Make owner" (row menu + drawer) plus an owner-side "Transfer ownership" picker, gated to tenant admins / platform admins so a departed owner can be replaced; promotes the target and demotes the prior owner to admin. Backend (platform-api): contact fields on User; AuthentikClient.updateUser; StalwartClient.setMailboxName; UsersService updateTenantMember, changeMemberPrimaryEmail, list/add/removeMemberAlias, createMailboxForMember, transferOwnership; new DTOs and tenant-member routes. All mutations audited. Portal: Nuxt proxies for the new endpoints + extended TenantUserDoc.
560 lines
22 KiB
TypeScript
560 lines
22 KiB
TypeScript
import { Injectable, Logger } from '@nestjs/common'
|
|
import { ConfigService } from '@nestjs/config'
|
|
|
|
interface AuthentikGroup {
|
|
pk: string
|
|
name: string
|
|
attributes?: Record<string, unknown>
|
|
}
|
|
|
|
// Thin wrapper around the Authentik API for the operations the provisioning
|
|
// service needs. We never expose raw Authentik errors to API callers — they
|
|
// surface as provisioningErrors.authentik strings.
|
|
@Injectable()
|
|
export class AuthentikClient {
|
|
private readonly logger = new Logger(AuthentikClient.name)
|
|
private readonly base: string
|
|
private readonly token: string
|
|
|
|
constructor(config: ConfigService) {
|
|
this.base = config.getOrThrow<string>('AUTHENTIK_API_URL')
|
|
this.token = config.getOrThrow<string>('AUTHENTIK_API_TOKEN')
|
|
}
|
|
|
|
// Public Authentik origin (no /api/v3) — for building user-facing OIDC URLs
|
|
// like the per-app issuer / .well-known discovery document.
|
|
get publicBase(): string {
|
|
return this.base.replace(/\/api\/v3\/?$/, '')
|
|
}
|
|
|
|
private async request<T>(path: string, init: RequestInit = {}): Promise<T> {
|
|
const res = await fetch(`${this.base}${path}`, {
|
|
...init,
|
|
headers: {
|
|
Authorization: `Bearer ${this.token}`,
|
|
'Content-Type': 'application/json',
|
|
Accept: 'application/json',
|
|
...(init.headers ?? {}),
|
|
},
|
|
})
|
|
if (!res.ok) {
|
|
const body = await res.text().catch(() => '')
|
|
throw new Error(`Authentik ${init.method ?? 'GET'} ${path} → ${res.status}: ${body.slice(0, 200)}`)
|
|
}
|
|
// 204 No Content (and other empty-body successes) crash res.json().
|
|
// Endpoints like /core/groups/:id/add_user/ return 204; callers with a
|
|
// void return type don't care about the payload, so hand back undefined.
|
|
if (res.status === 204 || res.headers.get('content-length') === '0') {
|
|
return undefined as T
|
|
}
|
|
return (await res.json()) as T
|
|
}
|
|
|
|
// Idempotent: returns existing group if name is taken, creates otherwise.
|
|
async ensureGroup(slug: string, attributes: Record<string, unknown> = {}): Promise<AuthentikGroup> {
|
|
const search = await this.request<{ results: AuthentikGroup[] }>(
|
|
`/core/groups/?name=${encodeURIComponent(slug)}`,
|
|
)
|
|
if (search.results.length > 0) {
|
|
this.logger.log(`Authentik group "${slug}" already exists (pk=${search.results[0].pk})`)
|
|
return search.results[0]
|
|
}
|
|
const created = await this.request<AuthentikGroup>('/core/groups/', {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
name: slug,
|
|
attributes: { role: 'tenant', slug, ...attributes },
|
|
}),
|
|
})
|
|
this.logger.log(`Created Authentik group "${slug}" (pk=${created.pk})`)
|
|
return created
|
|
}
|
|
|
|
// Fully delete a user from Authentik (used when a member is removed from their
|
|
// last tenant). 404 is tolerated so a re-run after a partial removal is safe.
|
|
async deleteUser(userPk: number): Promise<void> {
|
|
const res = await fetch(`${this.base}/core/users/${userPk}/`, {
|
|
method: 'DELETE',
|
|
headers: { Authorization: `Bearer ${this.token}` },
|
|
})
|
|
if (!res.ok && res.status !== 404) {
|
|
const body = await res.text().catch(() => '')
|
|
throw new Error(`Authentik DELETE user ${userPk} → ${res.status}: ${body.slice(0, 200)}`)
|
|
}
|
|
this.logger.log(`Deleted Authentik user ${userPk}`)
|
|
}
|
|
|
|
// Enable / disable a user. is_active=false blocks all sign-in (portal, SSO,
|
|
// and OCIS-via-SSO) without deleting anything — the basis of suspend/resume.
|
|
async setUserActive(userPk: number, active: boolean): Promise<void> {
|
|
await this.request(`/core/users/${userPk}/`, {
|
|
method: 'PATCH',
|
|
body: JSON.stringify({ is_active: active }),
|
|
})
|
|
this.logger.log(`Set Authentik user ${userPk} is_active=${active}`)
|
|
}
|
|
|
|
// Patch a user's identity / profile fields. `username` + `email` are kept
|
|
// aligned by callers (our convention). `attributesMerge`, when given, is
|
|
// read-modify-written so we don't clobber unrelated attributes — Authentik
|
|
// PATCH replaces nested objects wholesale, so a naive `attributes: {...}`
|
|
// would wipe e.g. the passwordExpired flag. No-op if nothing to change.
|
|
async updateUser(
|
|
userPk: number,
|
|
patch: { username?: string; email?: string; name?: string; attributesMerge?: Record<string, unknown> },
|
|
): Promise<void> {
|
|
const body: Record<string, unknown> = {}
|
|
if (patch.username !== undefined) body.username = patch.username
|
|
if (patch.email !== undefined) body.email = patch.email
|
|
if (patch.name !== undefined) body.name = patch.name
|
|
if (patch.attributesMerge && Object.keys(patch.attributesMerge).length > 0) {
|
|
const user = await this.request<AuthentikUser & { attributes?: Record<string, unknown> }>(
|
|
`/core/users/${userPk}/`,
|
|
)
|
|
body.attributes = { ...(user.attributes ?? {}), ...patch.attributesMerge }
|
|
}
|
|
if (Object.keys(body).length === 0) return
|
|
await this.request(`/core/users/${userPk}/`, { method: 'PATCH', body: JSON.stringify(body) })
|
|
this.logger.log(`Updated Authentik user ${userPk} (${Object.keys(body).join(', ')})`)
|
|
}
|
|
|
|
// Force-logout: terminate the user's active sessions so they must sign in
|
|
// again. Returns how many were terminated. We pass the `?user=` filter AND
|
|
// re-filter client-side on the session's `user` pk — Authentik's endpoint
|
|
// silently ignores an unknown query filter, which would otherwise return (and
|
|
// delete) EVERY user's session. The client-side filter makes that impossible.
|
|
async terminateSessions(userPk: number): Promise<number> {
|
|
const res = await this.request<{ results: Array<{ uuid: string; user: number }> }>(
|
|
`/core/authenticated_sessions/?user=${userPk}`,
|
|
)
|
|
const sessions = (res.results ?? []).filter((s) => s.user === userPk)
|
|
await Promise.all(
|
|
sessions.map((s) =>
|
|
fetch(`${this.base}/core/authenticated_sessions/${s.uuid}/`, {
|
|
method: 'DELETE',
|
|
headers: { Authorization: `Bearer ${this.token}` },
|
|
}).catch(() => {}),
|
|
),
|
|
)
|
|
this.logger.log(`Terminated ${sessions.length} Authentik session(s) for user ${userPk}`)
|
|
return sessions.length
|
|
}
|
|
|
|
async deleteGroup(groupId: string): Promise<void> {
|
|
const res = await fetch(`${this.base}/core/groups/${groupId}/`, {
|
|
method: 'DELETE',
|
|
headers: { Authorization: `Bearer ${this.token}` },
|
|
})
|
|
if (!res.ok && res.status !== 404) {
|
|
const body = await res.text().catch(() => '')
|
|
throw new Error(`Authentik DELETE group ${groupId} → ${res.status}: ${body.slice(0, 200)}`)
|
|
}
|
|
this.logger.log(`Deleted Authentik group ${groupId}`)
|
|
}
|
|
|
|
// Pull a window of Authentik events. Used by the audit ingest worker.
|
|
// `since` filters by created timestamp (strict greater-than); pagination is
|
|
// forward-only via `page`. Authentik's default page size is 100.
|
|
async listEvents(
|
|
since?: Date,
|
|
page = 1,
|
|
pageSize = 100,
|
|
): Promise<AuthentikEventPage> {
|
|
const params = new URLSearchParams({
|
|
ordering: 'created',
|
|
page: String(page),
|
|
page_size: String(pageSize),
|
|
})
|
|
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}`)
|
|
}
|
|
|
|
// Remove a user from a group by ID. Authentik 204s even if the user wasn't
|
|
// a member, so this is effectively idempotent.
|
|
async removeUserFromGroup(userPk: number, groupId: string): Promise<void> {
|
|
await this.request(`/core/groups/${groupId}/remove_user/`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({ pk: userPk }),
|
|
})
|
|
this.logger.log(`Removed user ${userPk} from Authentik group ${groupId}`)
|
|
}
|
|
|
|
// Count a user's configured authenticators (TOTP / WebAuthn / static). Used
|
|
// to surface an "MFA enrolled" badge on the partner team list — callers treat
|
|
// a count > 0 as enrolled. Authentik has no single "all devices" admin route;
|
|
// it exposes one per device type, so we query the common three and sum. Each
|
|
// returns a paginated { results } envelope.
|
|
async countAuthenticators(userPk: number): Promise<number> {
|
|
const types = ['totp', 'webauthn', 'static']
|
|
const counts = await Promise.all(
|
|
types.map(async (t) => {
|
|
try {
|
|
const res = await this.request<{ results?: unknown[] }>(
|
|
`/authenticators/admin/${t}/?user=${userPk}`,
|
|
)
|
|
return Array.isArray(res?.results) ? res.results.length : 0
|
|
} catch {
|
|
// A device type not enabled on this Authentik instance returns 404 —
|
|
// don't let it zero out the types that do resolve.
|
|
return 0
|
|
}
|
|
}),
|
|
)
|
|
return counts.reduce((a, b) => a + b, 0)
|
|
}
|
|
|
|
// 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}`)
|
|
}
|
|
|
|
// Mark the user's password as expired so Authentik forces a change at next
|
|
// login. Used by the temp-password fallback path so a stolen temp password
|
|
// can't outlive the first real session. The recovery-link path doesn't
|
|
// need this — clicking the link runs through the recovery flow which sets
|
|
// a fresh password anyway.
|
|
async markPasswordExpired(userPk: number): Promise<void> {
|
|
// Authentik stores per-user policy state under `attributes`. PATCH merges
|
|
// top-level keys but replaces nested objects, so we have to read-modify-
|
|
// write to avoid clobbering other attributes.
|
|
const user = await this.request<AuthentikUser & { attributes?: Record<string, unknown> }>(
|
|
`/core/users/${userPk}/`,
|
|
)
|
|
const attrs = {
|
|
...(user.attributes ?? {}),
|
|
// Authentik's expiry check looks at `passwordExpired` (camelCase).
|
|
passwordExpired: true,
|
|
passwordExpiredAt: new Date().toISOString(),
|
|
}
|
|
await this.request(`/core/users/${userPk}/`, {
|
|
method: 'PATCH',
|
|
body: JSON.stringify({ attributes: attrs }),
|
|
})
|
|
this.logger.log(`Marked password as expired for Authentik user ${userPk}`)
|
|
}
|
|
|
|
// ── Recovery flow bootstrap ────────────────────────────────────────────
|
|
// Default Authentik installs don't ship a recovery flow — operators have to
|
|
// either create one in the admin UI or have us provision it. The methods
|
|
// below make the bootstrap idempotent: re-running on an already-configured
|
|
// Authentik is a no-op.
|
|
|
|
async findRecoveryFlow(): Promise<AuthentikFlow | undefined> {
|
|
const res = await this.request<{ results: AuthentikFlow[] }>(
|
|
`/flows/instances/?designation=recovery`,
|
|
)
|
|
return res.results[0]
|
|
}
|
|
|
|
async getDefaultBrand(): Promise<AuthentikBrand | undefined> {
|
|
const res = await this.request<{ results: AuthentikBrand[] }>('/core/brands/')
|
|
return res.results.find((b) => b._default) ?? res.results[0]
|
|
}
|
|
|
|
async findStageByName(name: string): Promise<AuthentikStage | undefined> {
|
|
const res = await this.request<{ results: AuthentikStage[] }>(
|
|
`/stages/all/?name=${encodeURIComponent(name)}`,
|
|
)
|
|
return res.results.find((s) => s.name === name)
|
|
}
|
|
|
|
async createFlow(input: {
|
|
name: string
|
|
slug: string
|
|
title: string
|
|
designation: 'recovery' | 'authentication' | 'authorization' | 'enrollment' | 'invalidation' | 'stage_configuration' | 'unenrollment'
|
|
}): Promise<AuthentikFlow> {
|
|
return this.request<AuthentikFlow>('/flows/instances/', {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
name: input.name,
|
|
slug: input.slug,
|
|
title: input.title,
|
|
designation: input.designation,
|
|
// 'none' = no extra auth required to start the flow (the recovery
|
|
// token in the link is the auth). 'require_authenticated' would
|
|
// break the link-click path.
|
|
authentication: 'none',
|
|
// No background; the consent screen is up to the brand.
|
|
background: '',
|
|
}),
|
|
})
|
|
}
|
|
|
|
async bindStageToFlow(input: { target: string; stage: string; order: number }): Promise<void> {
|
|
await this.request('/flows/bindings/', {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
target: input.target,
|
|
stage: input.stage,
|
|
order: input.order,
|
|
evaluate_on_plan: true,
|
|
re_evaluate_policies: false,
|
|
}),
|
|
})
|
|
}
|
|
|
|
async setBrandRecoveryFlow(brandUuid: string, flowUuid: string): Promise<void> {
|
|
await this.request(`/core/brands/${brandUuid}/`, {
|
|
method: 'PATCH',
|
|
body: JSON.stringify({ flow_recovery: flowUuid }),
|
|
})
|
|
this.logger.log(`Set brand ${brandUuid} flow_recovery → ${flowUuid}`)
|
|
}
|
|
|
|
// ── SSO apps: customer registers external apps using Dezky as the IdP ─────
|
|
// We create an OAuth2/OIDC Provider + Application in Authentik and bind the
|
|
// tenant's group to the application so only that workspace's members can use
|
|
// it. Provider pk is a number; Application pk is a uuid (slug is the human id).
|
|
|
|
// Find a flow pk by designation, preferring a slug substring (e.g. the
|
|
// explicit-consent authorization flow) and falling back to the first match.
|
|
async findFlowPk(designation: string, preferSlugIncludes?: string): Promise<string | undefined> {
|
|
const res = await this.request<{ results: AuthentikFlow[] }>(
|
|
`/flows/instances/?designation=${encodeURIComponent(designation)}`,
|
|
)
|
|
if (!res.results.length) return undefined
|
|
if (preferSlugIncludes) {
|
|
const pref = res.results.find((f) => f.slug.includes(preferSlugIncludes))
|
|
if (pref) return pref.pk
|
|
}
|
|
return res.results[0].pk
|
|
}
|
|
|
|
async findSigningKeyPk(): Promise<string | undefined> {
|
|
const res = await this.request<{ results: Array<{ pk: string; private_key_available?: boolean }> }>(
|
|
`/crypto/certificatekeypairs/?has_key=true`,
|
|
)
|
|
return res.results.find((k) => k.private_key_available)?.pk ?? res.results[0]?.pk
|
|
}
|
|
|
|
// The three standard OIDC scope mappings, resolved by Authentik's stable
|
|
// `managed` identifiers (pks differ per instance).
|
|
async findOidcScopeMappingPks(): Promise<string[]> {
|
|
const res = await this.request<{ results: Array<{ pk: string; managed?: string }> }>(
|
|
`/propertymappings/provider/scope/`,
|
|
)
|
|
const wanted = new Set([
|
|
'goauthentik.io/providers/oauth2/scope-openid',
|
|
'goauthentik.io/providers/oauth2/scope-email',
|
|
'goauthentik.io/providers/oauth2/scope-profile',
|
|
])
|
|
return res.results.filter((m) => m.managed && wanted.has(m.managed)).map((m) => m.pk)
|
|
}
|
|
|
|
async createOAuth2Provider(input: {
|
|
name: string
|
|
redirectUris: string[]
|
|
clientType?: 'confidential' | 'public'
|
|
}): Promise<{ pk: number; clientId: string; clientSecret: string }> {
|
|
const [authorizationFlow, invalidationFlow, signingKey, scopeMappings] = await Promise.all([
|
|
this.findFlowPk('authorization', 'explicit-consent'),
|
|
this.findFlowPk('invalidation', 'provider-invalidation'),
|
|
this.findSigningKeyPk(),
|
|
this.findOidcScopeMappingPks(),
|
|
])
|
|
if (!authorizationFlow) throw new Error('No Authentik authorization flow available')
|
|
const body: Record<string, unknown> = {
|
|
name: input.name,
|
|
authorization_flow: authorizationFlow,
|
|
client_type: input.clientType ?? 'confidential',
|
|
redirect_uris: input.redirectUris.map((url) => ({ matching_mode: 'strict', url })),
|
|
property_mappings: scopeMappings,
|
|
sub_mode: 'hashed_user_id',
|
|
}
|
|
if (invalidationFlow) body.invalidation_flow = invalidationFlow
|
|
if (signingKey) body.signing_key = signingKey
|
|
const p = await this.request<{ pk: number; client_id: string; client_secret: string }>(
|
|
'/providers/oauth2/',
|
|
{ method: 'POST', body: JSON.stringify(body) },
|
|
)
|
|
this.logger.log(`Created Authentik OAuth2 provider "${input.name}" (pk=${p.pk})`)
|
|
return { pk: p.pk, clientId: p.client_id, clientSecret: p.client_secret }
|
|
}
|
|
|
|
async createApplication(input: {
|
|
name: string
|
|
slug: string
|
|
providerPk: number
|
|
group?: string
|
|
}): Promise<{ pk: string; slug: string }> {
|
|
const app = await this.request<{ pk: string; slug: string }>('/core/applications/', {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
name: input.name,
|
|
slug: input.slug,
|
|
provider: input.providerPk,
|
|
group: input.group ?? '',
|
|
}),
|
|
})
|
|
this.logger.log(`Created Authentik application "${input.slug}" (pk=${app.pk})`)
|
|
return { pk: app.pk, slug: app.slug }
|
|
}
|
|
|
|
// A binding with a group and no policy = allow that group. Scopes the app to
|
|
// the tenant's workspace members.
|
|
async bindGroupToApplication(appPk: string, groupPk: string): Promise<void> {
|
|
await this.request('/policies/bindings/', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ target: appPk, group: groupPk, order: 0, enabled: true }),
|
|
})
|
|
}
|
|
|
|
async deleteApplication(slug: string): Promise<void> {
|
|
const res = await fetch(`${this.base}/core/applications/${slug}/`, {
|
|
method: 'DELETE',
|
|
headers: { Authorization: `Bearer ${this.token}` },
|
|
})
|
|
if (!res.ok && res.status !== 404) {
|
|
const body = await res.text().catch(() => '')
|
|
throw new Error(`Authentik DELETE application ${slug} → ${res.status}: ${body.slice(0, 200)}`)
|
|
}
|
|
}
|
|
|
|
async deleteOAuth2Provider(pk: number): Promise<void> {
|
|
const res = await fetch(`${this.base}/providers/oauth2/${pk}/`, {
|
|
method: 'DELETE',
|
|
headers: { Authorization: `Bearer ${this.token}` },
|
|
})
|
|
if (!res.ok && res.status !== 404) {
|
|
const body = await res.text().catch(() => '')
|
|
throw new Error(`Authentik DELETE provider ${pk} → ${res.status}: ${body.slice(0, 200)}`)
|
|
}
|
|
}
|
|
}
|
|
|
|
export interface AuthentikFlow {
|
|
pk: string
|
|
slug: string
|
|
name: string
|
|
title: string
|
|
designation: string
|
|
}
|
|
|
|
export interface AuthentikBrand {
|
|
brand_uuid: string
|
|
domain: string
|
|
_default?: boolean
|
|
flow_recovery?: string | null
|
|
}
|
|
|
|
export interface AuthentikStage {
|
|
pk: string
|
|
name: string
|
|
component: string
|
|
}
|
|
|
|
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
|
|
// includes a number of others (tenant, brand) we don't need.
|
|
export interface AuthentikEvent {
|
|
pk: string
|
|
action: string
|
|
app?: string
|
|
user?: { pk?: number; username?: string; name?: string; email?: string }
|
|
context?: Record<string, unknown>
|
|
client_ip?: string
|
|
created: string
|
|
}
|
|
export interface AuthentikEventPage {
|
|
pagination: { next: number; previous: number; count: number; current: number; total_pages: number; start_index: number; end_index: number }
|
|
results: AuthentikEvent[]
|
|
}
|