From 496b1dfc1f286bfd02cd33a8f260fa3ef4572db0 Mon Sep 17 00:00:00 2001 From: R3D347HR4Y Date: Fri, 19 Jun 2026 23:47:16 +0200 Subject: [PATCH] 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. --- app/globals.css | 19 +- components/auth/auth-connect-button.tsx | 2 +- components/auth/auth-flow-page.tsx | 1 + components/auth/flow-challenge-form.tsx | 89 ++++- components/auth/password-field-block.tsx | 139 ++++++++ components/auth/signup-credentials-fields.tsx | 320 ++++++++++++++++++ lib/auth/check-mail-address.ts | 22 ++ lib/auth/password-strength.ts | 37 ++ lib/auth/signup-platform.ts | 11 + 9 files changed, 618 insertions(+), 22 deletions(-) create mode 100644 components/auth/password-field-block.tsx create mode 100644 components/auth/signup-credentials-fields.tsx create mode 100644 lib/auth/check-mail-address.ts create mode 100644 lib/auth/password-strength.ts create mode 100644 lib/auth/signup-platform.ts diff --git a/app/globals.css b/app/globals.css index 95296a6..b51695b 100644 --- a/app/globals.css +++ b/app/globals.css @@ -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; } diff --git a/components/auth/auth-connect-button.tsx b/components/auth/auth-connect-button.tsx index 2370cc3..7bc4209 100644 --- a/components/auth/auth-connect-button.tsx +++ b/components/auth/auth-connect-button.tsx @@ -60,7 +60,7 @@ export function AuthConnectButton(props: AuthConnectButtonProps) { return (
-
{control}
+
{control}
) } diff --git a/components/auth/auth-flow-page.tsx b/components/auth/auth-flow-page.tsx index de337b7..a3feafc 100644 --- a/components/auth/auth-flow-page.tsx +++ b/components/auth/auth-flow-page.tsx @@ -147,6 +147,7 @@ export function AuthFlowPage({ component={component} submitting={submitting} fieldErrors={validationErrors} + flowSlug={slug} onSubmit={handleSubmit} /> )} diff --git a/components/auth/flow-challenge-form.tsx b/components/auth/flow-challenge-form.tsx index 1db4012..2b8905f 100644 --- a/components/auth/flow-challenge-form.tsx +++ b/components/auth/flow-challenge-form.tsx @@ -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 + flowSlug?: string onSubmit: (payload: Record) => void | Promise } @@ -30,13 +38,25 @@ export function FlowChallengeForm({ component, submitting, fieldErrors = {}, + flowSlug, onSubmit, }: FlowChallengeFormProps) { const [values, setValues] = useState>({}) + const [signupCanSubmit, setSignupCanSubmit] = useState(true) const initializedComponentRef = useRef(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 ( -
+ {component === "ak-stage-prompt" ? ( - - setValues((prev) => ({ ...prev, [key]: value })) - } - /> + signupCredentials ? ( + + setValues((prev) => ({ ...prev, [key]: value })) + } + /> + ) : ( + + setValues((prev) => ({ ...prev, [key]: value })) + } + /> + ) ) : null} {component === "ak-stage-identification" ? ( @@ -154,8 +190,7 @@ export function FlowChallengeForm({ {isKnownComponent(component) ? ( {submitting ? ( <> @@ -232,6 +267,29 @@ function PromptFields({ ) } + if (field.type === "password") { + return ( + onChange(field.field_key, value)} + /> + ) + } + return ( {withPassword ? ( - onChange("password", value)} required + autoComplete="current-password" + onChange={(value) => onChange("password", value)} /> ) : null} diff --git a/components/auth/password-field-block.tsx b/components/auth/password-field-block.tsx new file mode 100644 index 0000000..df2e89d --- /dev/null +++ b/components/auth/password-field-block.tsx @@ -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, 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 ( +
+ +
+ onChange(event.target.value)} + className="h-10 rounded-lg pr-10" + /> + +
+ + {showStrength && value ? ( +
+
+ {STRENGTH_SEGMENTS.map((level, index) => ( + + ))} +
+

+ Robustesse :{" "} + {strength.label} +

+
+ ) : null} + + {match !== null ? ( +

+ {match + ? "Les mots de passe correspondent." + : "Les mots de passe ne correspondent pas."} +

+ ) : null} + + {error ? ( +

+ {error} +

+ ) : null} +
+ ) +} diff --git a/components/auth/signup-credentials-fields.tsx b/components/auth/signup-credentials-fields.tsx new file mode 100644 index 0000000..30efa57 --- /dev/null +++ b/components/auth/signup-credentials-fields.tsx @@ -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 + fieldErrors: Record + 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 ( +
+
+ +
+ 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]" + /> + + @{SIGNUP_MAIL_DOMAIN} + +
+ + + +

+ Votre adresse sera{" "} + + {previewEmail || `identifiant@${SIGNUP_MAIL_DOMAIN}`} + +

+ + + + {fieldErrors.username ? ( +

+ {fieldErrors.username} +

+ ) : null} +
+ + onChange("password", value)} + /> + + onChange("password_repeat", value)} + /> + + {otherFields.map((field) => ( + onChange(field.field_key, value)} + /> + ))} +
+ ) +} + +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 ( +

+ Au moins 2 caractères avant le @. +

+ ) + } + + if (local !== debouncedLocal || status === "checking") { + return ( +

+ + Vérification de la disponibilité… +

+ ) + } + + if (status === "available") { + return ( +

+ Cette adresse est disponible. +

+ ) + } + + if (status === "taken") { + return ( +

+ Cette adresse est déjà prise. +

+ ) + } + + if (status === "error") { + return ( +

+ Impossible de vérifier la disponibilité pour le moment. +

+ ) + } + + return null +} + +function GenericSignupField({ + field, + value, + error, + onChange, +}: { + field: PromptField + value: string + error?: string + onChange: (value: string) => void +}) { + if (field.type === "file") { + return ( +
+ + { + const file = event.target.files?.[0] + if (!file) { + onChange("") + return + } + const reader = new FileReader() + reader.onload = () => onChange(String(reader.result ?? "")) + reader.readAsDataURL(file) + }} + /> +
+ ) + } + + return ( +
+ + onChange(event.target.value)} + className="h-10 rounded-lg" + /> + {error ? ( +

+ {error} +

+ ) : null} +
+ ) +} + +function autoCompleteForField(fieldKey: string): string | undefined { + if (fieldKey === "name") return "name" + if (fieldKey.includes("phone")) return "tel" + return undefined +} diff --git a/lib/auth/check-mail-address.ts b/lib/auth/check-mail-address.ts new file mode 100644 index 0000000..2e1a49b --- /dev/null +++ b/lib/auth/check-mail-address.ts @@ -0,0 +1,22 @@ +export type MailAddressAvailability = { + available: boolean + reason?: string +} + +export async function checkMailAddressAvailability( + local: string, + domain: string +): Promise { + 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 +} diff --git a/lib/auth/password-strength.ts b/lib/auth/password-strength.ts new file mode 100644 index 0000000..05cdecb --- /dev/null +++ b/lib/auth/password-strength.ts @@ -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 +} diff --git a/lib/auth/signup-platform.ts b/lib/auth/signup-platform.ts new file mode 100644 index 0000000..554dbb2 --- /dev/null +++ b/lib/auth/signup-platform.ts @@ -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}` : "" +}