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} +
+ {successExternal ? ( + + {successActionLabel} + + ) : ( + + {successActionLabel} + + )} +
+
+ ) : ( + + )} +
+ ) +} + +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 ( +
+ {component === "ak-stage-prompt" ? ( + + setValues((prev) => ({ ...prev, [key]: value })) + } + /> + ) : null} + + {component === "ak-stage-identification" ? ( + + setValues((prev) => ({ ...prev, [key]: value })) + } + /> + ) : null} + + {component === "ak-stage-email" ? ( + setValues((prev) => ({ ...prev, email: value }))} + required + /> + ) : null} + + {component === "ak-stage-authenticator-validate" ? ( + setValues((prev) => ({ ...prev, code: value }))} + required + /> + ) : null} + + {!isKnownComponent(component) ? ( +

+ Étape non prise en charge ({component}). Utilisez le portail Authentik. +

+ ) : null} + + {isKnownComponent(component) ? ( +
+ +
+ ) : null} + + ) +} + +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 {