Some checks are pending
E2E / Playwright e2e (push) Waiting to run
- Added AuthConnectButton component to centralize and simplify authentication button rendering across various forms. - Updated existing authentication components to utilize AuthConnectButton, enhancing code reusability and maintainability. - Refactored CSS styles for consistent button appearance and layout in authentication flows.
395 lines
11 KiB
TypeScript
395 lines
11 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 { Input } from "@/components/ui/input"
|
|
import { Label } from "@/components/ui/label"
|
|
import type { FlowChallenge } from "@/lib/auth/flow-api"
|
|
|
|
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>
|
|
onSubmit: (payload: Record<string, unknown>) => void | Promise<void>
|
|
}
|
|
|
|
export function FlowChallengeForm({
|
|
challenge,
|
|
component,
|
|
submitting,
|
|
fieldErrors = {},
|
|
onSubmit,
|
|
}: FlowChallengeFormProps) {
|
|
const [values, setValues] = useState<Record<string, string>>({})
|
|
const initializedComponentRef = useRef<string | null>(null)
|
|
|
|
const promptFields = useMemo(() => readPromptFields(challenge), [challenge])
|
|
const primaryAction = readPrimaryAction(challenge)
|
|
|
|
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 = ""
|
|
}
|
|
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") {
|
|
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-authenticator-validate") {
|
|
payload.code = values.code ?? ""
|
|
} else {
|
|
Object.assign(payload, values)
|
|
}
|
|
|
|
await onSubmit(payload)
|
|
}
|
|
|
|
if (!component || component === "ak-stage-access-denied") {
|
|
return null
|
|
}
|
|
|
|
return (
|
|
<form className="space-y-4" onSubmit={handleSubmit}>
|
|
{component === "ak-stage-prompt" ? (
|
|
<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}
|
|
|
|
{component === "ak-stage-authenticator-validate" ? (
|
|
<FieldBlock
|
|
id="code"
|
|
label="Code de validation"
|
|
type="text"
|
|
inputMode="numeric"
|
|
autoComplete="one-time-code"
|
|
value={values.code ?? ""}
|
|
onChange={(value) => setValues((prev) => ({ ...prev, code: value }))}
|
|
required
|
|
/>
|
|
) : null}
|
|
|
|
{!isKnownComponent(component) ? (
|
|
<p className="text-sm text-muted-foreground">
|
|
Étape non prise en charge ({component}). Utilisez le portail Authentik.
|
|
</p>
|
|
) : null}
|
|
|
|
{isKnownComponent(component) ? (
|
|
<AuthConnectButton
|
|
type="submit"
|
|
disabled={submitting}
|
|
className="disabled:opacity-60"
|
|
>
|
|
{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>
|
|
)
|
|
}
|
|
|
|
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 ? (
|
|
<FieldBlock
|
|
id="password"
|
|
label="Mot de passe"
|
|
type="password"
|
|
autoComplete="current-password"
|
|
value={values.password ?? ""}
|
|
error={fieldErrors.password}
|
|
onChange={(value) => onChange("password", value)}
|
|
required
|
|
/>
|
|
) : 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 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
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
function isKnownComponent(component: string): boolean {
|
|
return [
|
|
"ak-stage-prompt",
|
|
"ak-stage-identification",
|
|
"ak-stage-email",
|
|
"ak-stage-authenticator-validate",
|
|
].includes(component)
|
|
}
|