feat: enhance authentication and password reset flows with new components and layout
Some checks are pending
E2E / Playwright e2e (push) Waiting to run

- Replaced LoginForm with LoginPageContent for improved login handling and user experience.
- Introduced ResetPasswordPage and ResetPasswordLayout components to facilitate password reset functionality.
- Added new flow stages for authentication, including PasswordStage and SourceOAuthStage, to streamline user interactions.
- Updated FlowChallengeForm to integrate new stages and improve error handling during authentication processes.
- Refactored existing components to support the new authentication flow structure, enhancing maintainability and user experience.
This commit is contained in:
R3D347HR4Y 2026-06-20 01:09:30 +02:00
parent 496b1dfc1f
commit de5b5a60ef
17 changed files with 597 additions and 67 deletions

View File

@ -2,7 +2,7 @@
import { useSearchParams } from "next/navigation"
import { Suspense } from "react"
import { LoginForm } from "@/components/auth/login-form"
import { LoginPageContent } from "@/components/auth/login-page-content"
import { useNativeRuntime } from "@/lib/platform"
import { NativeLogin } from "@/components/mobile/native-login"
@ -10,22 +10,12 @@ function LoginContent() {
const searchParams = useSearchParams()
const error = searchParams.get("error")
const returnTo = searchParams.get("returnTo") ?? "/mail/inbox"
const loginHref = `/api/auth/login?returnTo=${encodeURIComponent(returnTo)}`
const signupHref = `/signup?returnTo=${encodeURIComponent(returnTo)}`
const forgotPasswordHref = `/forgot-password?returnTo=${encodeURIComponent(returnTo)}`
if (useNativeRuntime()) {
return <NativeLogin returnTo={returnTo} />
}
return (
<LoginForm
loginHref={loginHref}
signupHref={signupHref}
forgotPasswordHref={forgotPasswordHref}
error={error}
/>
)
return <LoginPageContent returnTo={returnTo} error={error} />
}
export default function LoginPage() {

View File

@ -0,0 +1,16 @@
import { LoginChrome } from "@/components/auth/login-chrome"
import type { Metadata } from "next"
import { suitePageMetadata } from "@/lib/suite/page-metadata"
export const metadata: Metadata = suitePageMetadata({
app: "suite",
title: "Réinitialiser le mot de passe",
})
export default function ResetPasswordLayout({
children,
}: {
children: React.ReactNode
}) {
return <LoginChrome>{children}</LoginChrome>
}

View File

@ -0,0 +1,20 @@
"use client"
import { useSearchParams } from "next/navigation"
import { Suspense } from "react"
import { ResetPasswordPageContent } from "@/components/auth/reset-password-page-content"
function ResetPasswordContent() {
const searchParams = useSearchParams()
const flowQuery = searchParams.toString()
return <ResetPasswordPageContent flowQuery={flowQuery || undefined} />
}
export default function ResetPasswordPage() {
return (
<Suspense fallback={null}>
<ResetPasswordContent />
</Suspense>
)
}

View File

@ -1,12 +1,17 @@
"use client"
import { useCallback, useEffect, useMemo, useState } from "react"
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { Loader2 } from "lucide-react"
import { AuthCard } from "@/components/auth/auth-card"
import { AuthConnectButton } from "@/components/auth/auth-connect-button"
import { FlowChallengeForm } from "@/components/auth/flow-challenge-form"
import {
AUTH_FLOW_SLUGS,
isAuthenticationFlow,
type AuthFlowSlug,
} from "@/lib/auth/auth-flow-slugs"
import {
completeAuthFlow,
flowComponent,
flowRedirectUrl,
flowTitle,
@ -14,12 +19,12 @@ import {
isRecoveryEmailSent,
respondAuthFlow,
startAuthFlow,
type AuthFlowSlug,
type FlowChallenge,
} from "@/lib/auth/flow-api"
type AuthFlowPageProps = {
slug: AuthFlowSlug
flowQuery?: string
defaultTitle: string
defaultDescription: string
successTitle: string
@ -28,12 +33,26 @@ type AuthFlowPageProps = {
successHref: string
/** Full document navigation required (OIDC login routes). */
successExternal?: boolean
/** After embedded auth flow, bridge to OIDC session via BFF /flows/complete. */
bridgeAuthentication?: boolean
returnTo?: string
initialError?: string | null
footer: React.ReactNode
onSuccess?: () => void
}
function decodeAuthError(value: string | null | undefined): string | null {
if (!value) return null
try {
return decodeURIComponent(value)
} catch {
return value
}
}
export function AuthFlowPage({
slug,
flowQuery,
defaultTitle,
defaultDescription,
successTitle,
@ -41,6 +60,9 @@ export function AuthFlowPage({
successActionLabel,
successHref,
successExternal = false,
bridgeAuthentication = false,
returnTo = "/mail/inbox",
initialError = null,
footer,
onSuccess,
}: AuthFlowPageProps) {
@ -48,35 +70,92 @@ export function AuthFlowPage({
const [challenge, setChallenge] = useState<FlowChallenge | null>(null)
const [loading, setLoading] = useState(true)
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
const [bridging, setBridging] = useState(false)
const [flowError, setFlowError] = useState<string | null>(null)
const [done, setDone] = useState(false)
const [denied, setDenied] = useState(false)
const bridgedRef = useRef(false)
const redirectError = decodeAuthError(initialError)
const finishAuthentication = useCallback(async () => {
if (!bridgeAuthentication || !isAuthenticationFlow(slug) || bridgedRef.current) {
onSuccess?.()
return
}
bridgedRef.current = true
setBridging(true)
setFlowError(null)
try {
const { redirectUrl } = await completeAuthFlow(returnTo)
window.location.href = redirectUrl
} catch (err) {
bridgedRef.current = false
setBridging(false)
setFlowError(
err instanceof Error ? err.message : "Impossible de finaliser la connexion"
)
}
}, [bridgeAuthentication, onSuccess, returnTo, slug])
const redirectToOidcLogin = useCallback(() => {
const params = new URLSearchParams({ returnTo })
window.location.assign(`/api/auth/login?${params.toString()}`)
}, [returnTo])
const bootstrap = useCallback(async () => {
setLoading(true)
setError(null)
try {
const step = await startAuthFlow(slug)
const step = await startAuthFlow(slug, flowQuery)
setSessionId(step.sessionId)
setChallenge(step.challenge)
setDone(step.done)
setDenied(step.denied)
if (step.done && !step.denied) {
if (bridgeAuthentication) {
// Start returned immediate redirect — no flow session cookie for /complete.
redirectToOidcLogin()
return
}
onSuccess?.()
}
} catch (err) {
setError(err instanceof Error ? err.message : "Impossible de démarrer le parcours")
setFlowError(
err instanceof Error ? err.message : "Impossible de démarrer le parcours"
)
} finally {
setLoading(false)
}
}, [onSuccess, slug])
}, [bridgeAuthentication, flowQuery, onSuccess, redirectToOidcLogin, slug])
useEffect(() => {
void bootstrap()
}, [bootstrap])
const handleSubmit = useCallback(
async (payload: Record<string, unknown>) => {
if (!sessionId) return
setSubmitting(true)
setFlowError(null)
try {
const step = await respondAuthFlow(slug, payload, flowQuery)
setSessionId(step.sessionId)
setChallenge(step.challenge)
setDone(step.done)
setDenied(step.denied)
if (step.done && !step.denied) {
await finishAuthentication()
}
} catch (err) {
setFlowError(err instanceof Error ? err.message : "Échec de l'étape")
} finally {
setSubmitting(false)
}
},
[finishAuthentication, flowQuery, sessionId, slug]
)
const validationErrors = useMemo(() => flowValidationErrors(challenge), [challenge])
const formError = error ?? validationErrors._form ?? null
const formError = flowError ?? redirectError ?? validationErrors._form ?? null
const title = useMemo(() => {
if ((done && !denied) || isRecoveryEmailSent(slug, challenge)) return successTitle
@ -92,37 +171,18 @@ export function AuthFlowPage({
return defaultDescription
}, [challenge, defaultDescription, denied, done, slug, successDescription])
async function handleSubmit(payload: Record<string, unknown>) {
if (!sessionId) return
setSubmitting(true)
setError(null)
try {
const step = await respondAuthFlow(slug, payload)
setSessionId(step.sessionId)
setChallenge(step.challenge)
setDone(step.done)
setDenied(step.denied)
if (step.done && !step.denied) {
onSuccess?.()
}
} catch (err) {
setError(err instanceof Error ? err.message : "Échec de l'étape")
} finally {
setSubmitting(false)
}
}
const component = flowComponent(challenge)
const redirectUrl = flowRedirectUrl(challenge)
const recoveryEmailSent = isRecoveryEmailSent(slug, challenge)
const showSuccess = (done && !denied) || recoveryEmailSent
const showSuccess =
(done && !denied && !bridgeAuthentication) || recoveryEmailSent
return (
<AuthCard title={title} description={description} error={formError} footer={footer}>
{loading ? (
{loading || bridging ? (
<div className="flex justify-center py-8">
<Loader2 className="size-6 animate-spin text-muted-foreground" aria-hidden />
<span className="sr-only">Chargement</span>
<span className="sr-only">{bridging ? "Finalisation…" : "Chargement…"}</span>
</div>
) : showSuccess ? (
<div className="flex flex-col items-center gap-4">

View File

@ -13,6 +13,26 @@ import { AUTH_FLOW_SLUGS } from "@/lib/auth/auth-flow-slugs"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import type { FlowChallenge } from "@/lib/auth/flow-api"
import { flowRedirectUrl } from "@/lib/auth/flow-api"
import {
FLOW_STAGE_REGISTRY,
isKnownFlowComponent,
readPrimaryAction,
} from "@/components/auth/flow-stage-registry"
import { PasswordStage, UserLoginStage } from "@/components/auth/flow-stages/password-stage"
import { WebAuthnStage } from "@/components/auth/flow-stages/webauthn-stage"
import { SourceOAuthStage } from "@/components/auth/flow-stages/source-oauth-stage"
import {
AccessDeniedStage,
FlowRedirectStage,
} from "@/components/auth/flow-stages/access-denied-stage"
FLOW_STAGE_REGISTRY["ak-stage-password"] = PasswordStage
FLOW_STAGE_REGISTRY["ak-stage-user-login"] = UserLoginStage
FLOW_STAGE_REGISTRY["ak-stage-authenticator-webauthn"] = WebAuthnStage
FLOW_STAGE_REGISTRY["ak-stage-source"] = SourceOAuthStage
FLOW_STAGE_REGISTRY["ak-stage-access-denied"] = AccessDeniedStage
FLOW_STAGE_REGISTRY["xak-flow-redirect"] = FlowRedirectStage
type PromptField = {
field_key: string
@ -51,6 +71,7 @@ export function FlowChallengeForm({
flowSlug === AUTH_FLOW_SLUGS.enrollment &&
component === "ak-stage-prompt" &&
isSignupCredentialsStep(promptFields)
const RegistryStage = FLOW_STAGE_REGISTRY[component]
useEffect(() => {
if (!signupCredentials) {
@ -78,6 +99,12 @@ export function FlowChallengeForm({
if (component === "ak-stage-email") {
next.email = ""
}
if (component === "ak-stage-authenticator-validate") {
next.code = ""
}
if (component === "ak-stage-password") {
next.password = ""
}
setValues(next)
}, [challenge, component, promptFields])
@ -108,17 +135,66 @@ export function FlowChallengeForm({
payload.email = values.email ?? ""
} else if (component === "ak-stage-authenticator-validate") {
payload.code = values.code ?? ""
} else {
} else if (component === "ak-stage-password") {
payload.password = values.password ?? ""
} else if (!RegistryStage) {
Object.assign(payload, values)
}
await onSubmit(payload)
}
if (!component || component === "ak-stage-access-denied") {
if (!component) {
return null
}
const autoSubmitStage =
component === "ak-stage-user-login" ||
component === "ak-stage-authenticator-webauthn" ||
component === "xak-flow-redirect" ||
(component === "ak-stage-source" && Boolean(flowRedirectUrl(challenge)))
if (component === "ak-stage-access-denied") {
return <AccessDeniedStage />
}
if (RegistryStage) {
if (component === "ak-stage-password") {
return (
<form className="space-y-4" autoComplete="on" onSubmit={handleSubmit}>
<RegistryStage
challenge={challenge}
values={values}
fieldErrors={fieldErrors}
submitting={submitting}
onChange={(key, value) => setValues((prev) => ({ ...prev, [key]: value }))}
onSubmit={onSubmit}
/>
<AuthConnectButton type="submit" disabled={submitting}>
{submitting ? (
<>
<Loader2 className="size-4 animate-spin" aria-hidden />
<span>Patientez</span>
</>
) : (
primaryAction
)}
</AuthConnectButton>
</form>
)
}
return (
<RegistryStage
challenge={challenge}
values={values}
fieldErrors={fieldErrors}
submitting={submitting}
onChange={(key, value) => setValues((prev) => ({ ...prev, [key]: value }))}
onSubmit={onSubmit}
/>
)
}
return (
<form className="space-y-4" autoComplete="on" onSubmit={handleSubmit}>
{component === "ak-stage-prompt" ? (
@ -181,13 +257,13 @@ export function FlowChallengeForm({
/>
) : null}
{!isKnownComponent(component) ? (
{!isKnownFlowComponent(component) ? (
<p className="text-sm text-muted-foreground">
Étape non prise en charge ({component}). Utilisez le portail Authentik.
</p>
) : null}
{isKnownComponent(component) ? (
{isKnownFlowComponent(component) && !autoSubmitStage ? (
<AuthConnectButton
type="submit"
disabled={submitting || (signupCredentials && !signupCanSubmit)}
@ -414,12 +490,6 @@ function isPromptField(value: unknown): value is PromptField {
typeof (value as PromptField).field_key === "string"
)
}
function readPrimaryAction(challenge: FlowChallenge | null): string {
const action = challenge?.primary_action
return typeof action === "string" && action.trim() ? action : "Continuer"
}
function readPasswordFields(challenge: FlowChallenge | null): boolean {
return challenge?.password_fields === true
}
@ -441,11 +511,3 @@ function autoCompleteFor(fieldKey: string, type?: string): string | undefined {
return undefined
}
function isKnownComponent(component: string): boolean {
return [
"ak-stage-prompt",
"ak-stage-identification",
"ak-stage-email",
"ak-stage-authenticator-validate",
].includes(component)
}

View File

@ -0,0 +1,39 @@
"use client"
import type { FlowChallenge } from "@/lib/auth/flow-api"
export type StageRendererProps = {
challenge: FlowChallenge | null
values: Record<string, string>
fieldErrors: Record<string, string>
submitting: boolean
onChange: (key: string, value: string) => void
onSubmit: (payload: Record<string, unknown>) => void | Promise<void>
}
export type StageRenderer = React.ComponentType<StageRendererProps>
export function readPrimaryAction(challenge: FlowChallenge | null): string {
const action = challenge?.primary_action
return typeof action === "string" && action.trim() ? action : "Continuer"
}
export function isKnownFlowComponent(component: string): boolean {
return component in FLOW_STAGE_REGISTRY || LEGACY_KNOWN_COMPONENTS.has(component)
}
const LEGACY_KNOWN_COMPONENTS = new Set([
"ak-stage-prompt",
"ak-stage-identification",
"ak-stage-email",
"ak-stage-authenticator-validate",
"ak-stage-password",
"ak-stage-user-login",
"ak-stage-authenticator-webauthn",
"ak-stage-source",
"xak-flow-redirect",
"ak-stage-access-denied",
])
/** Registry for Phase 3 stages. */
export const FLOW_STAGE_REGISTRY: Record<string, StageRenderer> = {}

View File

@ -0,0 +1,19 @@
"use client"
import type { StageRendererProps } from "@/components/auth/flow-stage-registry"
export function AccessDeniedStage() {
return (
<p className="text-sm text-muted-foreground">
Accès refusé. Vérifiez vos identifiants ou contactez le support.
</p>
)
}
export function FlowRedirectStage({ challenge }: StageRendererProps) {
return (
<p className="text-center text-sm text-muted-foreground">
Finalisation de la connexion
</p>
)
}

View File

@ -0,0 +1,31 @@
"use client"
import { useEffect, useRef } from "react"
import { PasswordFieldBlock } from "@/components/auth/password-field-block"
import type { StageRendererProps } from "@/components/auth/flow-stage-registry"
export function PasswordStage({ values, fieldErrors, onChange }: StageRendererProps) {
return (
<PasswordFieldBlock
id="password"
label="Mot de passe"
value={values.password ?? ""}
error={fieldErrors.password}
required
autoComplete="current-password"
onChange={(value) => onChange("password", value)}
/>
)
}
export function UserLoginStage({ submitting, onSubmit }: StageRendererProps) {
const submittedRef = useRef(false)
useEffect(() => {
if (submitting || submittedRef.current) return
submittedRef.current = true
void onSubmit({ component: "ak-stage-user-login" })
}, [onSubmit, submitting])
return null
}

View File

@ -0,0 +1,67 @@
"use client"
import { useEffect } from "react"
import { AuthConnectButton } from "@/components/auth/auth-connect-button"
import type { StageRendererProps } from "@/components/auth/flow-stage-registry"
import { flowRedirectUrl } from "@/lib/auth/flow-api"
type FlowSource = {
pk?: string
name?: string
}
function readSources(challenge: StageRendererProps["challenge"]): FlowSource[] {
const raw = challenge?.sources
if (!Array.isArray(raw)) return []
return raw.filter(
(item): item is FlowSource =>
typeof item === "object" && item !== null && typeof (item as FlowSource).pk === "string"
)
}
export function SourceOAuthStage({ challenge, submitting, onSubmit }: StageRendererProps) {
const redirect = flowRedirectUrl(challenge)
const sources = readSources(challenge)
useEffect(() => {
if (redirect) {
window.location.href = redirect
}
}, [redirect])
if (redirect) {
return (
<p className="text-center text-sm text-muted-foreground">
Redirection vers le fournisseur
</p>
)
}
if (sources.length === 0) {
return (
<p className="text-sm text-muted-foreground">
Connexion tierce indisponible. Utilisez votre identifiant UltiSpace.
</p>
)
}
return (
<div className="flex flex-col gap-3">
{sources.map((source) => (
<AuthConnectButton
key={source.pk}
type="button"
disabled={submitting}
onClick={() =>
void onSubmit({
component: "ak-stage-source",
selected_source: source.pk,
})
}
>
{source.name ?? "Continuer"}
</AuthConnectButton>
))}
</div>
)
}

View File

@ -0,0 +1,100 @@
"use client"
import { useEffect, useRef } from "react"
import { Loader2 } from "lucide-react"
import type { StageRendererProps } from "@/components/auth/flow-stage-registry"
function readRequestOptions(
challenge: StageRendererProps["challenge"]
): PublicKeyCredentialRequestOptions | null {
const raw = challenge?.request_options ?? challenge?.requestOptions
if (!raw || typeof raw !== "object") return null
const nested = raw as { publicKey?: PublicKeyCredentialRequestOptions }
if (nested.publicKey) return nested.publicKey
return raw as PublicKeyCredentialRequestOptions
}
function bufferFromBase64URL(value: string): ArrayBuffer {
const padded = value.replace(/-/g, "+").replace(/_/g, "/")
const pad = padded.length % 4 === 0 ? "" : "=".repeat(4 - (padded.length % 4))
const binary = atob(padded + pad)
const bytes = new Uint8Array(binary.length)
for (let i = 0; i < binary.length; i += 1) {
bytes[i] = binary.charCodeAt(i)
}
return bytes.buffer
}
function bufferToBase64URL(buffer: ArrayBuffer): string {
const bytes = new Uint8Array(buffer)
let binary = ""
for (let i = 0; i < bytes.length; i += 1) {
binary += String.fromCharCode(bytes[i]!)
}
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "")
}
function normalizeRequestOptions(
options: PublicKeyCredentialRequestOptions
): PublicKeyCredentialRequestOptions {
const challenge = options.challenge
if (typeof challenge === "string") {
return { ...options, challenge: bufferFromBase64URL(challenge) }
}
return options
}
function serializeCredential(credential: PublicKeyCredential): Record<string, unknown> {
const response = credential.response as AuthenticatorAssertionResponse
return {
id: credential.id,
rawId: bufferToBase64URL(credential.rawId),
type: credential.type,
response: {
clientDataJSON: bufferToBase64URL(response.clientDataJSON),
authenticatorData: bufferToBase64URL(response.authenticatorData),
signature: bufferToBase64URL(response.signature),
userHandle: response.userHandle
? bufferToBase64URL(response.userHandle)
: null,
},
}
}
export function WebAuthnStage({ challenge, submitting, onSubmit }: StageRendererProps) {
const startedRef = useRef(false)
useEffect(() => {
if (startedRef.current || submitting) return
const options = readRequestOptions(challenge)
if (!options || typeof window === "undefined" || !window.PublicKeyCredential) {
return
}
startedRef.current = true
void (async () => {
try {
const credential = (await navigator.credentials.get({
publicKey: normalizeRequestOptions(options),
})) as PublicKeyCredential | null
if (!credential) {
startedRef.current = false
return
}
await onSubmit({
component: "ak-stage-authenticator-webauthn",
response: serializeCredential(credential),
})
} catch {
startedRef.current = false
}
})()
}, [challenge, onSubmit, submitting])
return (
<div className="flex flex-col items-center gap-3 py-4 text-sm text-muted-foreground">
<Loader2 className="size-5 animate-spin" aria-hidden />
<p>Suivez l&apos;invite de votre clé de sécurité ou biométrie</p>
</div>
)
}

View File

@ -0,0 +1,55 @@
"use client"
import Link from "next/link"
import { AuthFlowPage } from "@/components/auth/auth-flow-page"
import { AUTH_FLOW_SLUGS } from "@/lib/auth/auth-flow-slugs"
type LoginPageContentProps = {
returnTo?: string
error?: string | null
}
export function LoginPageContent({
returnTo = "/mail/inbox",
error = null,
}: LoginPageContentProps) {
const signupHref = `/signup?returnTo=${encodeURIComponent(returnTo)}`
const forgotPasswordHref = `/forgot-password?returnTo=${encodeURIComponent(returnTo)}`
const oidcFallbackHref = `/api/auth/login?returnTo=${encodeURIComponent(returnTo)}`
return (
<AuthFlowPage
slug={AUTH_FLOW_SLUGS.authentication}
flowQuery={undefined}
defaultTitle="Connexion"
defaultDescription="Connecte-toi avec ton compte UltiSpace pour accéder à ta suite."
successTitle="Connexion réussie"
successDescription="Redirection vers votre espace…"
successActionLabel="Continuer"
successHref={returnTo}
bridgeAuthentication
returnTo={returnTo}
initialError={error}
footer={
<div className="flex w-full flex-col gap-3 text-center text-sm text-muted-foreground">
<p>
<a className="font-medium text-primary underline" href={oidcFallbackHref}>
Connexion via redirect UltiSpace
</a>
</p>
<p>
Pas encore de compte ?{" "}
<Link className="font-medium text-primary underline" href={signupHref}>
Créer un compte
</Link>
</p>
<p>
<Link className="font-medium text-primary underline" href={forgotPasswordHref}>
Mot de passe oublié ?
</Link>
</p>
</div>
}
/>
)
}

View File

@ -0,0 +1,31 @@
"use client"
import Link from "next/link"
import { AuthFlowPage } from "@/components/auth/auth-flow-page"
import { AUTH_FLOW_SLUGS } from "@/lib/auth/auth-flow-slugs"
type ResetPasswordPageContentProps = {
flowQuery?: string
}
export function ResetPasswordPageContent({ flowQuery }: ResetPasswordPageContentProps) {
return (
<AuthFlowPage
slug={AUTH_FLOW_SLUGS.recovery}
flowQuery={flowQuery}
defaultTitle="Nouveau mot de passe"
defaultDescription="Choisissez un nouveau mot de passe pour votre compte UltiSpace."
successTitle="Mot de passe mis à jour"
successDescription="Votre mot de passe a été modifié. Connectez-vous pour continuer."
successActionLabel="Se connecter"
successHref="/login"
footer={
<p className="w-full text-center text-sm text-muted-foreground">
<Link className="font-medium text-primary underline" href="/login">
Retour à la connexion
</Link>
</p>
}
/>
)
}

View File

@ -2,6 +2,11 @@
export const AUTH_FLOW_SLUGS = {
enrollment: "ulti-enrollment",
recovery: "ulti-recovery",
authentication: "default-authentication-flow",
} as const
export type AuthFlowSlug = (typeof AUTH_FLOW_SLUGS)[keyof typeof AUTH_FLOW_SLUGS]
export function isAuthenticationFlow(slug: AuthFlowSlug): boolean {
return slug === AUTH_FLOW_SLUGS.authentication
}

View File

@ -9,6 +9,10 @@ export type FlowStepResponse = {
denied: boolean
}
export type FlowCompleteResponse = {
redirectUrl: string
}
export type FlowApiError = {
code?: string
message?: string
@ -18,6 +22,12 @@ 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<FlowStepResponse> {
const body = (await res.json()) as FlowStepResponse & FlowApiError
if (!res.ok) {
@ -26,8 +36,11 @@ async function parseFlowResponse(res: Response): Promise<FlowStepResponse> {
return body
}
export async function startAuthFlow(slug: AuthFlowSlug): Promise<FlowStepResponse> {
const res = await fetch(`${flowApiBase(slug)}/start`, {
export async function startAuthFlow(
slug: AuthFlowSlug,
query?: string
): Promise<FlowStepResponse> {
const res = await fetch(`${flowApiBase(slug)}/start${buildQuerySuffix(query)}`, {
method: "POST",
credentials: "include",
headers: { Accept: "application/json" },
@ -37,9 +50,10 @@ export async function startAuthFlow(slug: AuthFlowSlug): Promise<FlowStepRespons
export async function respondAuthFlow(
slug: AuthFlowSlug,
payload: Record<string, unknown>
payload: Record<string, unknown>,
query?: string
): Promise<FlowStepResponse> {
const res = await fetch(`${flowApiBase(slug)}/respond`, {
const res = await fetch(`${flowApiBase(slug)}/respond${buildQuerySuffix(query)}`, {
method: "POST",
credentials: "include",
headers: {
@ -51,6 +65,24 @@ export async function respondAuthFlow(
return parseFlowResponse(res)
}
/** Bridge embedded authentication to OIDC session (sets Authentik cookies + login URL). */
export async function completeAuthFlow(returnTo: string): Promise<FlowCompleteResponse> {
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

View File

@ -41,6 +41,9 @@ export const SIGNUP_PATH = "/signup"
/** In-app forgot-password page (Phase 2 custom UI via ultid BFF). */
export const FORGOT_PASSWORD_PATH = "/forgot-password"
/** In-app password reset after e-mail link (Phase 3, is_restored flow). */
export const RESET_PASSWORD_PATH = "/reset-password"
export function getSignupUrl(): string {
return SIGNUP_PATH
}

View File

@ -1,4 +1,4 @@
const AUTH_PUBLIC_PREFIXES = ["/login", "/signup", "/forgot-password", "/auth/", "/api/auth/"]
const AUTH_PUBLIC_PREFIXES = ["/login", "/signup", "/forgot-password", "/reset-password", "/auth/", "/api/auth/"]
/** Routes without session enforcement (login, public shares, interactive demos). */
export function isAuthPublicPath(pathname: string): boolean {

File diff suppressed because one or more lines are too long