ultisuite-client/components/auth/signup-credentials-fields.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

321 lines
8.5 KiB
TypeScript

"use client"
import { useEffect, useMemo, useState } from "react"
import { Loader2 } from "lucide-react"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { PasswordFieldBlock } from "@/components/auth/password-field-block"
import { checkMailAddressAvailability } from "@/lib/auth/check-mail-address"
import {
buildSignupEmail,
normalizeSignupLocalPart,
SIGNUP_MAIL_DOMAIN,
} from "@/lib/auth/signup-platform"
import { useDebouncedValue } from "@/lib/hooks/use-debounced-value"
import { cn } from "@/lib/utils"
type PromptField = {
field_key: string
label?: string
type?: string
required?: boolean
placeholder?: string
}
type SignupCredentialsFieldsProps = {
fields: PromptField[]
values: Record<string, string>
fieldErrors: Record<string, string>
onChange: (key: string, value: string) => void
onCanSubmitChange?: (canSubmit: boolean) => void
}
export function isSignupCredentialsStep(fields: PromptField[]): boolean {
const keys = new Set(fields.map((field) => field.field_key))
return keys.has("username") && keys.has("password") && keys.has("password_repeat")
}
export function SignupCredentialsFields({
fields,
values,
fieldErrors,
onChange,
onCanSubmitChange,
}: SignupCredentialsFieldsProps) {
const username = values.username ?? ""
const normalizedLocal = useMemo(() => normalizeSignupLocalPart(username), [username])
const previewEmail = buildSignupEmail(username)
const debouncedLocal = useDebouncedValue(normalizedLocal, 1000)
const [availability, setAvailability] = useState<
"idle" | "checking" | "available" | "taken" | "error"
>("idle")
useEffect(() => {
if (debouncedLocal.length < 2) {
setAvailability("idle")
return
}
let cancelled = false
setAvailability("checking")
void checkMailAddressAvailability(debouncedLocal, SIGNUP_MAIL_DOMAIN)
.then((result) => {
if (cancelled) return
setAvailability(result.available ? "available" : "taken")
})
.catch(() => {
if (cancelled) return
setAvailability("error")
})
return () => {
cancelled = true
}
}, [debouncedLocal])
const canSubmit =
normalizedLocal.length >= 2 &&
availability !== "taken" &&
availability !== "checking"
useEffect(() => {
onCanSubmitChange?.(canSubmit)
}, [canSubmit, onCanSubmitChange])
const otherFields = fields.filter(
(field) =>
field.type !== "hidden" &&
field.type !== "static" &&
!["username", "password", "password_repeat", "domain_hint"].includes(
field.field_key
)
)
return (
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="username">Adresse e-mail</Label>
<div className="relative flex items-center">
<Input
id="username"
name="username"
type="text"
inputMode="email"
autoComplete="username"
spellCheck={false}
autoCapitalize="none"
autoCorrect="off"
placeholder={
fields.find((field) => field.field_key === "username")?.placeholder ??
"prenom.nom"
}
value={username}
required
aria-invalid={
fieldErrors.username || availability === "taken" ? true : undefined
}
onChange={(event) =>
onChange("username", normalizeSignupLocalPart(event.target.value))
}
onKeyDown={(event) => {
if (event.key === "@") event.preventDefault()
}}
onPaste={(event) => {
const text = event.clipboardData.getData("text")
if (!text.includes("@")) return
event.preventDefault()
onChange("username", normalizeSignupLocalPart(text))
}}
className="h-10 rounded-lg pr-[9.5rem]"
/>
<span
className="pointer-events-none absolute inset-y-0 right-3 flex items-center text-sm text-muted-foreground"
aria-hidden
>
@{SIGNUP_MAIL_DOMAIN}
</span>
</div>
<input
type="email"
name="email"
autoComplete="email"
value={previewEmail}
readOnly
tabIndex={-1}
aria-hidden
className="sr-only"
/>
<p className="text-xs text-muted-foreground">
Votre adresse sera{" "}
<span className="font-medium text-foreground">
{previewEmail || `identifiant@${SIGNUP_MAIL_DOMAIN}`}
</span>
</p>
<AvailabilityHint
local={normalizedLocal}
debouncedLocal={debouncedLocal}
status={availability}
/>
{fieldErrors.username ? (
<p className="text-xs text-destructive" role="alert">
{fieldErrors.username}
</p>
) : null}
</div>
<PasswordFieldBlock
id="password"
label="Mot de passe"
value={values.password ?? ""}
error={fieldErrors.password}
required
autoComplete="new-password"
showStrength
onChange={(value) => onChange("password", value)}
/>
<PasswordFieldBlock
id="password_repeat"
label="Confirmer le mot de passe"
value={values.password_repeat ?? ""}
error={fieldErrors.password_repeat}
required
autoComplete="new-password"
compareWith={values.password ?? ""}
onChange={(value) => onChange("password_repeat", value)}
/>
{otherFields.map((field) => (
<GenericSignupField
key={field.field_key}
field={field}
value={values[field.field_key] ?? ""}
error={fieldErrors[field.field_key]}
onChange={(value) => onChange(field.field_key, value)}
/>
))}
</div>
)
}
function AvailabilityHint({
local,
debouncedLocal,
status,
}: {
local: string
debouncedLocal: string
status: "idle" | "checking" | "available" | "taken" | "error"
}) {
if (local.length === 0) return null
if (local.length < 2) {
return (
<p className="text-xs text-muted-foreground">
Au moins 2 caractères avant le @.
</p>
)
}
if (local !== debouncedLocal || status === "checking") {
return (
<p className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Loader2 className="size-3 animate-spin" aria-hidden />
Vérification de la disponibilité
</p>
)
}
if (status === "available") {
return (
<p className="text-xs text-emerald-600 dark:text-emerald-400" role="status">
Cette adresse est disponible.
</p>
)
}
if (status === "taken") {
return (
<p className="text-xs text-destructive" role="alert">
Cette adresse est déjà prise.
</p>
)
}
if (status === "error") {
return (
<p className="text-xs text-muted-foreground">
Impossible de vérifier la disponibilité pour le moment.
</p>
)
}
return null
}
function GenericSignupField({
field,
value,
error,
onChange,
}: {
field: PromptField
value: string
error?: string
onChange: (value: string) => void
}) {
if (field.type === "file") {
return (
<div 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("")
return
}
const reader = new FileReader()
reader.onload = () => onChange(String(reader.result ?? ""))
reader.readAsDataURL(file)
}}
/>
</div>
)
}
return (
<div className="space-y-2">
<Label htmlFor={field.field_key}>{field.label}</Label>
<Input
id={field.field_key}
type="text"
value={value}
required={field.required}
autoComplete={autoCompleteForField(field.field_key)}
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 autoCompleteForField(fieldKey: string): string | undefined {
if (fieldKey === "name") return "name"
if (fieldKey.includes("phone")) return "tel"
return undefined
}