feat: enhance authentication flow with new password management and signup components
Some checks are pending
E2E / Playwright e2e (push) Waiting to run
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.
This commit is contained in:
parent
be9133e220
commit
496b1dfc1f
@ -1056,6 +1056,7 @@ html[data-route-scope='drive'] body {
|
||||
border: 0;
|
||||
border-radius: 9999px;
|
||||
overflow: hidden;
|
||||
appearance: none;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
@ -1063,14 +1064,15 @@ html[data-route-scope='drive'] body {
|
||||
color: #fff !important;
|
||||
text-decoration: none !important;
|
||||
text-shadow: 0 1px 3px rgb(0 0 0 / 60%);
|
||||
background:
|
||||
background-color: #050505;
|
||||
background-image:
|
||||
radial-gradient(circle at 15% 25%, rgb(255 255 255 / 70%) 0, rgb(255 255 255 / 70%) 0.45px, transparent 1px),
|
||||
radial-gradient(circle at 62% 18%, rgb(255 255 255 / 55%) 0, rgb(255 255 255 / 55%) 0.4px, transparent 1px),
|
||||
radial-gradient(circle at 82% 68%, rgb(255 255 255 / 60%) 0, rgb(255 255 255 / 60%) 0.45px, transparent 1px),
|
||||
radial-gradient(circle at 35% 78%, rgb(255 255 255 / 45%) 0, rgb(255 255 255 / 45%) 0.35px, transparent 1px),
|
||||
radial-gradient(ellipse 70% 50% at 20% 0%, rgb(99 102 241 / 12%), transparent 55%),
|
||||
radial-gradient(ellipse 60% 45% at 90% 100%, rgb(34 211 238 / 8%), transparent 50%),
|
||||
linear-gradient(180deg, #050505 0%, #000 55%, #030303 100%) !important;
|
||||
linear-gradient(180deg, #050505 0%, #000 55%, #030303 100%);
|
||||
background-size: 100% 100% !important;
|
||||
background-repeat: no-repeat !important;
|
||||
box-shadow:
|
||||
@ -1082,6 +1084,12 @@ html[data-route-scope='drive'] body {
|
||||
letter-spacing 0.35s ease;
|
||||
}
|
||||
|
||||
.ultimail-login button.ultimail-login-connect-btn:disabled {
|
||||
cursor: not-allowed;
|
||||
filter: brightness(0.78);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.ultimail-login a.ultimail-login-connect-btn > *,
|
||||
.ultimail-login button.ultimail-login-connect-btn > * {
|
||||
position: relative;
|
||||
@ -1126,15 +1134,16 @@ html[data-route-scope='drive'] body {
|
||||
}
|
||||
|
||||
.dark .ultimail-login a.ultimail-login-connect-btn,
|
||||
.ultimail-login button.ultimail-login-connect-btn {
|
||||
background:
|
||||
.dark .ultimail-login button.ultimail-login-connect-btn {
|
||||
background-color: #000;
|
||||
background-image:
|
||||
radial-gradient(circle at 15% 25%, rgb(255 255 255 / 75%) 0, rgb(255 255 255 / 75%) 0.45px, transparent 1px),
|
||||
radial-gradient(circle at 62% 18%, rgb(255 255 255 / 60%) 0, rgb(255 255 255 / 60%) 0.4px, transparent 1px),
|
||||
radial-gradient(circle at 82% 68%, rgb(255 255 255 / 65%) 0, rgb(255 255 255 / 65%) 0.45px, transparent 1px),
|
||||
radial-gradient(circle at 35% 78%, rgb(255 255 255 / 50%) 0, rgb(255 255 255 / 50%) 0.35px, transparent 1px),
|
||||
radial-gradient(ellipse 70% 50% at 20% 0%, rgb(129 140 248 / 14%), transparent 55%),
|
||||
radial-gradient(ellipse 60% 45% at 90% 100%, rgb(34 211 238 / 10%), transparent 50%),
|
||||
linear-gradient(180deg, #000 0%, #000 55%, #020202 100%) !important;
|
||||
linear-gradient(180deg, #000 0%, #000 55%, #020202 100%);
|
||||
background-size: 100% 100% !important;
|
||||
background-repeat: no-repeat !important;
|
||||
}
|
||||
|
||||
@ -60,7 +60,7 @@ export function AuthConnectButton(props: AuthConnectButtonProps) {
|
||||
|
||||
return (
|
||||
<div className="ultimail-login-connect-action">
|
||||
<div className="ultimail-login-connect-border">{control}</div>
|
||||
<div className="ultimail-login-connect-border w-full">{control}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -147,6 +147,7 @@ export function AuthFlowPage({
|
||||
component={component}
|
||||
submitting={submitting}
|
||||
fieldErrors={validationErrors}
|
||||
flowSlug={slug}
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
)}
|
||||
|
||||
@ -3,6 +3,13 @@
|
||||
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"
|
||||
@ -22,6 +29,7 @@ type FlowChallengeFormProps = {
|
||||
component: string
|
||||
submitting: boolean
|
||||
fieldErrors?: Record<string, string>
|
||||
flowSlug?: string
|
||||
onSubmit: (payload: Record<string, unknown>) => void | Promise<void>
|
||||
}
|
||||
|
||||
@ -30,13 +38,25 @@ export function FlowChallengeForm({
|
||||
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
|
||||
@ -70,7 +90,11 @@ export function FlowChallengeForm({
|
||||
const key = field.field_key
|
||||
if (field.type === "static") continue
|
||||
if (field.type === "hidden") {
|
||||
payload[key] = field.initial_value ?? ""
|
||||
if (key === "email" && signupCredentials) {
|
||||
payload[key] = buildSignupEmail(values.username ?? "")
|
||||
} else {
|
||||
payload[key] = field.initial_value ?? ""
|
||||
}
|
||||
continue
|
||||
}
|
||||
payload[key] = values[key] ?? ""
|
||||
@ -96,16 +120,28 @@ export function FlowChallengeForm({
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="space-y-4" onSubmit={handleSubmit}>
|
||||
<form className="space-y-4" autoComplete="on" onSubmit={handleSubmit}>
|
||||
{component === "ak-stage-prompt" ? (
|
||||
<PromptFields
|
||||
fields={promptFields}
|
||||
values={values}
|
||||
fieldErrors={fieldErrors}
|
||||
onChange={(key, value) =>
|
||||
setValues((prev) => ({ ...prev, [key]: value }))
|
||||
}
|
||||
/>
|
||||
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" ? (
|
||||
@ -154,8 +190,7 @@ export function FlowChallengeForm({
|
||||
{isKnownComponent(component) ? (
|
||||
<AuthConnectButton
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
className="disabled:opacity-60"
|
||||
disabled={submitting || (signupCredentials && !signupCanSubmit)}
|
||||
>
|
||||
{submitting ? (
|
||||
<>
|
||||
@ -232,6 +267,29 @@ function PromptFields({
|
||||
)
|
||||
}
|
||||
|
||||
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}
|
||||
@ -282,15 +340,14 @@ function IdentificationFields({
|
||||
required
|
||||
/>
|
||||
{withPassword ? (
|
||||
<FieldBlock
|
||||
<PasswordFieldBlock
|
||||
id="password"
|
||||
label="Mot de passe"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={values.password ?? ""}
|
||||
error={fieldErrors.password}
|
||||
onChange={(value) => onChange("password", value)}
|
||||
required
|
||||
autoComplete="current-password"
|
||||
onChange={(value) => onChange("password", value)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
139
components/auth/password-field-block.tsx
Normal file
139
components/auth/password-field-block.tsx
Normal file
@ -0,0 +1,139 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Eye, EyeOff } from "lucide-react"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import {
|
||||
evaluatePasswordStrength,
|
||||
passwordsMatch,
|
||||
type PasswordStrengthLevel,
|
||||
} from "@/lib/auth/password-strength"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
type PasswordFieldBlockProps = {
|
||||
id: string
|
||||
label: string
|
||||
value: string
|
||||
error?: string
|
||||
required?: boolean
|
||||
autoComplete: "new-password" | "current-password"
|
||||
onChange: (value: string) => void
|
||||
/** Show strength meter (signup password). */
|
||||
showStrength?: boolean
|
||||
/** Compare with primary password (signup confirmation). */
|
||||
compareWith?: string
|
||||
}
|
||||
|
||||
const STRENGTH_SEGMENTS: PasswordStrengthLevel[] = [
|
||||
"weak",
|
||||
"fair",
|
||||
"good",
|
||||
"strong",
|
||||
]
|
||||
|
||||
const STRENGTH_ACTIVE: Record<Exclude<PasswordStrengthLevel, "empty">, string> = {
|
||||
weak: "bg-destructive",
|
||||
fair: "bg-amber-500",
|
||||
good: "bg-emerald-500",
|
||||
strong: "bg-emerald-600",
|
||||
}
|
||||
|
||||
export function PasswordFieldBlock({
|
||||
id,
|
||||
label,
|
||||
value,
|
||||
error,
|
||||
required,
|
||||
autoComplete,
|
||||
onChange,
|
||||
showStrength = false,
|
||||
compareWith,
|
||||
}: PasswordFieldBlockProps) {
|
||||
const [visible, setVisible] = useState(false)
|
||||
const strength = evaluatePasswordStrength(value)
|
||||
const match = compareWith !== undefined ? passwordsMatch(compareWith, value) : null
|
||||
|
||||
const activeSegments =
|
||||
strength.level === "empty"
|
||||
? 0
|
||||
: strength.level === "weak"
|
||||
? 1
|
||||
: strength.level === "fair"
|
||||
? 2
|
||||
: strength.level === "good"
|
||||
? 3
|
||||
: 4
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={id}>{label}</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id={id}
|
||||
type={visible ? "text" : "password"}
|
||||
value={value}
|
||||
required={required}
|
||||
autoComplete={autoComplete}
|
||||
aria-invalid={error ? true : undefined}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
className="h-10 rounded-lg pr-10"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute inset-y-0 right-0 flex w-10 items-center justify-center text-muted-foreground transition-colors hover:text-foreground"
|
||||
aria-label={visible ? "Masquer le mot de passe" : "Afficher le mot de passe"}
|
||||
onClick={() => setVisible((v) => !v)}
|
||||
>
|
||||
{visible ? (
|
||||
<EyeOff className="size-4" aria-hidden />
|
||||
) : (
|
||||
<Eye className="size-4" aria-hidden />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showStrength && value ? (
|
||||
<div className="space-y-1.5" aria-live="polite">
|
||||
<div className="flex gap-1">
|
||||
{STRENGTH_SEGMENTS.map((level, index) => (
|
||||
<span
|
||||
key={level}
|
||||
className={cn(
|
||||
"h-1 flex-1 rounded-full bg-muted transition-colors",
|
||||
index < activeSegments && strength.level !== "empty"
|
||||
? STRENGTH_ACTIVE[strength.level === "empty" ? "weak" : strength.level]
|
||||
: null
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Robustesse :{" "}
|
||||
<span className="font-medium text-foreground">{strength.label}</span>
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{match !== null ? (
|
||||
<p
|
||||
className={cn(
|
||||
"text-xs",
|
||||
match ? "text-emerald-600 dark:text-emerald-400" : "text-destructive"
|
||||
)}
|
||||
role="status"
|
||||
>
|
||||
{match
|
||||
? "Les mots de passe correspondent."
|
||||
: "Les mots de passe ne correspondent pas."}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{error ? (
|
||||
<p className="text-xs text-destructive" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
320
components/auth/signup-credentials-fields.tsx
Normal file
320
components/auth/signup-credentials-fields.tsx
Normal file
@ -0,0 +1,320 @@
|
||||
"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
|
||||
}
|
||||
22
lib/auth/check-mail-address.ts
Normal file
22
lib/auth/check-mail-address.ts
Normal file
@ -0,0 +1,22 @@
|
||||
export type MailAddressAvailability = {
|
||||
available: boolean
|
||||
reason?: string
|
||||
}
|
||||
|
||||
export async function checkMailAddressAvailability(
|
||||
local: string,
|
||||
domain: string
|
||||
): Promise<MailAddressAvailability> {
|
||||
const params = new URLSearchParams({
|
||||
local,
|
||||
domain,
|
||||
})
|
||||
const res = await fetch(`/api/v1/mail/addresses/check?${params.toString()}`, {
|
||||
credentials: "include",
|
||||
headers: { Accept: "application/json" },
|
||||
})
|
||||
if (!res.ok) {
|
||||
throw new Error(`address check failed (${res.status})`)
|
||||
}
|
||||
return (await res.json()) as MailAddressAvailability
|
||||
}
|
||||
37
lib/auth/password-strength.ts
Normal file
37
lib/auth/password-strength.ts
Normal file
@ -0,0 +1,37 @@
|
||||
export type PasswordStrengthLevel = "empty" | "weak" | "fair" | "good" | "strong"
|
||||
|
||||
export type PasswordStrength = {
|
||||
level: PasswordStrengthLevel
|
||||
score: number
|
||||
label: string
|
||||
}
|
||||
|
||||
export function evaluatePasswordStrength(password: string): PasswordStrength {
|
||||
if (!password) {
|
||||
return { level: "empty", score: 0, label: "" }
|
||||
}
|
||||
|
||||
let score = 0
|
||||
if (password.length >= 8) score += 1
|
||||
if (password.length >= 12) score += 1
|
||||
if (/[a-z]/.test(password) && /[A-Z]/.test(password)) score += 1
|
||||
if (/\d/.test(password)) score += 1
|
||||
if (/[^A-Za-z0-9]/.test(password)) score += 1
|
||||
|
||||
if (score <= 1) {
|
||||
return { level: "weak", score, label: "Faible" }
|
||||
}
|
||||
if (score === 2) {
|
||||
return { level: "fair", score, label: "Moyen" }
|
||||
}
|
||||
if (score === 3 || score === 4) {
|
||||
return { level: "good", score, label: "Bon" }
|
||||
}
|
||||
return { level: "strong", score, label: "Fort" }
|
||||
}
|
||||
|
||||
export function passwordsMatch(a: string, b: string): boolean | null {
|
||||
if (!a && !b) return null
|
||||
if (!b) return null
|
||||
return a === b
|
||||
}
|
||||
11
lib/auth/signup-platform.ts
Normal file
11
lib/auth/signup-platform.ts
Normal file
@ -0,0 +1,11 @@
|
||||
/** Platform mail domain for self-service signup (Authentik enrollment). */
|
||||
export const SIGNUP_MAIL_DOMAIN = "ultisuite.fr"
|
||||
|
||||
export function normalizeSignupLocalPart(raw: string): string {
|
||||
return raw.replace(/@+/g, "").trim().toLowerCase()
|
||||
}
|
||||
|
||||
export function buildSignupEmail(local: string): string {
|
||||
const normalized = normalizeSignupLocalPart(local)
|
||||
return normalized ? `${normalized}@${SIGNUP_MAIL_DOMAIN}` : ""
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user