feat: implement forgot password and signup flows with new layouts and components
Some checks are pending
E2E / Playwright e2e (push) Waiting to run

- Added new layout and page components for forgot password and signup functionalities, enhancing user experience.
- Integrated authentication flow handling for password recovery and account creation, utilizing dynamic metadata.
- Updated login form to include links for forgot password and signup, improving navigation between authentication states.
- Refactored CSS styles for login components to ensure consistent design across different authentication pages.
This commit is contained in:
R3D347HR4Y 2026-06-19 22:34:23 +02:00
parent b61e1a1441
commit 359931c2f3
21 changed files with 995 additions and 86 deletions

View File

@ -0,0 +1,16 @@
import { LoginChrome } from "@/components/auth/login-chrome"
import type { Metadata } from "next"
import { suitePageMetadata } from "@/lib/suite/page-metadata"
export const metadata: Metadata = suitePageMetadata({
app: "suite",
title: "Mot de passe oublié",
})
export default function ForgotPasswordLayout({
children,
}: {
children: React.ReactNode
}) {
return <LoginChrome>{children}</LoginChrome>
}

View File

@ -0,0 +1,5 @@
import { ForgotPasswordPageContent } from "@/components/auth/forgot-password-page-content"
export default function ForgotPasswordPage() {
return <ForgotPasswordPageContent />
}

View File

@ -701,6 +701,20 @@ html:has(.ultimail-login) body {
color: var(--login-text-link) !important; color: var(--login-text-link) !important;
} }
.ultimail-login [data-slot='card'] input[type='text'],
.ultimail-login [data-slot='card'] input[type='email'],
.ultimail-login [data-slot='card'] input[type='password'] {
border-color: rgb(255 255 255 / 0.12);
background: rgb(255 255 255 / 0.04);
}
.dark .ultimail-login [data-slot='card'] input[type='text'],
.dark .ultimail-login [data-slot='card'] input[type='email'],
.dark .ultimail-login [data-slot='card'] input[type='password'] {
border-color: rgb(255 255 255 / 0.14);
background: rgb(0 0 0 / 0.25);
}
.ultimail-login [data-slot='card'] { .ultimail-login [data-slot='card'] {
position: relative; position: relative;
isolation: isolate; isolation: isolate;
@ -1021,7 +1035,8 @@ html[data-route-scope='drive'] body {
transition-duration: 0.12s; transition-duration: 0.12s;
} }
.ultimail-login a.ultimail-login-connect-btn { .ultimail-login a.ultimail-login-connect-btn,
.ultimail-login button.ultimail-login-connect-btn {
position: relative; position: relative;
z-index: 1; z-index: 1;
isolation: isolate; isolation: isolate;
@ -1061,12 +1076,14 @@ html[data-route-scope='drive'] body {
letter-spacing 0.35s ease; letter-spacing 0.35s ease;
} }
.ultimail-login a.ultimail-login-connect-btn > * { .ultimail-login a.ultimail-login-connect-btn > *,
.ultimail-login button.ultimail-login-connect-btn > * {
position: relative; position: relative;
z-index: 1; z-index: 1;
} }
.ultimail-login a.ultimail-login-connect-btn::before { .ultimail-login a.ultimail-login-connect-btn::before,
.ultimail-login button.ultimail-login-connect-btn::before {
content: ''; content: '';
position: absolute; position: absolute;
inset: 0; inset: 0;
@ -1084,7 +1101,8 @@ html[data-route-scope='drive'] body {
transform 0.35s ease; transform 0.35s ease;
} }
.ultimail-login a.ultimail-login-connect-btn::after { .ultimail-login a.ultimail-login-connect-btn::after,
.ultimail-login button.ultimail-login-connect-btn::after {
content: ''; content: '';
position: absolute; position: absolute;
inset: 0; inset: 0;
@ -1101,7 +1119,8 @@ html[data-route-scope='drive'] body {
pointer-events: none; pointer-events: none;
} }
.dark .ultimail-login a.ultimail-login-connect-btn { .dark .ultimail-login a.ultimail-login-connect-btn,
.ultimail-login button.ultimail-login-connect-btn {
background: background:
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),
@ -1114,7 +1133,8 @@ html[data-route-scope='drive'] body {
background-repeat: no-repeat !important; background-repeat: no-repeat !important;
} }
.ultimail-login .ultimail-login-connect-border:hover a.ultimail-login-connect-btn { .ultimail-login .ultimail-login-connect-border:hover a.ultimail-login-connect-btn,
.ultimail-login .ultimail-login-connect-border:hover button.ultimail-login-connect-btn {
filter: brightness(1.14); filter: brightness(1.14);
letter-spacing: 0.01em; letter-spacing: 0.01em;
box-shadow: box-shadow:
@ -1123,7 +1143,8 @@ html[data-route-scope='drive'] body {
inset 0 0 24px -8px rgb(99 102 241 / 35%); inset 0 0 24px -8px rgb(99 102 241 / 35%);
} }
.ultimail-login .ultimail-login-connect-border:hover a.ultimail-login-connect-btn::before { .ultimail-login .ultimail-login-connect-border:hover a.ultimail-login-connect-btn::before,
.ultimail-login .ultimail-login-connect-border:hover button.ultimail-login-connect-btn::before {
opacity: 1; opacity: 1;
transform: scale(1.08); transform: scale(1.08);
background: radial-gradient( background: radial-gradient(
@ -1133,7 +1154,8 @@ html[data-route-scope='drive'] body {
); );
} }
.ultimail-login .ultimail-login-connect-border:hover a.ultimail-login-connect-btn::after { .ultimail-login .ultimail-login-connect-border:hover a.ultimail-login-connect-btn::after,
.ultimail-login .ultimail-login-connect-border:hover button.ultimail-login-connect-btn::after {
animation: login-connect-shimmer 0.85s cubic-bezier(0.22, 1, 0.36, 1) forwards; animation: login-connect-shimmer 0.85s cubic-bezier(0.22, 1, 0.36, 1) forwards;
} }
@ -1145,7 +1167,8 @@ html[data-route-scope='drive'] body {
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.ultimail-login .ultimail-login-connect-border, .ultimail-login .ultimail-login-connect-border,
.ultimail-login a.ultimail-login-connect-btn { .ultimail-login a.ultimail-login-connect-btn,
.ultimail-login button.ultimail-login-connect-btn {
transition: none; transition: none;
} }

View File

@ -3,7 +3,6 @@
import { useSearchParams } from "next/navigation" import { useSearchParams } from "next/navigation"
import { Suspense } from "react" import { Suspense } from "react"
import { LoginForm } from "@/components/auth/login-form" import { LoginForm } from "@/components/auth/login-form"
import { getAuthentikEnrollmentUrl } from "@/lib/auth/oidc-config"
import { useNativeRuntime } from "@/lib/platform" import { useNativeRuntime } from "@/lib/platform"
import { NativeLogin } from "@/components/mobile/native-login" import { NativeLogin } from "@/components/mobile/native-login"
@ -12,7 +11,8 @@ function LoginContent() {
const error = searchParams.get("error") const error = searchParams.get("error")
const returnTo = searchParams.get("returnTo") ?? "/mail/inbox" const returnTo = searchParams.get("returnTo") ?? "/mail/inbox"
const loginHref = `/api/auth/login?returnTo=${encodeURIComponent(returnTo)}` const loginHref = `/api/auth/login?returnTo=${encodeURIComponent(returnTo)}`
const signupHref = getAuthentikEnrollmentUrl() const signupHref = `/signup?returnTo=${encodeURIComponent(returnTo)}`
const forgotPasswordHref = `/forgot-password?returnTo=${encodeURIComponent(returnTo)}`
if (useNativeRuntime()) { if (useNativeRuntime()) {
return <NativeLogin returnTo={returnTo} /> return <NativeLogin returnTo={returnTo} />
@ -22,6 +22,7 @@ function LoginContent() {
<LoginForm <LoginForm
loginHref={loginHref} loginHref={loginHref}
signupHref={signupHref} signupHref={signupHref}
forgotPasswordHref={forgotPasswordHref}
error={error} error={error}
/> />
) )

16
app/signup/layout.tsx Normal file
View File

@ -0,0 +1,16 @@
import { LoginChrome } from "@/components/auth/login-chrome"
import type { Metadata } from "next"
import { suitePageMetadata } from "@/lib/suite/page-metadata"
export const metadata: Metadata = suitePageMetadata({
app: "suite",
title: "Créer un compte",
})
export default function SignupLayout({
children,
}: {
children: React.ReactNode
}) {
return <LoginChrome>{children}</LoginChrome>
}

19
app/signup/page.tsx Normal file
View File

@ -0,0 +1,19 @@
"use client"
import { Suspense } from "react"
import { useSearchParams } from "next/navigation"
import { SignupPageContent } from "@/components/auth/signup-page-content"
function SignupContent() {
const searchParams = useSearchParams()
const returnTo = searchParams.get("returnTo") ?? "/mail/inbox"
return <SignupPageContent returnTo={returnTo} />
}
export default function SignupPage() {
return (
<Suspense fallback={null}>
<SignupContent />
</Suspense>
)
}

View File

@ -0,0 +1,80 @@
"use client"
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
} from "@/components/ui/card"
import { cn } from "@/lib/utils"
export const AUTH_CARD_CLASS = cn(
"w-full gap-4 border-0 bg-transparent px-4 py-6 shadow-none",
"sm:gap-5 sm:rounded-none sm:px-8 sm:py-8",
"sm:text-card-foreground sm:dark:text-mail-text sm:shadow-none"
)
type AuthCardProps = {
title?: string
description?: string
error?: string | null
children: React.ReactNode
footer?: React.ReactNode
}
export function AuthBrandMark() {
return (
<div className="flex flex-col items-center gap-3 py-4">
<img
src="/ultisuite-mark.svg"
alt=""
width={72}
height={72}
draggable={false}
className="h-16 w-16 select-none"
aria-hidden
/>
<span className="text-2xl font-bold tracking-tight">
Ulti<span className="landing-gradient-text">Suite</span>
</span>
</div>
)
}
export function AuthCard({
title,
description,
error,
children,
footer,
}: AuthCardProps) {
return (
<div className="flex flex-1 flex-col items-center justify-center px-4">
<div className="ultimail-login-card-frame mx-auto w-full max-w-sm">
<Card className={AUTH_CARD_CLASS}>
<CardHeader className="gap-4 px-0 text-center sm:px-0">
<AuthBrandMark />
{title ? (
<h1 className="text-lg font-semibold text-foreground">{title}</h1>
) : null}
{description ? (
<CardDescription>{description}</CardDescription>
) : null}
{error ? (
<p className="text-sm text-destructive" role="alert">
{error}
</p>
) : null}
</CardHeader>
<CardContent className="px-0 sm:px-0">{children}</CardContent>
{footer ? (
<CardFooter className="px-0 sm:px-0">{footer}</CardFooter>
) : null}
</Card>
</div>
</div>
)
}

View File

@ -0,0 +1,159 @@
"use client"
import { useCallback, useEffect, useMemo, useState } from "react"
import Link from "next/link"
import { Loader2 } from "lucide-react"
import { AuthCard } from "@/components/auth/auth-card"
import { FlowChallengeForm } from "@/components/auth/flow-challenge-form"
import {
AUTH_FLOW_SLUGS,
flowComponent,
flowRedirectUrl,
flowTitle,
flowValidationErrors,
isRecoveryEmailSent,
respondAuthFlow,
startAuthFlow,
type AuthFlowSlug,
type FlowChallenge,
} from "@/lib/auth/flow-api"
type AuthFlowPageProps = {
slug: AuthFlowSlug
defaultTitle: string
defaultDescription: string
successTitle: string
successDescription: string
successActionLabel: string
successHref: string
/** Full document navigation required (OIDC login routes). */
successExternal?: boolean
footer: React.ReactNode
onSuccess?: () => void
}
export function AuthFlowPage({
slug,
defaultTitle,
defaultDescription,
successTitle,
successDescription,
successActionLabel,
successHref,
successExternal = false,
footer,
onSuccess,
}: AuthFlowPageProps) {
const [sessionId, setSessionId] = useState<string | null>(null)
const [challenge, setChallenge] = useState<FlowChallenge | null>(null)
const [loading, setLoading] = useState(true)
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
const [done, setDone] = useState(false)
const [denied, setDenied] = useState(false)
const bootstrap = useCallback(async () => {
setLoading(true)
setError(null)
try {
const step = await startAuthFlow(slug)
setSessionId(step.sessionId)
setChallenge(step.challenge)
setDone(step.done)
setDenied(step.denied)
if (step.done && !step.denied) {
onSuccess?.()
}
} catch (err) {
setError(err instanceof Error ? err.message : "Impossible de démarrer le parcours")
} finally {
setLoading(false)
}
}, [onSuccess, slug])
useEffect(() => {
void bootstrap()
}, [bootstrap])
const validationErrors = useMemo(() => flowValidationErrors(challenge), [challenge])
const formError = error ?? validationErrors._form ?? null
const title = useMemo(() => {
if ((done && !denied) || isRecoveryEmailSent(slug, challenge)) return successTitle
if (denied) return "Accès refusé"
return flowTitle(challenge) || defaultTitle
}, [challenge, defaultTitle, denied, done, slug, successTitle])
const description = useMemo(() => {
if ((done && !denied) || isRecoveryEmailSent(slug, challenge)) return successDescription
if (denied) {
return "Ce parcours a été refusé. Vérifiez vos informations ou contactez le support."
}
return defaultDescription
}, [challenge, defaultDescription, denied, done, slug, successDescription])
async function handleSubmit(payload: Record<string, unknown>) {
if (!sessionId) return
setSubmitting(true)
setError(null)
try {
const step = await respondAuthFlow(slug, payload)
setSessionId(step.sessionId)
setChallenge(step.challenge)
setDone(step.done)
setDenied(step.denied)
if (step.done && !step.denied) {
onSuccess?.()
}
} catch (err) {
setError(err instanceof Error ? err.message : "Échec de l'étape")
} finally {
setSubmitting(false)
}
}
const component = flowComponent(challenge)
const redirectUrl = flowRedirectUrl(challenge)
const recoveryEmailSent = isRecoveryEmailSent(slug, challenge)
const showSuccess = (done && !denied) || recoveryEmailSent
return (
<AuthCard title={title} description={description} error={formError} footer={footer}>
{loading ? (
<div className="flex justify-center py-8">
<Loader2 className="size-6 animate-spin text-muted-foreground" aria-hidden />
<span className="sr-only">Chargement</span>
</div>
) : showSuccess ? (
<div className="flex flex-col items-center gap-4">
{redirectUrl ? (
<p className="text-center text-sm text-muted-foreground">
Redirection en cours
</p>
) : null}
<div className="ultimail-login-connect-border w-full">
{successExternal ? (
<a href={successHref} className="ultimail-login-connect-btn">
{successActionLabel}
</a>
) : (
<Link href={successHref} className="ultimail-login-connect-btn">
{successActionLabel}
</Link>
)}
</div>
</div>
) : (
<FlowChallengeForm
challenge={challenge}
component={component}
submitting={submitting}
fieldErrors={validationErrors}
onSubmit={handleSubmit}
/>
)}
</AuthCard>
)
}
export { AUTH_FLOW_SLUGS }

View File

@ -0,0 +1,395 @@
"use client"
import { useEffect, useMemo, useRef, useState } from "react"
import { Loader2 } from "lucide-react"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import type { FlowChallenge } from "@/lib/auth/flow-api"
type PromptField = {
field_key: string
label?: string
type?: string
required?: boolean
placeholder?: string
initial_value?: string
choices?: Array<{ label?: string; value?: string }>
}
type FlowChallengeFormProps = {
challenge: FlowChallenge | null
component: string
submitting: boolean
fieldErrors?: Record<string, string>
onSubmit: (payload: Record<string, unknown>) => void | Promise<void>
}
export function FlowChallengeForm({
challenge,
component,
submitting,
fieldErrors = {},
onSubmit,
}: FlowChallengeFormProps) {
const [values, setValues] = useState<Record<string, string>>({})
const initializedComponentRef = useRef<string | null>(null)
const promptFields = useMemo(() => readPromptFields(challenge), [challenge])
const primaryAction = readPrimaryAction(challenge)
useEffect(() => {
if (initializedComponentRef.current === component) return
initializedComponentRef.current = component
const next: Record<string, string> = {}
if (component === "ak-stage-prompt") {
for (const field of promptFields) {
if (field.type === "hidden" || field.type === "static") continue
next[field.field_key] = field.initial_value ?? ""
}
}
if (component === "ak-stage-identification") {
next.uid_field = ""
if (readPasswordFields(challenge)) {
next.password = ""
}
}
if (component === "ak-stage-email") {
next.email = ""
}
setValues(next)
}, [challenge, component, promptFields])
async function handleSubmit(event: React.FormEvent) {
event.preventDefault()
const payload: Record<string, unknown> = { component }
if (component === "ak-stage-prompt") {
for (const field of promptFields) {
const key = field.field_key
if (field.type === "static") continue
if (field.type === "hidden") {
payload[key] = field.initial_value ?? ""
continue
}
payload[key] = values[key] ?? ""
}
} else if (component === "ak-stage-identification") {
payload.uid_field = values.uid_field ?? ""
if (readPasswordFields(challenge)) {
payload.password = values.password ?? ""
}
} else if (component === "ak-stage-email") {
payload.email = values.email ?? ""
} else if (component === "ak-stage-authenticator-validate") {
payload.code = values.code ?? ""
} else {
Object.assign(payload, values)
}
await onSubmit(payload)
}
if (!component || component === "ak-stage-access-denied") {
return null
}
return (
<form className="space-y-4" onSubmit={handleSubmit}>
{component === "ak-stage-prompt" ? (
<PromptFields
fields={promptFields}
values={values}
fieldErrors={fieldErrors}
onChange={(key, value) =>
setValues((prev) => ({ ...prev, [key]: value }))
}
/>
) : null}
{component === "ak-stage-identification" ? (
<IdentificationFields
challenge={challenge}
values={values}
fieldErrors={fieldErrors}
onChange={(key, value) =>
setValues((prev) => ({ ...prev, [key]: value }))
}
/>
) : null}
{component === "ak-stage-email" ? (
<FieldBlock
id="email"
label="Adresse e-mail"
type="email"
autoComplete="email"
value={values.email ?? ""}
error={fieldErrors.email}
onChange={(value) => setValues((prev) => ({ ...prev, email: value }))}
required
/>
) : null}
{component === "ak-stage-authenticator-validate" ? (
<FieldBlock
id="code"
label="Code de validation"
type="text"
inputMode="numeric"
autoComplete="one-time-code"
value={values.code ?? ""}
onChange={(value) => setValues((prev) => ({ ...prev, code: value }))}
required
/>
) : null}
{!isKnownComponent(component) ? (
<p className="text-sm text-muted-foreground">
Étape non prise en charge ({component}). Utilisez le portail Authentik.
</p>
) : null}
{isKnownComponent(component) ? (
<div className="ultimail-login-connect-border pt-1">
<button
type="submit"
disabled={submitting}
className="ultimail-login-connect-btn w-full disabled:opacity-60"
>
{submitting ? (
<>
<Loader2 className="size-4 animate-spin" aria-hidden />
<span>Patientez</span>
</>
) : (
primaryAction
)}
</button>
</div>
) : null}
</form>
)
}
function PromptFields({
fields,
values,
fieldErrors,
onChange,
}: {
fields: PromptField[]
values: Record<string, string>
fieldErrors: Record<string, string>
onChange: (key: string, value: string) => void
}) {
return (
<div className="space-y-4">
{fields.map((field) => {
if (field.type === "hidden") return null
if (field.type === "static") {
return (
<p key={field.field_key} className="text-sm text-muted-foreground">
{field.label}
{field.initial_value ? (
<span className="font-medium text-foreground">
{" "}
{field.initial_value}
</span>
) : null}
</p>
)
}
const inputType =
field.type === "password"
? "password"
: field.type === "email"
? "email"
: "text"
if (field.type === "file") {
return (
<div key={field.field_key} 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(field.field_key, "")
return
}
const reader = new FileReader()
reader.onload = () => {
onChange(field.field_key, String(reader.result ?? ""))
}
reader.readAsDataURL(file)
}}
/>
</div>
)
}
return (
<FieldBlock
key={field.field_key}
id={field.field_key}
label={field.label ?? field.field_key}
type={inputType}
placeholder={field.placeholder}
value={values[field.field_key] ?? ""}
error={fieldErrors[field.field_key]}
onChange={(value) => onChange(field.field_key, value)}
required={field.required}
autoComplete={autoCompleteFor(field.field_key, field.type)}
/>
)
})}
</div>
)
}
function IdentificationFields({
challenge,
values,
fieldErrors,
onChange,
}: {
challenge: FlowChallenge | null
values: Record<string, string>
fieldErrors: Record<string, string>
onChange: (key: string, value: string) => void
}) {
const withPassword = readPasswordFields(challenge)
const userFields = readUserFields(challenge)
const label =
userFields.includes("email") && !userFields.includes("username")
? "Adresse e-mail"
: "Identifiant"
return (
<div className="space-y-4">
<FieldBlock
id="uid_field"
label={label}
type={userFields.includes("email") ? "email" : "text"}
autoComplete="username"
value={values.uid_field ?? ""}
error={fieldErrors.uid_field}
onChange={(value) => onChange("uid_field", value)}
required
/>
{withPassword ? (
<FieldBlock
id="password"
label="Mot de passe"
type="password"
autoComplete="current-password"
value={values.password ?? ""}
error={fieldErrors.password}
onChange={(value) => onChange("password", value)}
required
/>
) : null}
</div>
)
}
function FieldBlock({
id,
label,
type = "text",
placeholder,
value,
error,
onChange,
required,
autoComplete,
inputMode,
}: {
id: string
label: string
type?: string
placeholder?: string
value: string
error?: string
onChange: (value: string) => void
required?: boolean
autoComplete?: string
inputMode?: React.HTMLAttributes<HTMLInputElement>["inputMode"]
}) {
return (
<div className="space-y-2">
<Label htmlFor={id}>{label}</Label>
<Input
id={id}
type={type}
placeholder={placeholder}
value={value}
required={required}
autoComplete={autoComplete}
inputMode={inputMode}
aria-invalid={error ? true : undefined}
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 readPromptFields(challenge: FlowChallenge | null): PromptField[] {
const raw = challenge?.fields
if (!Array.isArray(raw)) return []
return raw.filter(isPromptField)
}
function isPromptField(value: unknown): value is PromptField {
return (
typeof value === "object" &&
value !== null &&
typeof (value as PromptField).field_key === "string"
)
}
function readPrimaryAction(challenge: FlowChallenge | null): string {
const action = challenge?.primary_action
return typeof action === "string" && action.trim() ? action : "Continuer"
}
function readPasswordFields(challenge: FlowChallenge | null): boolean {
return challenge?.password_fields === true
}
function readUserFields(challenge: FlowChallenge | null): string[] {
const raw = challenge?.user_fields
if (!Array.isArray(raw)) return ["username"]
return raw.filter((v): v is string => typeof v === "string")
}
function autoCompleteFor(fieldKey: string, type?: string): string | undefined {
if (type === "password") {
return fieldKey.includes("repeat") ? "new-password" : "new-password"
}
if (fieldKey === "username") return "username"
if (fieldKey === "email" || fieldKey.includes("email")) return "email"
if (fieldKey === "name") return "name"
if (fieldKey.includes("phone")) return "tel"
return undefined
}
function isKnownComponent(component: string): boolean {
return [
"ak-stage-prompt",
"ak-stage-identification",
"ak-stage-email",
"ak-stage-authenticator-validate",
].includes(component)
}

View File

@ -0,0 +1,26 @@
"use client"
import Link from "next/link"
import { AuthFlowPage } from "@/components/auth/auth-flow-page"
import { AUTH_FLOW_SLUGS } from "@/lib/auth/auth-flow-slugs"
export function ForgotPasswordPageContent() {
return (
<AuthFlowPage
slug={AUTH_FLOW_SLUGS.recovery}
defaultTitle="Mot de passe oublié"
defaultDescription="Indiquez votre adresse e-mail pour recevoir un lien de réinitialisation."
successTitle="E-mail envoyé"
successDescription="Si un compte existe pour cette adresse, vous recevrez un e-mail avec les instructions."
successActionLabel="Retour à la connexion"
successHref="/login"
footer={
<p className="w-full text-center text-sm text-muted-foreground">
<Link className="font-medium text-primary underline" href="/login">
Retour à la connexion
</Link>
</p>
}
/>
)
}

View File

@ -1,80 +1,50 @@
"use client" "use client"
import Link from "next/link"
import { Sparkles } from "lucide-react" import { Sparkles } from "lucide-react"
import { import { AuthCard } from "@/components/auth/auth-card"
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
} from "@/components/ui/card"
import { cn } from "@/lib/utils"
const LOGIN_CARD_CLASS = cn(
"w-full gap-4 border-0 bg-transparent px-4 py-6 shadow-none",
"sm:gap-5 sm:rounded-none sm:px-8 sm:py-8",
"sm:text-card-foreground sm:dark:text-mail-text sm:shadow-none"
)
type LoginFormProps = { type LoginFormProps = {
loginHref: string loginHref: string
signupHref: string signupHref: string
forgotPasswordHref: string
error: string | null error: string | null
} }
export function LoginForm({ loginHref, signupHref, error }: LoginFormProps) { export function LoginForm({
loginHref,
signupHref,
forgotPasswordHref,
error,
}: LoginFormProps) {
return ( return (
<div className="flex flex-1 flex-col items-center justify-center px-4"> <AuthCard
<div className="ultimail-login-card-frame mx-auto w-full max-w-sm"> description="Connecte-toi avec ton compte UltiSpace pour accéder à ta suite."
<Card className={LOGIN_CARD_CLASS}> error={error ? decodeURIComponent(error) : null}
<CardHeader className="gap-4 px-0 text-center sm:px-0"> footer={
<div className="flex flex-col items-center gap-3 py-4"> <div className="flex w-full flex-col gap-3 text-center text-sm text-muted-foreground">
<img <p>
src="/ultisuite-mark.svg" Pas encore de compte ?{" "}
alt="" <Link className="font-medium text-primary underline" href={signupHref}>
width={72} Créer un compte
height={72} </Link>
draggable={false}
className="h-16 w-16 select-none"
aria-hidden
/>
<span className="text-2xl font-bold tracking-tight">
Ulti<span className="landing-gradient-text">Suite</span>
</span>
</div>
<CardDescription>
Connecte-toi avec ton compte UltiSpace pour accéder à ta suite.
</CardDescription>
{error ? (
<p className="text-sm text-destructive" role="alert">
{decodeURIComponent(error)}
</p> </p>
) : null} <p>
</CardHeader> <Link className="font-medium text-primary underline" href={forgotPasswordHref}>
Mot de passe oublié ?
<CardContent className="flex justify-center px-0 sm:px-0"> </Link>
<div className="ultimail-login-connect-border"> </p>
</div>
}
>
<div className="flex justify-center">
<div className="ultimail-login-connect-border w-full">
<a href={loginHref} className="ultimail-login-connect-btn"> <a href={loginHref} className="ultimail-login-connect-btn">
<Sparkles className="size-4 shrink-0" strokeWidth={2} aria-hidden /> <Sparkles className="size-4 shrink-0" strokeWidth={2} aria-hidden />
Se connecter avec UltiSpace Se connecter avec UltiSpace
</a> </a>
</div> </div>
</CardContent>
<CardFooter className="px-0 sm:px-0">
<p className="w-full text-center text-sm text-muted-foreground">
Pas encore de compte ?{" "}
<a
className="font-medium text-primary underline"
href={signupHref}
suppressHydrationWarning
>
Créer un compte
</a>
</p>
</CardFooter>
</Card>
</div>
</div> </div>
</AuthCard>
) )
} }

View File

@ -0,0 +1,35 @@
"use client"
import Link from "next/link"
import { AuthFlowPage } from "@/components/auth/auth-flow-page"
import { AUTH_FLOW_SLUGS } from "@/lib/auth/auth-flow-slugs"
type SignupPageContentProps = {
returnTo?: string
}
export function SignupPageContent({ returnTo = "/mail/inbox" }: SignupPageContentProps) {
const loginHref = `/login?returnTo=${encodeURIComponent(returnTo)}`
const oidcHref = `/api/auth/login?returnTo=${encodeURIComponent(returnTo)}`
return (
<AuthFlowPage
slug={AUTH_FLOW_SLUGS.enrollment}
defaultTitle="Créer un compte"
defaultDescription="Choisissez votre identifiant @ultisuite.fr et configurez votre profil."
successTitle="Compte créé"
successDescription="Votre compte UltiSpace est prêt. Connectez-vous pour accéder à la suite."
successActionLabel="Se connecter"
successHref={oidcHref}
successExternal
footer={
<p className="w-full text-center text-sm text-muted-foreground">
Déjà un compte ?{" "}
<Link className="font-medium text-primary underline" href={loginHref}>
Se connecter
</Link>
</p>
}
/>
)
}

View File

@ -8,7 +8,7 @@ import { AccountSwitcherDropdown } from "@/components/suite/account-switcher-dro
import { SuiteFavoritesMenu } from "@/components/suite/suite-favorites-menu" import { SuiteFavoritesMenu } from "@/components/suite/suite-favorites-menu"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { useChromeIdentity } from "@/lib/hooks/use-chrome-identity" import { useChromeIdentity } from "@/lib/hooks/use-chrome-identity"
import { getAuthentikEnrollmentUrl } from "@/lib/auth/oidc-config" import { getSignupUrl } from "@/lib/auth/oidc-config"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
const NAV_LINKS = [ const NAV_LINKS = [
@ -104,7 +104,7 @@ export function LandingHeader({ scrolled }: { scrolled: boolean }) {
) : ( ) : (
<> <>
<a <a
href={getAuthentikEnrollmentUrl()} href={getSignupUrl()}
className="landing-cta landing-cta--ghost hidden h-9 px-4 text-sm sm:inline-flex" className="landing-cta landing-cta--ghost hidden h-9 px-4 text-sm sm:inline-flex"
> >
Créer un compte Créer un compte

View File

@ -5,7 +5,7 @@ import { Icon } from "@iconify/react"
import { LandingReveal } from "@/components/landing/landing-reveal" import { LandingReveal } from "@/components/landing/landing-reveal"
import { LANDING_APPS, LANDING_APP_DEMO_TAB } from "@/components/landing/landing-data" import { LANDING_APPS, LANDING_APP_DEMO_TAB } from "@/components/landing/landing-data"
import { useChromeIdentity } from "@/lib/hooks/use-chrome-identity" import { useChromeIdentity } from "@/lib/hooks/use-chrome-identity"
import { getAuthentikEnrollmentUrl } from "@/lib/auth/oidc-config" import { getSignupUrl } from "@/lib/auth/oidc-config"
function HeroDock({ function HeroDock({
authenticated, authenticated,
@ -127,7 +127,7 @@ export function LandingHero({ onOpenDemo }: { onOpenDemo: (demoTabId: string | n
<Icon icon="mdi:arrow-right" className="size-5" aria-hidden /> <Icon icon="mdi:arrow-right" className="size-5" aria-hidden />
</Link> </Link>
<a <a
href={getAuthentikEnrollmentUrl()} href={getSignupUrl()}
className="landing-cta landing-cta--ghost h-12 px-7 text-base" className="landing-cta landing-cta--ghost h-12 px-7 text-base"
> >
Créer un compte Créer un compte

View File

@ -11,7 +11,7 @@ import {
landingAppGlowVars, landingAppGlowVars,
} from "@/components/landing/landing-data" } from "@/components/landing/landing-data"
import { useChromeIdentity } from "@/lib/hooks/use-chrome-identity" import { useChromeIdentity } from "@/lib/hooks/use-chrome-identity"
import { getAuthentikEnrollmentUrl } from "@/lib/auth/oidc-config" import { getSignupUrl } from "@/lib/auth/oidc-config"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
function SectionHeading({ function SectionHeading({
@ -327,7 +327,7 @@ export function LandingFooter() {
) : ( ) : (
<> <>
<a <a
href={getAuthentikEnrollmentUrl()} href={getSignupUrl()}
className="landing-cta landing-cta--primary h-12 px-7 text-base" className="landing-cta landing-cta--primary h-12 px-7 text-base"
> >
Créer un compte Créer un compte

View File

@ -13,7 +13,7 @@ import { ProductHighlights } from "@/components/landing/product/product-highligh
import { ProductIntegrations } from "@/components/landing/product/product-integrations" import { ProductIntegrations } from "@/components/landing/product/product-integrations"
import type { ProductPageData } from "@/components/landing/product/product-data" import type { ProductPageData } from "@/components/landing/product/product-data"
import { useChromeIdentity } from "@/lib/hooks/use-chrome-identity" import { useChromeIdentity } from "@/lib/hooks/use-chrome-identity"
import { getAuthentikEnrollmentUrl } from "@/lib/auth/oidc-config" import { getSignupUrl } from "@/lib/auth/oidc-config"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
function ProductHeader({ function ProductHeader({
@ -82,7 +82,7 @@ function ProductHeader({
) : ( ) : (
<> <>
<a <a
href={getAuthentikEnrollmentUrl()} href={getSignupUrl()}
className="landing-cta landing-cta--ghost hidden h-9 px-4 text-sm sm:inline-flex" className="landing-cta landing-cta--ghost hidden h-9 px-4 text-sm sm:inline-flex"
> >
Créer un compte Créer un compte

View File

@ -0,0 +1,7 @@
/** Authentik flow slugs used by Ulti custom auth UI. */
export const AUTH_FLOW_SLUGS = {
enrollment: "ulti-enrollment",
recovery: "ulti-recovery",
} as const
export type AuthFlowSlug = (typeof AUTH_FLOW_SLUGS)[keyof typeof AUTH_FLOW_SLUGS]

View File

@ -22,6 +22,7 @@ const TAB_PAGE: Record<AuthentikUserSettingsTab, string> = {
/** Flows Authentik par défaut pour self-service (modifiables côté admin). */ /** Flows Authentik par défaut pour self-service (modifiables côté admin). */
export const AUTHENTIK_SELF_SERVICE_FLOWS = { export const AUTHENTIK_SELF_SERVICE_FLOWS = {
passwordChange: "default-password-change", passwordChange: "default-password-change",
recovery: "ulti-recovery",
totpSetup: "default-authenticator-totp-setup", totpSetup: "default-authenticator-totp-setup",
webauthnSetup: "default-authenticator-webauthn-setup", webauthnSetup: "default-authenticator-webauthn-setup",
staticSetup: "default-authenticator-static-setup", staticSetup: "default-authenticator-static-setup",

142
lib/auth/flow-api.ts Normal file
View File

@ -0,0 +1,142 @@
import { AUTH_FLOW_SLUGS, type AuthFlowSlug } from "@/lib/auth/auth-flow-slugs"
export type FlowChallenge = Record<string, unknown>
export type FlowStepResponse = {
sessionId: string
challenge: FlowChallenge
done: boolean
denied: boolean
}
export type FlowApiError = {
code?: string
message?: string
}
function flowApiBase(slug: AuthFlowSlug): string {
return `/api/v1/auth/flows/${encodeURIComponent(slug)}`
}
async function parseFlowResponse(res: Response): Promise<FlowStepResponse> {
const body = (await res.json()) as FlowStepResponse & FlowApiError
if (!res.ok) {
throw new Error(body.message ?? `flow request failed (${res.status})`)
}
return body
}
export async function startAuthFlow(slug: AuthFlowSlug): Promise<FlowStepResponse> {
const res = await fetch(`${flowApiBase(slug)}/start`, {
method: "POST",
credentials: "include",
headers: { Accept: "application/json" },
})
return parseFlowResponse(res)
}
export async function respondAuthFlow(
slug: AuthFlowSlug,
payload: Record<string, unknown>
): Promise<FlowStepResponse> {
const res = await fetch(`${flowApiBase(slug)}/respond`, {
method: "POST",
credentials: "include",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify({ payload }),
})
return parseFlowResponse(res)
}
export function flowComponent(challenge: FlowChallenge | null | undefined): string {
if (!challenge) return ""
const value = challenge.component
return typeof value === "string" ? value : ""
}
/** Recovery flow: email stage reached after identification means the reset link was sent. */
export function isRecoveryEmailSent(
slug: AuthFlowSlug,
challenge: FlowChallenge | null | undefined
): boolean {
if (slug !== AUTH_FLOW_SLUGS.recovery || !challenge) return false
if (flowComponent(challenge) !== "ak-stage-email") return false
const errors = challenge.response_errors
if (!errors || typeof errors !== "object") return true
const record = errors as Record<string, unknown>
for (const [key, value] of Object.entries(record)) {
if (key === "non_field_errors" && Array.isArray(value)) {
if (value.length === 0) continue
const onlyEmailSent = value.every(
(item) =>
typeof item === "object" &&
item !== null &&
(item as Record<string, unknown>).code === "email-sent"
)
if (onlyEmailSent) return true
return false
}
if (Array.isArray(value) && value.length > 0) return false
if (typeof value === "string" && value.trim()) return false
}
return true
}
/** Extract Authentik field validation errors from a challenge. */
export function flowValidationErrors(
challenge: FlowChallenge | null | undefined
): Record<string, string> {
const out: Record<string, string> = {}
const errors = challenge?.response_errors
if (!errors || typeof errors !== "object") return out
for (const [key, value] of Object.entries(errors as Record<string, unknown>)) {
if (key === "non_field_errors" && Array.isArray(value)) {
const messages = value
.map((item) => formatFlowErrorItem(item))
.filter(Boolean)
if (messages.length > 0) {
out._form = messages.join(" ")
}
continue
}
if (Array.isArray(value)) {
const messages = value.map((item) => formatFlowErrorItem(item)).filter(Boolean)
if (messages.length > 0) out[key] = messages.join(" ")
} else if (typeof value === "string" && value.trim()) {
out[key] = value
}
}
return out
}
function formatFlowErrorItem(item: unknown): string {
if (typeof item === "string") return item
if (typeof item === "object" && item !== null) {
const record = item as Record<string, unknown>
if (typeof record.string === "string" && record.string.trim()) return record.string
if (typeof record.code === "string" && record.code.trim()) return record.code
}
return ""
}
export function flowTitle(challenge: FlowChallenge | null | undefined): string {
const info = challenge?.flow_info
if (info && typeof info === "object" && info !== null) {
const title = (info as Record<string, unknown>).title
if (typeof title === "string" && title.trim()) return title
}
return ""
}
export function flowRedirectUrl(challenge: FlowChallenge | null | undefined): string {
const to = challenge?.to
return typeof to === "string" ? to : ""
}
export { AUTH_FLOW_SLUGS }

View File

@ -35,6 +35,20 @@ export function getAuthentikBase(): string {
/** Authentik enrollment flow (same origin as the suite — nginx /auth/). */ /** Authentik enrollment flow (same origin as the suite — nginx /auth/). */
const AUTHENTIK_ENROLLMENT_PATH = "/auth/if/flow/ulti-enrollment/" const AUTHENTIK_ENROLLMENT_PATH = "/auth/if/flow/ulti-enrollment/"
/** In-app signup page (Phase 2 custom UI via ultid BFF). */
export const SIGNUP_PATH = "/signup"
/** In-app forgot-password page (Phase 2 custom UI via ultid BFF). */
export const FORGOT_PASSWORD_PATH = "/forgot-password"
export function getSignupUrl(): string {
return SIGNUP_PATH
}
export function getForgotPasswordUrl(): string {
return FORGOT_PASSWORD_PATH
}
export function getAuthentikEnrollmentUrl(): string { export function getAuthentikEnrollmentUrl(): string {
if (useNativeRuntime()) { if (useNativeRuntime()) {
const cfg = getRuntimeConfig() const cfg = getRuntimeConfig()

View File

@ -1,4 +1,4 @@
const AUTH_PUBLIC_PREFIXES = ["/login", "/auth/", "/api/auth/"] const AUTH_PUBLIC_PREFIXES = ["/login", "/signup", "/forgot-password", "/auth/", "/api/auth/"]
/** Routes without session enforcement (login, public shares, interactive demos). */ /** Routes without session enforcement (login, public shares, interactive demos). */
export function isAuthPublicPath(pathname: string): boolean { export function isAuthPublicPath(pathname: string): boolean {