feat: enhance authentication flow with new password management and signup components
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:
R3D347HR4Y 2026-06-19 23:47:16 +02:00
parent be9133e220
commit 496b1dfc1f
9 changed files with 618 additions and 22 deletions

View File

@ -1056,6 +1056,7 @@ html[data-route-scope='drive'] body {
border: 0; border: 0;
border-radius: 9999px; border-radius: 9999px;
overflow: hidden; overflow: hidden;
appearance: none;
font-size: 0.875rem; font-size: 0.875rem;
font-weight: 600; font-weight: 600;
line-height: 1; line-height: 1;
@ -1063,14 +1064,15 @@ html[data-route-scope='drive'] body {
color: #fff !important; color: #fff !important;
text-decoration: none !important; text-decoration: none !important;
text-shadow: 0 1px 3px rgb(0 0 0 / 60%); 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 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 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 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(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 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%), 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-size: 100% 100% !important;
background-repeat: no-repeat !important; background-repeat: no-repeat !important;
box-shadow: box-shadow:
@ -1082,6 +1084,12 @@ html[data-route-scope='drive'] body {
letter-spacing 0.35s ease; 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 a.ultimail-login-connect-btn > *,
.ultimail-login button.ultimail-login-connect-btn > * { .ultimail-login button.ultimail-login-connect-btn > * {
position: relative; position: relative;
@ -1126,15 +1134,16 @@ html[data-route-scope='drive'] body {
} }
.dark .ultimail-login a.ultimail-login-connect-btn, .dark .ultimail-login a.ultimail-login-connect-btn,
.ultimail-login button.ultimail-login-connect-btn { .dark .ultimail-login button.ultimail-login-connect-btn {
background: 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 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 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 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(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 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%), 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-size: 100% 100% !important;
background-repeat: no-repeat !important; background-repeat: no-repeat !important;
} }

View File

@ -60,7 +60,7 @@ export function AuthConnectButton(props: AuthConnectButtonProps) {
return ( return (
<div className="ultimail-login-connect-action"> <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> </div>
) )
} }

View File

@ -147,6 +147,7 @@ export function AuthFlowPage({
component={component} component={component}
submitting={submitting} submitting={submitting}
fieldErrors={validationErrors} fieldErrors={validationErrors}
flowSlug={slug}
onSubmit={handleSubmit} onSubmit={handleSubmit}
/> />
)} )}

View File

@ -3,6 +3,13 @@
import { useEffect, useMemo, useRef, useState } from "react" import { useEffect, useMemo, useRef, useState } from "react"
import { Loader2 } from "lucide-react" import { Loader2 } from "lucide-react"
import { AuthConnectButton } from "@/components/auth/auth-connect-button" 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 { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label"
import type { FlowChallenge } from "@/lib/auth/flow-api" import type { FlowChallenge } from "@/lib/auth/flow-api"
@ -22,6 +29,7 @@ type FlowChallengeFormProps = {
component: string component: string
submitting: boolean submitting: boolean
fieldErrors?: Record<string, string> fieldErrors?: Record<string, string>
flowSlug?: string
onSubmit: (payload: Record<string, unknown>) => void | Promise<void> onSubmit: (payload: Record<string, unknown>) => void | Promise<void>
} }
@ -30,13 +38,25 @@ export function FlowChallengeForm({
component, component,
submitting, submitting,
fieldErrors = {}, fieldErrors = {},
flowSlug,
onSubmit, onSubmit,
}: FlowChallengeFormProps) { }: FlowChallengeFormProps) {
const [values, setValues] = useState<Record<string, string>>({}) const [values, setValues] = useState<Record<string, string>>({})
const [signupCanSubmit, setSignupCanSubmit] = useState(true)
const initializedComponentRef = useRef<string | null>(null) const initializedComponentRef = useRef<string | null>(null)
const promptFields = useMemo(() => readPromptFields(challenge), [challenge]) const promptFields = useMemo(() => readPromptFields(challenge), [challenge])
const primaryAction = readPrimaryAction(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(() => { useEffect(() => {
if (initializedComponentRef.current === component) return if (initializedComponentRef.current === component) return
@ -70,7 +90,11 @@ export function FlowChallengeForm({
const key = field.field_key const key = field.field_key
if (field.type === "static") continue if (field.type === "static") continue
if (field.type === "hidden") { 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 continue
} }
payload[key] = values[key] ?? "" payload[key] = values[key] ?? ""
@ -96,16 +120,28 @@ export function FlowChallengeForm({
} }
return ( return (
<form className="space-y-4" onSubmit={handleSubmit}> <form className="space-y-4" autoComplete="on" onSubmit={handleSubmit}>
{component === "ak-stage-prompt" ? ( {component === "ak-stage-prompt" ? (
<PromptFields signupCredentials ? (
fields={promptFields} <SignupCredentialsFields
values={values} fields={promptFields}
fieldErrors={fieldErrors} values={values}
onChange={(key, value) => fieldErrors={fieldErrors}
setValues((prev) => ({ ...prev, [key]: value })) 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} ) : null}
{component === "ak-stage-identification" ? ( {component === "ak-stage-identification" ? (
@ -154,8 +190,7 @@ export function FlowChallengeForm({
{isKnownComponent(component) ? ( {isKnownComponent(component) ? (
<AuthConnectButton <AuthConnectButton
type="submit" type="submit"
disabled={submitting} disabled={submitting || (signupCredentials && !signupCanSubmit)}
className="disabled:opacity-60"
> >
{submitting ? ( {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 ( return (
<FieldBlock <FieldBlock
key={field.field_key} key={field.field_key}
@ -282,15 +340,14 @@ function IdentificationFields({
required required
/> />
{withPassword ? ( {withPassword ? (
<FieldBlock <PasswordFieldBlock
id="password" id="password"
label="Mot de passe" label="Mot de passe"
type="password"
autoComplete="current-password"
value={values.password ?? ""} value={values.password ?? ""}
error={fieldErrors.password} error={fieldErrors.password}
onChange={(value) => onChange("password", value)}
required required
autoComplete="current-password"
onChange={(value) => onChange("password", value)}
/> />
) : null} ) : null}
</div> </div>

View 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>
)
}

View 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
}

View 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
}

View 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
}

View 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}` : ""
}