Some checks are pending
E2E / Playwright e2e (push) Waiting to run
Add authenticator-validate device picker, captcha/frame stages, WebAuthn utils fix, and recovery query parsing for /reset-password links.
506 lines
15 KiB
TypeScript
506 lines
15 KiB
TypeScript
"use client"
|
|
|
|
import { useEffect, useMemo, useRef, useState } from "react"
|
|
import { Loader2 } from "lucide-react"
|
|
import { AuthConnectButton } from "@/components/auth/auth-connect-button"
|
|
import { PasswordFieldBlock } from "@/components/auth/password-field-block"
|
|
import {
|
|
isSignupCredentialsStep,
|
|
SignupCredentialsFields,
|
|
} from "@/components/auth/signup-credentials-fields"
|
|
import { buildSignupEmail } from "@/lib/auth/signup-platform"
|
|
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"
|
|
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
|
|
label?: string
|
|
type?: string
|
|
required?: boolean
|
|
placeholder?: string
|
|
initial_value?: string
|
|
choices?: Array<{ label?: string; value?: string }>
|
|
}
|
|
|
|
type FlowChallengeFormProps = {
|
|
challenge: FlowChallenge | null
|
|
component: string
|
|
submitting: boolean
|
|
fieldErrors?: Record<string, string>
|
|
flowSlug?: string
|
|
onSubmit: (payload: Record<string, unknown>) => void | Promise<void>
|
|
}
|
|
|
|
export function FlowChallengeForm({
|
|
challenge,
|
|
component,
|
|
submitting,
|
|
fieldErrors = {},
|
|
flowSlug,
|
|
onSubmit,
|
|
}: FlowChallengeFormProps) {
|
|
const [values, setValues] = useState<Record<string, string>>({})
|
|
const [signupCanSubmit, setSignupCanSubmit] = useState(true)
|
|
const initializedComponentRef = useRef<string | null>(null)
|
|
|
|
const promptFields = useMemo(() => readPromptFields(challenge), [challenge])
|
|
const primaryAction = readPrimaryAction(challenge)
|
|
const signupCredentials =
|
|
flowSlug === AUTH_FLOW_SLUGS.enrollment &&
|
|
component === "ak-stage-prompt" &&
|
|
isSignupCredentialsStep(promptFields)
|
|
const RegistryStage = FLOW_STAGE_REGISTRY[component]
|
|
|
|
useEffect(() => {
|
|
if (!signupCredentials) {
|
|
setSignupCanSubmit(true)
|
|
}
|
|
}, [signupCredentials])
|
|
|
|
useEffect(() => {
|
|
if (initializedComponentRef.current === component) return
|
|
initializedComponentRef.current = component
|
|
|
|
const next: Record<string, string> = {}
|
|
if (component === "ak-stage-prompt") {
|
|
for (const field of promptFields) {
|
|
if (field.type === "hidden" || field.type === "static") continue
|
|
next[field.field_key] = field.initial_value ?? ""
|
|
}
|
|
}
|
|
if (component === "ak-stage-identification") {
|
|
next.uid_field = ""
|
|
if (readPasswordFields(challenge)) {
|
|
next.password = ""
|
|
}
|
|
}
|
|
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])
|
|
|
|
async function handleSubmit(event: React.FormEvent) {
|
|
event.preventDefault()
|
|
const payload: Record<string, unknown> = { component }
|
|
|
|
if (component === "ak-stage-prompt") {
|
|
for (const field of promptFields) {
|
|
const key = field.field_key
|
|
if (field.type === "static") continue
|
|
if (field.type === "hidden") {
|
|
if (key === "email" && signupCredentials) {
|
|
payload[key] = buildSignupEmail(values.username ?? "")
|
|
} else {
|
|
payload[key] = field.initial_value ?? ""
|
|
}
|
|
continue
|
|
}
|
|
payload[key] = values[key] ?? ""
|
|
}
|
|
} else if (component === "ak-stage-identification") {
|
|
payload.uid_field = values.uid_field ?? ""
|
|
if (readPasswordFields(challenge)) {
|
|
payload.password = values.password ?? ""
|
|
}
|
|
} else if (component === "ak-stage-email") {
|
|
payload.email = values.email ?? ""
|
|
} else if (component === "ak-stage-password") {
|
|
payload.password = values.password ?? ""
|
|
} else if (!RegistryStage) {
|
|
Object.assign(payload, values)
|
|
}
|
|
|
|
await onSubmit(payload)
|
|
}
|
|
|
|
if (!component) {
|
|
return null
|
|
}
|
|
|
|
const autoSubmitStage =
|
|
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") {
|
|
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" ? (
|
|
signupCredentials ? (
|
|
<SignupCredentialsFields
|
|
fields={promptFields}
|
|
values={values}
|
|
fieldErrors={fieldErrors}
|
|
onCanSubmitChange={setSignupCanSubmit}
|
|
onChange={(key, value) =>
|
|
setValues((prev) => ({ ...prev, [key]: value }))
|
|
}
|
|
/>
|
|
) : (
|
|
<PromptFields
|
|
fields={promptFields}
|
|
values={values}
|
|
fieldErrors={fieldErrors}
|
|
onChange={(key, value) =>
|
|
setValues((prev) => ({ ...prev, [key]: value }))
|
|
}
|
|
/>
|
|
)
|
|
) : null}
|
|
|
|
{component === "ak-stage-identification" ? (
|
|
<IdentificationFields
|
|
challenge={challenge}
|
|
values={values}
|
|
fieldErrors={fieldErrors}
|
|
onChange={(key, value) =>
|
|
setValues((prev) => ({ ...prev, [key]: value }))
|
|
}
|
|
/>
|
|
) : null}
|
|
|
|
{component === "ak-stage-email" ? (
|
|
<FieldBlock
|
|
id="email"
|
|
label="Adresse e-mail"
|
|
type="email"
|
|
autoComplete="email"
|
|
value={values.email ?? ""}
|
|
error={fieldErrors.email}
|
|
onChange={(value) => setValues((prev) => ({ ...prev, email: value }))}
|
|
required
|
|
/>
|
|
) : null}
|
|
|
|
{!isKnownFlowComponent(component) ? (
|
|
<p className="text-sm text-muted-foreground">
|
|
Étape non prise en charge ({component}). Utilisez le portail Authentik.
|
|
</p>
|
|
) : null}
|
|
|
|
{isKnownFlowComponent(component) && !autoSubmitStage ? (
|
|
<AuthConnectButton
|
|
type="submit"
|
|
disabled={submitting || (signupCredentials && !signupCanSubmit)}
|
|
>
|
|
{submitting ? (
|
|
<>
|
|
<Loader2 className="size-4 animate-spin" aria-hidden />
|
|
<span>Patientez…</span>
|
|
</>
|
|
) : (
|
|
primaryAction
|
|
)}
|
|
</AuthConnectButton>
|
|
) : null}
|
|
</form>
|
|
)
|
|
}
|
|
|
|
function PromptFields({
|
|
fields,
|
|
values,
|
|
fieldErrors,
|
|
onChange,
|
|
}: {
|
|
fields: PromptField[]
|
|
values: Record<string, string>
|
|
fieldErrors: Record<string, string>
|
|
onChange: (key: string, value: string) => void
|
|
}) {
|
|
return (
|
|
<div className="space-y-4">
|
|
{fields.map((field) => {
|
|
if (field.type === "hidden") return null
|
|
if (field.type === "static") {
|
|
return (
|
|
<p key={field.field_key} className="text-sm text-muted-foreground">
|
|
{field.label}
|
|
{field.initial_value ? (
|
|
<span className="font-medium text-foreground">
|
|
{" "}
|
|
{field.initial_value}
|
|
</span>
|
|
) : null}
|
|
</p>
|
|
)
|
|
}
|
|
|
|
const inputType =
|
|
field.type === "password"
|
|
? "password"
|
|
: field.type === "email"
|
|
? "email"
|
|
: "text"
|
|
|
|
if (field.type === "file") {
|
|
return (
|
|
<div key={field.field_key} className="space-y-2">
|
|
<Label htmlFor={field.field_key}>{field.label}</Label>
|
|
<Input
|
|
id={field.field_key}
|
|
type="file"
|
|
accept="image/*"
|
|
onChange={(event) => {
|
|
const file = event.target.files?.[0]
|
|
if (!file) {
|
|
onChange(field.field_key, "")
|
|
return
|
|
}
|
|
const reader = new FileReader()
|
|
reader.onload = () => {
|
|
onChange(field.field_key, String(reader.result ?? ""))
|
|
}
|
|
reader.readAsDataURL(file)
|
|
}}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (field.type === "password") {
|
|
return (
|
|
<PasswordFieldBlock
|
|
key={field.field_key}
|
|
id={field.field_key}
|
|
label={field.label ?? field.field_key}
|
|
value={values[field.field_key] ?? ""}
|
|
error={fieldErrors[field.field_key]}
|
|
required={field.required}
|
|
autoComplete={
|
|
field.field_key.includes("repeat") ? "new-password" : "new-password"
|
|
}
|
|
showStrength={field.field_key === "password"}
|
|
compareWith={
|
|
field.field_key === "password_repeat"
|
|
? (values.password ?? "")
|
|
: undefined
|
|
}
|
|
onChange={(value) => onChange(field.field_key, value)}
|
|
/>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<FieldBlock
|
|
key={field.field_key}
|
|
id={field.field_key}
|
|
label={field.label ?? field.field_key}
|
|
type={inputType}
|
|
placeholder={field.placeholder}
|
|
value={values[field.field_key] ?? ""}
|
|
error={fieldErrors[field.field_key]}
|
|
onChange={(value) => onChange(field.field_key, value)}
|
|
required={field.required}
|
|
autoComplete={autoCompleteFor(field.field_key, field.type)}
|
|
/>
|
|
)
|
|
})}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function IdentificationFields({
|
|
challenge,
|
|
values,
|
|
fieldErrors,
|
|
onChange,
|
|
}: {
|
|
challenge: FlowChallenge | null
|
|
values: Record<string, string>
|
|
fieldErrors: Record<string, string>
|
|
onChange: (key: string, value: string) => void
|
|
}) {
|
|
const withPassword = readPasswordFields(challenge)
|
|
const userFields = readUserFields(challenge)
|
|
const label =
|
|
userFields.includes("email") && !userFields.includes("username")
|
|
? "Adresse e-mail"
|
|
: "Identifiant"
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<FieldBlock
|
|
id="uid_field"
|
|
label={label}
|
|
type={userFields.includes("email") ? "email" : "text"}
|
|
autoComplete="username"
|
|
value={values.uid_field ?? ""}
|
|
error={fieldErrors.uid_field}
|
|
onChange={(value) => onChange("uid_field", value)}
|
|
required
|
|
/>
|
|
{withPassword ? (
|
|
<PasswordFieldBlock
|
|
id="password"
|
|
label="Mot de passe"
|
|
value={values.password ?? ""}
|
|
error={fieldErrors.password}
|
|
required
|
|
autoComplete="current-password"
|
|
onChange={(value) => onChange("password", value)}
|
|
/>
|
|
) : null}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function FieldBlock({
|
|
id,
|
|
label,
|
|
type = "text",
|
|
placeholder,
|
|
value,
|
|
error,
|
|
onChange,
|
|
required,
|
|
autoComplete,
|
|
inputMode,
|
|
}: {
|
|
id: string
|
|
label: string
|
|
type?: string
|
|
placeholder?: string
|
|
value: string
|
|
error?: string
|
|
onChange: (value: string) => void
|
|
required?: boolean
|
|
autoComplete?: string
|
|
inputMode?: React.HTMLAttributes<HTMLInputElement>["inputMode"]
|
|
}) {
|
|
return (
|
|
<div className="space-y-2">
|
|
<Label htmlFor={id}>{label}</Label>
|
|
<Input
|
|
id={id}
|
|
type={type}
|
|
placeholder={placeholder}
|
|
value={value}
|
|
required={required}
|
|
autoComplete={autoComplete}
|
|
inputMode={inputMode}
|
|
aria-invalid={error ? true : undefined}
|
|
onChange={(event) => onChange(event.target.value)}
|
|
className="h-10 rounded-lg"
|
|
/>
|
|
{error ? (
|
|
<p className="text-xs text-destructive" role="alert">
|
|
{error}
|
|
</p>
|
|
) : null}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function readPromptFields(challenge: FlowChallenge | null): PromptField[] {
|
|
const raw = challenge?.fields
|
|
if (!Array.isArray(raw)) return []
|
|
return raw.filter(isPromptField)
|
|
}
|
|
|
|
function isPromptField(value: unknown): value is PromptField {
|
|
return (
|
|
typeof value === "object" &&
|
|
value !== null &&
|
|
typeof (value as PromptField).field_key === "string"
|
|
)
|
|
}
|
|
function readPasswordFields(challenge: FlowChallenge | null): boolean {
|
|
return challenge?.password_fields === true
|
|
}
|
|
|
|
function readUserFields(challenge: FlowChallenge | null): string[] {
|
|
const raw = challenge?.user_fields
|
|
if (!Array.isArray(raw)) return ["username"]
|
|
return raw.filter((v): v is string => typeof v === "string")
|
|
}
|
|
|
|
function autoCompleteFor(fieldKey: string, type?: string): string | undefined {
|
|
if (type === "password") {
|
|
return fieldKey.includes("repeat") ? "new-password" : "new-password"
|
|
}
|
|
if (fieldKey === "username") return "username"
|
|
if (fieldKey === "email" || fieldKey.includes("email")) return "email"
|
|
if (fieldKey === "name") return "name"
|
|
if (fieldKey.includes("phone")) return "tel"
|
|
return undefined
|
|
}
|
|
|