ultisuite-client/lib/auth/flow-api.ts
R3D347HR4Y 359931c2f3
Some checks are pending
E2E / Playwright e2e (push) Waiting to run
feat: implement forgot password and signup flows with new layouts and components
- Added new layout and page components for forgot password and signup functionalities, enhancing user experience.
- Integrated authentication flow handling for password recovery and account creation, utilizing dynamic metadata.
- Updated login form to include links for forgot password and signup, improving navigation between authentication states.
- Refactored CSS styles for login components to ensure consistent design across different authentication pages.
2026-06-19 22:34:23 +02:00

143 lines
4.4 KiB
TypeScript

import { AUTH_FLOW_SLUGS, type AuthFlowSlug } from "@/lib/auth/auth-flow-slugs"
export type FlowChallenge = Record<string, unknown>
export type FlowStepResponse = {
sessionId: string
challenge: FlowChallenge
done: boolean
denied: boolean
}
export type FlowApiError = {
code?: string
message?: string
}
function flowApiBase(slug: AuthFlowSlug): string {
return `/api/v1/auth/flows/${encodeURIComponent(slug)}`
}
async function parseFlowResponse(res: Response): Promise<FlowStepResponse> {
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): Promise<FlowStepResponse> {
const res = await fetch(`${flowApiBase(slug)}/start`, {
method: "POST",
credentials: "include",
headers: { Accept: "application/json" },
})
return parseFlowResponse(res)
}
export async function respondAuthFlow(
slug: AuthFlowSlug,
payload: Record<string, unknown>
): Promise<FlowStepResponse> {
const res = await fetch(`${flowApiBase(slug)}/respond`, {
method: "POST",
credentials: "include",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify({ payload }),
})
return parseFlowResponse(res)
}
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<string, unknown>
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<string, unknown>).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<string, string> {
const out: Record<string, string> = {}
const errors = challenge?.response_errors
if (!errors || typeof errors !== "object") return out
for (const [key, value] of Object.entries(errors as Record<string, unknown>)) {
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<string, unknown>
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<string, unknown>).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 }