Some checks are pending
E2E / Playwright e2e (push) Waiting to run
- 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.
321 lines
8.5 KiB
TypeScript
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
|
|
}
|