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: 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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -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") {
|
||||||
|
if (key === "email" && signupCredentials) {
|
||||||
|
payload[key] = buildSignupEmail(values.username ?? "")
|
||||||
|
} else {
|
||||||
payload[key] = field.initial_value ?? ""
|
payload[key] = field.initial_value ?? ""
|
||||||
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
payload[key] = values[key] ?? ""
|
payload[key] = values[key] ?? ""
|
||||||
@ -96,8 +120,19 @@ 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" ? (
|
||||||
|
signupCredentials ? (
|
||||||
|
<SignupCredentialsFields
|
||||||
|
fields={promptFields}
|
||||||
|
values={values}
|
||||||
|
fieldErrors={fieldErrors}
|
||||||
|
onCanSubmitChange={setSignupCanSubmit}
|
||||||
|
onChange={(key, value) =>
|
||||||
|
setValues((prev) => ({ ...prev, [key]: value }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
<PromptFields
|
<PromptFields
|
||||||
fields={promptFields}
|
fields={promptFields}
|
||||||
values={values}
|
values={values}
|
||||||
@ -106,6 +141,7 @@ export function FlowChallengeForm({
|
|||||||
setValues((prev) => ({ ...prev, [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>
|
||||||
|
|||||||
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