From 359931c2f37a282396c2b2fd7edd9cb7b53488cd Mon Sep 17 00:00:00 2001
From: R3D347HR4Y
Date: Fri, 19 Jun 2026 22:34:23 +0200
Subject: [PATCH] feat: implement forgot password and signup flows with new
layouts and components
- 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.
---
app/forgot-password/layout.tsx | 16 +
app/forgot-password/page.tsx | 5 +
app/globals.css | 41 +-
app/login/page.tsx | 5 +-
app/signup/layout.tsx | 16 +
app/signup/page.tsx | 19 +
components/auth/auth-card.tsx | 80 ++++
components/auth/auth-flow-page.tsx | 159 +++++++
components/auth/flow-challenge-form.tsx | 395 ++++++++++++++++++
.../auth/forgot-password-page-content.tsx | 26 ++
components/auth/login-form.tsx | 102 ++---
components/auth/signup-page-content.tsx | 35 ++
components/landing/landing-header.tsx | 4 +-
components/landing/landing-hero.tsx | 4 +-
components/landing/landing-sections.tsx | 4 +-
.../landing/product/product-page-shell.tsx | 4 +-
lib/auth/auth-flow-slugs.ts | 7 +
lib/auth/authentik-user-url.ts | 1 +
lib/auth/flow-api.ts | 142 +++++++
lib/auth/oidc-config.ts | 14 +
lib/auth/public-paths.ts | 2 +-
21 files changed, 995 insertions(+), 86 deletions(-)
create mode 100644 app/forgot-password/layout.tsx
create mode 100644 app/forgot-password/page.tsx
create mode 100644 app/signup/layout.tsx
create mode 100644 app/signup/page.tsx
create mode 100644 components/auth/auth-card.tsx
create mode 100644 components/auth/auth-flow-page.tsx
create mode 100644 components/auth/flow-challenge-form.tsx
create mode 100644 components/auth/forgot-password-page-content.tsx
create mode 100644 components/auth/signup-page-content.tsx
create mode 100644 lib/auth/auth-flow-slugs.ts
create mode 100644 lib/auth/flow-api.ts
diff --git a/app/forgot-password/layout.tsx b/app/forgot-password/layout.tsx
new file mode 100644
index 0000000..8fa7db0
--- /dev/null
+++ b/app/forgot-password/layout.tsx
@@ -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 {children}
+}
diff --git a/app/forgot-password/page.tsx b/app/forgot-password/page.tsx
new file mode 100644
index 0000000..97f63b3
--- /dev/null
+++ b/app/forgot-password/page.tsx
@@ -0,0 +1,5 @@
+import { ForgotPasswordPageContent } from "@/components/auth/forgot-password-page-content"
+
+export default function ForgotPasswordPage() {
+ return
+}
diff --git a/app/globals.css b/app/globals.css
index cb8a91d..06cbc06 100644
--- a/app/globals.css
+++ b/app/globals.css
@@ -701,6 +701,20 @@ html:has(.ultimail-login) body {
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'] {
position: relative;
isolation: isolate;
@@ -1021,7 +1035,8 @@ html[data-route-scope='drive'] body {
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;
z-index: 1;
isolation: isolate;
@@ -1061,12 +1076,14 @@ html[data-route-scope='drive'] body {
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;
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: '';
position: absolute;
inset: 0;
@@ -1084,7 +1101,8 @@ html[data-route-scope='drive'] body {
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: '';
position: absolute;
inset: 0;
@@ -1101,7 +1119,8 @@ html[data-route-scope='drive'] body {
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:
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),
@@ -1114,7 +1133,8 @@ html[data-route-scope='drive'] body {
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);
letter-spacing: 0.01em;
box-shadow:
@@ -1123,7 +1143,8 @@ html[data-route-scope='drive'] body {
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;
transform: scale(1.08);
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;
}
@@ -1145,7 +1167,8 @@ html[data-route-scope='drive'] body {
@media (prefers-reduced-motion: reduce) {
.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;
}
diff --git a/app/login/page.tsx b/app/login/page.tsx
index 3e0f80e..c2bdaff 100644
--- a/app/login/page.tsx
+++ b/app/login/page.tsx
@@ -3,7 +3,6 @@
import { useSearchParams } from "next/navigation"
import { Suspense } from "react"
import { LoginForm } from "@/components/auth/login-form"
-import { getAuthentikEnrollmentUrl } from "@/lib/auth/oidc-config"
import { useNativeRuntime } from "@/lib/platform"
import { NativeLogin } from "@/components/mobile/native-login"
@@ -12,7 +11,8 @@ function LoginContent() {
const error = searchParams.get("error")
const returnTo = searchParams.get("returnTo") ?? "/mail/inbox"
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()) {
return
@@ -22,6 +22,7 @@ function LoginContent() {
)
diff --git a/app/signup/layout.tsx b/app/signup/layout.tsx
new file mode 100644
index 0000000..31fd7cd
--- /dev/null
+++ b/app/signup/layout.tsx
@@ -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 {children}
+}
diff --git a/app/signup/page.tsx b/app/signup/page.tsx
new file mode 100644
index 0000000..723ddae
--- /dev/null
+++ b/app/signup/page.tsx
@@ -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
+}
+
+export default function SignupPage() {
+ return (
+
+
+
+ )
+}
diff --git a/components/auth/auth-card.tsx b/components/auth/auth-card.tsx
new file mode 100644
index 0000000..743fb2f
--- /dev/null
+++ b/components/auth/auth-card.tsx
@@ -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 (
+
+

+
+ UltiSuite
+
+
+ )
+}
+
+export function AuthCard({
+ title,
+ description,
+ error,
+ children,
+ footer,
+}: AuthCardProps) {
+ return (
+
+
+
+
+
+ {title ? (
+ {title}
+ ) : null}
+ {description ? (
+ {description}
+ ) : null}
+ {error ? (
+
+ {error}
+
+ ) : null}
+
+
+ {children}
+
+ {footer ? (
+ {footer}
+ ) : null}
+
+
+
+ )
+}
diff --git a/components/auth/auth-flow-page.tsx b/components/auth/auth-flow-page.tsx
new file mode 100644
index 0000000..9f0b829
--- /dev/null
+++ b/components/auth/auth-flow-page.tsx
@@ -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(null)
+ const [challenge, setChallenge] = useState(null)
+ const [loading, setLoading] = useState(true)
+ const [submitting, setSubmitting] = useState(false)
+ const [error, setError] = useState(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) {
+ 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 (
+
+ {loading ? (
+
+
+ Chargement…
+
+ ) : showSuccess ? (
+
+ {redirectUrl ? (
+
+ Redirection en cours…
+
+ ) : null}
+
+
+ ) : (
+
+ )}
+
+ )
+}
+
+export { AUTH_FLOW_SLUGS }
diff --git a/components/auth/flow-challenge-form.tsx b/components/auth/flow-challenge-form.tsx
new file mode 100644
index 0000000..6f1b64f
--- /dev/null
+++ b/components/auth/flow-challenge-form.tsx
@@ -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
+ onSubmit: (payload: Record) => void | Promise
+}
+
+export function FlowChallengeForm({
+ challenge,
+ component,
+ submitting,
+ fieldErrors = {},
+ onSubmit,
+}: FlowChallengeFormProps) {
+ const [values, setValues] = useState>({})
+ const initializedComponentRef = useRef(null)
+
+ const promptFields = useMemo(() => readPromptFields(challenge), [challenge])
+ const primaryAction = readPrimaryAction(challenge)
+
+ useEffect(() => {
+ if (initializedComponentRef.current === component) return
+ initializedComponentRef.current = component
+
+ const next: Record = {}
+ 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 = { 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 (
+
+ )
+}
+
+function PromptFields({
+ fields,
+ values,
+ fieldErrors,
+ onChange,
+}: {
+ fields: PromptField[]
+ values: Record
+ fieldErrors: Record
+ onChange: (key: string, value: string) => void
+}) {
+ return (
+
+ {fields.map((field) => {
+ if (field.type === "hidden") return null
+ if (field.type === "static") {
+ return (
+
+ {field.label}
+ {field.initial_value ? (
+
+ {" "}
+ {field.initial_value}
+
+ ) : null}
+
+ )
+ }
+
+ const inputType =
+ field.type === "password"
+ ? "password"
+ : field.type === "email"
+ ? "email"
+ : "text"
+
+ if (field.type === "file") {
+ return (
+
+
+ {
+ 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)
+ }}
+ />
+
+ )
+ }
+
+ return (
+
onChange(field.field_key, value)}
+ required={field.required}
+ autoComplete={autoCompleteFor(field.field_key, field.type)}
+ />
+ )
+ })}
+
+ )
+}
+
+function IdentificationFields({
+ challenge,
+ values,
+ fieldErrors,
+ onChange,
+}: {
+ challenge: FlowChallenge | null
+ values: Record
+ fieldErrors: Record
+ 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 (
+
+ onChange("uid_field", value)}
+ required
+ />
+ {withPassword ? (
+ onChange("password", value)}
+ required
+ />
+ ) : null}
+
+ )
+}
+
+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["inputMode"]
+}) {
+ return (
+
+
+
onChange(event.target.value)}
+ className="h-10 rounded-lg"
+ />
+ {error ? (
+
+ {error}
+
+ ) : null}
+
+ )
+}
+
+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)
+}
diff --git a/components/auth/forgot-password-page-content.tsx b/components/auth/forgot-password-page-content.tsx
new file mode 100644
index 0000000..bd20eeb
--- /dev/null
+++ b/components/auth/forgot-password-page-content.tsx
@@ -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 (
+
+
+ Retour à la connexion
+
+
+ }
+ />
+ )
+}
diff --git a/components/auth/login-form.tsx b/components/auth/login-form.tsx
index 77cc235..c5101c1 100644
--- a/components/auth/login-form.tsx
+++ b/components/auth/login-form.tsx
@@ -1,80 +1,50 @@
"use client"
+import Link from "next/link"
import { Sparkles } from "lucide-react"
-import {
- 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"
-)
+import { AuthCard } from "@/components/auth/auth-card"
type LoginFormProps = {
loginHref: string
signupHref: string
+ forgotPasswordHref: string
error: string | null
}
-export function LoginForm({ loginHref, signupHref, error }: LoginFormProps) {
+export function LoginForm({
+ loginHref,
+ signupHref,
+ forgotPasswordHref,
+ error,
+}: LoginFormProps) {
return (
-
-
-
-
-
-

-
- UltiSuite
-
-
-
- Connecte-toi avec ton compte UltiSpace pour accéder à ta suite.
-
- {error ? (
-
- {decodeURIComponent(error)}
-
- ) : null}
-
-
-
-
-
-
-
-
- Pas encore de compte ?{" "}
-
- Créer un compte
-
-
-
-
+
+
+ Pas encore de compte ?{" "}
+
+ Créer un compte
+
+
+
+
+ Mot de passe oublié ?
+
+
+
+ }
+ >
+
-
+
)
}
diff --git a/components/auth/signup-page-content.tsx b/components/auth/signup-page-content.tsx
new file mode 100644
index 0000000..3b37849
--- /dev/null
+++ b/components/auth/signup-page-content.tsx
@@ -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 (
+
+ Déjà un compte ?{" "}
+
+ Se connecter
+
+
+ }
+ />
+ )
+}
diff --git a/components/landing/landing-header.tsx b/components/landing/landing-header.tsx
index 271df10..56a35b8 100644
--- a/components/landing/landing-header.tsx
+++ b/components/landing/landing-header.tsx
@@ -8,7 +8,7 @@ import { AccountSwitcherDropdown } from "@/components/suite/account-switcher-dro
import { SuiteFavoritesMenu } from "@/components/suite/suite-favorites-menu"
import { Button } from "@/components/ui/button"
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"
const NAV_LINKS = [
@@ -104,7 +104,7 @@ export function LandingHeader({ scrolled }: { scrolled: boolean }) {
) : (
<>
Créer un compte
diff --git a/components/landing/landing-hero.tsx b/components/landing/landing-hero.tsx
index eb768a8..161ca42 100644
--- a/components/landing/landing-hero.tsx
+++ b/components/landing/landing-hero.tsx
@@ -5,7 +5,7 @@ import { Icon } from "@iconify/react"
import { LandingReveal } from "@/components/landing/landing-reveal"
import { LANDING_APPS, LANDING_APP_DEMO_TAB } from "@/components/landing/landing-data"
import { useChromeIdentity } from "@/lib/hooks/use-chrome-identity"
-import { getAuthentikEnrollmentUrl } from "@/lib/auth/oidc-config"
+import { getSignupUrl } from "@/lib/auth/oidc-config"
function HeroDock({
authenticated,
@@ -127,7 +127,7 @@ export function LandingHero({ onOpenDemo }: { onOpenDemo: (demoTabId: string | n
Créer un compte
diff --git a/components/landing/landing-sections.tsx b/components/landing/landing-sections.tsx
index cd2af7b..968c337 100644
--- a/components/landing/landing-sections.tsx
+++ b/components/landing/landing-sections.tsx
@@ -11,7 +11,7 @@ import {
landingAppGlowVars,
} from "@/components/landing/landing-data"
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"
function SectionHeading({
@@ -327,7 +327,7 @@ export function LandingFooter() {
) : (
<>
Créer un compte
diff --git a/components/landing/product/product-page-shell.tsx b/components/landing/product/product-page-shell.tsx
index 8ac88e5..ef10916 100644
--- a/components/landing/product/product-page-shell.tsx
+++ b/components/landing/product/product-page-shell.tsx
@@ -13,7 +13,7 @@ import { ProductHighlights } from "@/components/landing/product/product-highligh
import { ProductIntegrations } from "@/components/landing/product/product-integrations"
import type { ProductPageData } from "@/components/landing/product/product-data"
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"
function ProductHeader({
@@ -82,7 +82,7 @@ function ProductHeader({
) : (
<>
Créer un compte
diff --git a/lib/auth/auth-flow-slugs.ts b/lib/auth/auth-flow-slugs.ts
new file mode 100644
index 0000000..f267d1b
--- /dev/null
+++ b/lib/auth/auth-flow-slugs.ts
@@ -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]
diff --git a/lib/auth/authentik-user-url.ts b/lib/auth/authentik-user-url.ts
index b9da3cb..2246462 100644
--- a/lib/auth/authentik-user-url.ts
+++ b/lib/auth/authentik-user-url.ts
@@ -22,6 +22,7 @@ const TAB_PAGE: Record = {
/** Flows Authentik par défaut pour self-service (modifiables côté admin). */
export const AUTHENTIK_SELF_SERVICE_FLOWS = {
passwordChange: "default-password-change",
+ recovery: "ulti-recovery",
totpSetup: "default-authenticator-totp-setup",
webauthnSetup: "default-authenticator-webauthn-setup",
staticSetup: "default-authenticator-static-setup",
diff --git a/lib/auth/flow-api.ts b/lib/auth/flow-api.ts
new file mode 100644
index 0000000..4ef3af8
--- /dev/null
+++ b/lib/auth/flow-api.ts
@@ -0,0 +1,142 @@
+import { AUTH_FLOW_SLUGS, type AuthFlowSlug } from "@/lib/auth/auth-flow-slugs"
+
+export type FlowChallenge = Record
+
+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 {
+ 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 {
+ 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
+): Promise {
+ 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
+ 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).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 {
+ const out: Record = {}
+ const errors = challenge?.response_errors
+ if (!errors || typeof errors !== "object") return out
+
+ for (const [key, value] of Object.entries(errors as Record)) {
+ 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
+ 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).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 }
diff --git a/lib/auth/oidc-config.ts b/lib/auth/oidc-config.ts
index 52dd0e4..e774568 100644
--- a/lib/auth/oidc-config.ts
+++ b/lib/auth/oidc-config.ts
@@ -35,6 +35,20 @@ export function getAuthentikBase(): string {
/** Authentik enrollment flow (same origin as the suite — nginx /auth/). */
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 {
if (useNativeRuntime()) {
const cfg = getRuntimeConfig()
diff --git a/lib/auth/public-paths.ts b/lib/auth/public-paths.ts
index 23be0f7..89a7345 100644
--- a/lib/auth/public-paths.ts
+++ b/lib/auth/public-paths.ts
@@ -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). */
export function isAuthPublicPath(pathname: string): boolean {