diff --git a/app/reset-password/page.tsx b/app/reset-password/page.tsx index 8d51017..d5fa260 100644 --- a/app/reset-password/page.tsx +++ b/app/reset-password/page.tsx @@ -3,12 +3,13 @@ import { useSearchParams } from "next/navigation" import { Suspense } from "react" import { ResetPasswordPageContent } from "@/components/auth/reset-password-page-content" +import { buildRecoveryFlowQuery } from "@/lib/auth/recovery-flow-query" function ResetPasswordContent() { const searchParams = useSearchParams() - const flowQuery = searchParams.toString() + const flowQuery = buildRecoveryFlowQuery(searchParams) - return + return } export default function ResetPasswordPage() { diff --git a/components/auth/flow-challenge-form.tsx b/components/auth/flow-challenge-form.tsx index 585601e..c65c14e 100644 --- a/components/auth/flow-challenge-form.tsx +++ b/components/auth/flow-challenge-form.tsx @@ -26,13 +26,19 @@ import { AccessDeniedStage, FlowRedirectStage, } from "@/components/auth/flow-stages/access-denied-stage" +import { AuthenticatorValidateStage } from "@/components/auth/flow-stages/authenticator-validate-stage" +import { CaptchaStage } from "@/components/auth/flow-stages/captcha-stage" +import { FlowFrameStage } from "@/components/auth/flow-stages/flow-frame-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-authenticator-validate"] = AuthenticatorValidateStage +FLOW_STAGE_REGISTRY["ak-stage-captcha"] = CaptchaStage FLOW_STAGE_REGISTRY["ak-stage-source"] = SourceOAuthStage FLOW_STAGE_REGISTRY["ak-stage-access-denied"] = AccessDeniedStage FLOW_STAGE_REGISTRY["xak-flow-redirect"] = FlowRedirectStage +FLOW_STAGE_REGISTRY["xak-flow-frame"] = FlowFrameStage type PromptField = { field_key: string @@ -133,8 +139,6 @@ export function FlowChallengeForm({ } } else if (component === "ak-stage-email") { payload.email = values.email ?? "" - } else if (component === "ak-stage-authenticator-validate") { - payload.code = values.code ?? "" } else if (component === "ak-stage-password") { payload.password = values.password ?? "" } else if (!RegistryStage) { @@ -152,6 +156,7 @@ export function FlowChallengeForm({ component === "ak-stage-user-login" || component === "ak-stage-authenticator-webauthn" || component === "xak-flow-redirect" || + component === "xak-flow-frame" || (component === "ak-stage-source" && Boolean(flowRedirectUrl(challenge))) if (component === "ak-stage-access-denied") { @@ -244,19 +249,6 @@ export function FlowChallengeForm({ /> ) : null} - {component === "ak-stage-authenticator-validate" ? ( - setValues((prev) => ({ ...prev, code: value }))} - required - /> - ) : null} - {!isKnownFlowComponent(component) ? (

Étape non prise en charge ({component}). Utilisez le portail Authentik. diff --git a/components/auth/flow-stage-registry.ts b/components/auth/flow-stage-registry.ts index ee8190f..365a3d7 100644 --- a/components/auth/flow-stage-registry.ts +++ b/components/auth/flow-stage-registry.ts @@ -31,7 +31,9 @@ const LEGACY_KNOWN_COMPONENTS = new Set([ "ak-stage-user-login", "ak-stage-authenticator-webauthn", "ak-stage-source", + "ak-stage-captcha", "xak-flow-redirect", + "xak-flow-frame", "ak-stage-access-denied", ]) diff --git a/components/auth/flow-stages/authenticator-validate-stage.tsx b/components/auth/flow-stages/authenticator-validate-stage.tsx new file mode 100644 index 0000000..04ac8cd --- /dev/null +++ b/components/auth/flow-stages/authenticator-validate-stage.tsx @@ -0,0 +1,222 @@ +"use client" + +import { useEffect, useRef, useState } from "react" +import { Loader2 } from "lucide-react" +import { AuthConnectButton } from "@/components/auth/auth-connect-button" +import type { StageRendererProps } from "@/components/auth/flow-stage-registry" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { + normalizeRequestOptions, + readWebAuthnRequestOptions, + serializeWebAuthnCredential, +} from "@/lib/auth/webauthn-utils" + +type DeviceChallenge = { + device_class?: string + device_uid?: string + challenge?: Record + title?: string +} + +type ConfigStage = { + pk?: string + name?: string + verbose_name?: string +} + +function readDeviceChallenges(challenge: StageRendererProps["challenge"]): DeviceChallenge[] { + const raw = challenge?.device_challenges ?? challenge?.deviceChallenges + if (!Array.isArray(raw)) return [] + return raw.filter((item): item is DeviceChallenge => typeof item === "object" && item !== null) +} + +function readConfigurationStages(challenge: StageRendererProps["challenge"]): ConfigStage[] { + const raw = challenge?.configuration_stages ?? challenge?.configurationStages + if (!Array.isArray(raw)) return [] + return raw.filter((item): item is ConfigStage => typeof item === "object" && item !== null) +} + +function isWebAuthnDevice(device: DeviceChallenge): boolean { + return device.device_class === "webauthn" || device.device_class === "apple" +} + +function deviceLabel(device: DeviceChallenge): string { + if (device.title?.trim()) return device.title + switch (device.device_class) { + case "webauthn": + return "Clé de sécurité" + case "totp": + return "Application TOTP" + case "static": + return "Codes de secours" + case "sms": + return "SMS" + case "email": + return "E-mail" + default: + return device.device_class ?? "Authentificateur" + } +} + +export function AuthenticatorValidateStage({ + challenge, + submitting, + onSubmit, +}: StageRendererProps) { + const [selectedDevice, setSelectedDevice] = useState(null) + const [code, setCode] = useState("") + const webAuthnStartedRef = useRef(false) + + const devices = readDeviceChallenges(challenge) + const configStages = readConfigurationStages(challenge) + const activeDevice = selectedDevice ?? (devices.length === 1 ? devices[0]! : null) + + useEffect(() => { + setSelectedDevice(null) + setCode("") + webAuthnStartedRef.current = false + }, [challenge]) + + useEffect(() => { + if (!activeDevice || !isWebAuthnDevice(activeDevice) || submitting) return + if (webAuthnStartedRef.current) return + if (typeof window === "undefined" || !window.PublicKeyCredential) return + + const options = + readWebAuthnRequestOptions(activeDevice.challenge) ?? + readWebAuthnRequestOptions(challenge) + if (!options) return + + webAuthnStartedRef.current = true + void (async () => { + try { + const credential = (await navigator.credentials.get({ + publicKey: normalizeRequestOptions(options), + })) as PublicKeyCredential | null + if (!credential) { + webAuthnStartedRef.current = false + return + } + await onSubmit({ + component: "ak-stage-authenticator-validate", + selected_challenge: activeDevice, + webauthn: serializeWebAuthnCredential(credential), + }) + } catch { + webAuthnStartedRef.current = false + } + })() + }, [activeDevice, challenge, onSubmit, submitting]) + + if (configStages.length > 0 && devices.length === 0) { + return ( +

+

+ Configurez une méthode d'authentification forte pour continuer. +

+ {configStages.map((stage) => ( + + void onSubmit({ + component: "ak-stage-authenticator-validate", + selected_stage: stage.pk, + }) + } + > + {stage.verbose_name ?? stage.name ?? "Configurer"} + + ))} +
+ ) + } + + if (devices.length > 1 && !selectedDevice) { + return ( +
+

Choisissez une méthode de vérification.

+ {devices.map((device, index) => ( + setSelectedDevice(device)} + > + {deviceLabel(device)} + + ))} +
+ ) + } + + if (activeDevice && isWebAuthnDevice(activeDevice)) { + return ( +
+ +

Suivez l'invite de votre clé de sécurité…

+ {devices.length > 1 ? ( + + ) : null} +
+ ) + } + + return ( +
{ + event.preventDefault() + void onSubmit({ + component: "ak-stage-authenticator-validate", + ...(activeDevice ? { selected_challenge: activeDevice } : {}), + code, + }) + }} + > +
+ + setCode(event.target.value)} + className="h-10 rounded-lg" + /> +
+ {devices.length > 1 ? ( + + ) : null} + + {submitting ? ( + <> + + Patientez… + + ) : ( + "Valider" + )} + +
+ ) +} diff --git a/components/auth/flow-stages/captcha-stage.tsx b/components/auth/flow-stages/captcha-stage.tsx new file mode 100644 index 0000000..7a729d8 --- /dev/null +++ b/components/auth/flow-stages/captcha-stage.tsx @@ -0,0 +1,64 @@ +"use client" + +import { Loader2 } from "lucide-react" +import { AuthConnectButton } from "@/components/auth/auth-connect-button" +import type { StageRendererProps } from "@/components/auth/flow-stage-registry" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" + +function readSiteKey(challenge: StageRendererProps["challenge"]): string { + const key = challenge?.site_key ?? challenge?.siteKey + return typeof key === "string" ? key : "" +} + +/** Captcha stage — token field fallback for embedded UI without provider widget. */ +export function CaptchaStage({ + challenge, + submitting, + values, + onChange, + onSubmit, +}: StageRendererProps) { + const siteKey = readSiteKey(challenge) + const token = values.captcha_token ?? values.token ?? "" + + return ( +
{ + event.preventDefault() + void onSubmit({ + component: "ak-stage-captcha", + captcha_token: token, + token, + }) + }} + > + {siteKey ? ( +

+ Captcha requis. Si le widget ne s'affiche pas ici, utilisez le lien de connexion + redirect sur la page de connexion. +

+ ) : null} +
+ + onChange("captcha_token", event.target.value)} + className="h-10 rounded-lg" + /> +
+ + {submitting ? ( + <> + + Patientez… + + ) : ( + "Continuer" + )} + +
+ ) +} diff --git a/components/auth/flow-stages/flow-frame-stage.tsx b/components/auth/flow-stages/flow-frame-stage.tsx new file mode 100644 index 0000000..c1e5e32 --- /dev/null +++ b/components/auth/flow-stages/flow-frame-stage.tsx @@ -0,0 +1,56 @@ +"use client" + +import { useEffect } from "react" +import type { StageRendererProps } from "@/components/auth/flow-stage-registry" +import { flowRedirectUrl } from "@/lib/auth/flow-api" + +function readFrameUrl(challenge: StageRendererProps["challenge"]): string { + const direct = flowRedirectUrl(challenge) + if (direct) return direct + const url = challenge?.url + return typeof url === "string" ? url : "" +} + +type AuthentikFrameMessage = { + source?: string + context?: string + message?: string +} + +/** Embedded Authentik sub-flow (captcha iframe, etc.). */ +export function FlowFrameStage({ challenge, onSubmit }: StageRendererProps) { + const frameUrl = readFrameUrl(challenge) + + useEffect(() => { + function handleMessage(event: MessageEvent) { + const data = event.data + if ( + data?.source === "goauthentik.io" && + data?.context === "flow-executor" && + data?.message === "submit" + ) { + void onSubmit({ component: "xak-flow-frame" }) + } + } + + window.addEventListener("message", handleMessage) + return () => window.removeEventListener("message", handleMessage) + }, [onSubmit]) + + if (!frameUrl) { + return ( +

+ Étape de vérification en cours… +

+ ) + } + + return ( +