import { AUTH_FLOW_SLUGS, type AuthFlowSlug } from "@/lib/auth/auth-flow-slugs" export type FlowChallenge = Record export type FlowStepResponse = { sessionId: string challenge: FlowChallenge done: boolean denied: boolean } export type FlowCompleteResponse = { redirectUrl: string } export type FlowApiError = { code?: string message?: string } function flowApiBase(slug: AuthFlowSlug): string { return `/api/v1/auth/flows/${encodeURIComponent(slug)}` } function buildQuerySuffix(query?: string): string { const trimmed = query?.trim() if (!trimmed) return "" return `?query=${encodeURIComponent(trimmed)}` } async function parseFlowResponse(res: Response): Promise { const body = (await res.json()) as FlowStepResponse & FlowApiError if (!res.ok) { throw new Error(body.message ?? `flow request failed (${res.status})`) } return body } export async function startAuthFlow( slug: AuthFlowSlug, query?: string ): Promise { const res = await fetch(`${flowApiBase(slug)}/start${buildQuerySuffix(query)}`, { method: "POST", credentials: "include", headers: { Accept: "application/json" }, }) return parseFlowResponse(res) } export async function respondAuthFlow( slug: AuthFlowSlug, payload: Record, query?: string ): Promise { const res = await fetch(`${flowApiBase(slug)}/respond${buildQuerySuffix(query)}`, { method: "POST", credentials: "include", headers: { Accept: "application/json", "Content-Type": "application/json", }, body: JSON.stringify({ payload }), }) return parseFlowResponse(res) } /** Bridge embedded authentication to OIDC session (sets Authentik cookies + login URL). */ export async function completeAuthFlow(returnTo: string): Promise { const res = await fetch("/api/v1/auth/flows/complete", { method: "POST", credentials: "include", headers: { Accept: "application/json", "Content-Type": "application/json", }, body: JSON.stringify({ returnTo }), }) const body = (await res.json()) as FlowCompleteResponse & FlowApiError if (!res.ok) { throw new Error(body.message ?? `flow complete failed (${res.status})`) } return body } export function flowComponent(challenge: FlowChallenge | null | undefined): string { if (!challenge) return "" const value = challenge.component return typeof value === "string" ? value : "" } /** Recovery flow: email stage reached after identification means the reset link was sent. */ export function isRecoveryEmailSent( slug: AuthFlowSlug, challenge: FlowChallenge | null | undefined ): boolean { if (slug !== AUTH_FLOW_SLUGS.recovery || !challenge) return false if (flowComponent(challenge) !== "ak-stage-email") return false const errors = challenge.response_errors if (!errors || typeof errors !== "object") return true const record = errors as Record for (const [key, value] of Object.entries(record)) { if (key === "non_field_errors" && Array.isArray(value)) { if (value.length === 0) continue const onlyEmailSent = value.every( (item) => typeof item === "object" && item !== null && (item as Record).code === "email-sent" ) if (onlyEmailSent) return true return false } if (Array.isArray(value) && value.length > 0) return false if (typeof value === "string" && value.trim()) return false } return true } /** Extract Authentik field validation errors from a challenge. */ export function flowValidationErrors( challenge: FlowChallenge | null | undefined ): Record { const out: Record = {} const errors = challenge?.response_errors if (!errors || typeof errors !== "object") return out for (const [key, value] of Object.entries(errors as Record)) { if (key === "non_field_errors" && Array.isArray(value)) { const messages = value .map((item) => formatFlowErrorItem(item)) .filter(Boolean) if (messages.length > 0) { out._form = messages.join(" ") } continue } if (Array.isArray(value)) { const messages = value.map((item) => formatFlowErrorItem(item)).filter(Boolean) if (messages.length > 0) out[key] = messages.join(" ") } else if (typeof value === "string" && value.trim()) { out[key] = value } } return out } function formatFlowErrorItem(item: unknown): string { if (typeof item === "string") return item if (typeof item === "object" && item !== null) { const record = item as Record if (typeof record.string === "string" && record.string.trim()) return record.string if (typeof record.code === "string" && record.code.trim()) return record.code } return "" } export function flowTitle(challenge: FlowChallenge | null | undefined): string { const info = challenge?.flow_info if (info && typeof info === "object" && info !== null) { const title = (info as Record).title if (typeof title === "string" && title.trim()) return title } return "" } export function flowRedirectUrl(challenge: FlowChallenge | null | undefined): string { const to = challenge?.to return typeof to === "string" ? to : "" } export { AUTH_FLOW_SLUGS }