feat(platform): real email domains, mailboxes & member lifecycle
Wire the mail/identity stack to real Stalwart/Authentik/OCIS provisioning, replacing the mocked Domains and Users pages. Domains (customer-admin): - StalwartClient: real JMAP management (v0.16 dropped REST) — create/list/delete email domains via x:Domain at the internal http://stalwart:8080 listener; DKIM auto-generated; the records to publish are read from the domain's dnsZoneFile. Gated by STALWART_PROVISIONING_ENABLED. - New Domain collection + DomainsModule: add/list/recheck/set-DMARC/remove, tenant-membership-gated and audited. - DnsVerifierService: verifies MX/SPF/DKIM/DMARC/ownership against a public resolver (1.1.1.1/8.8.8.8) and diffs them against the expected records. - Remove is guarded: refuses while accounts/aliases/mailing lists still use the domain (via Stalwart referential integrity). - Domains page + add wizard on real data; sidebar badge counts domains needing attention. Users & groups (customer-admin): - Create a member provisioned across Authentik SSO, a Stalwart mailbox on the tenant's primary domain, and OCIS — returning a one-time password. - Lifecycle: suspend/resume (Authentik is_active + freeze the mailbox via account permissions, original password preserved), force-logout (terminate sessions, filtered client-side so it can never end other users' sessions), reset password (new one-time password on SSO + mailbox), and remove (tear down mailbox + SSO identity + OCIS + doc; mailbox-in-use aware for multi-tenant users). Self-suspend / self-force-logout are blocked. Infra: point platform-api at the internal Stalwart listener; document the new STALWART_/provisioning vars in .env.example.
This commit is contained in:
@@ -70,6 +70,52 @@ export class AuthentikClient {
|
||||
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}`)
|
||||
}
|
||||
|
||||
// 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',
|
||||
|
||||
Reference in New Issue
Block a user