ultisuite-client/components/auth/flow-challenge-form.tsx
R3D347HR4Y 496b1dfc1f
Some checks are pending
E2E / Playwright e2e (push) Waiting to run
feat: enhance authentication flow with new password management and signup components
- Introduced PasswordFieldBlock component for improved password input handling, including visibility toggle and strength evaluation.
- Added SignupCredentialsFields component to streamline user signup with email availability checks and password confirmation.
- Updated FlowChallengeForm to integrate new components, enhancing user experience during authentication.
- Refactored CSS styles for consistent design across authentication components, ensuring a cohesive look and feel.
2026-06-19 23:47:16 +02:00

452 lines
13 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"
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)
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 = ""
}
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-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" 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}
{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 || (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 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)
}