Some checks are pending
E2E / Playwright e2e (push) Waiting to run
- 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.
143 lines
4.4 KiB
TypeScript
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 }
|