feat: implement forgot password and signup flows with new layouts and components
Some checks are pending
E2E / Playwright e2e (push) Waiting to run
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:
parent
b61e1a1441
commit
359931c2f3
16
app/forgot-password/layout.tsx
Normal file
16
app/forgot-password/layout.tsx
Normal 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>
|
||||
}
|
||||
5
app/forgot-password/page.tsx
Normal file
5
app/forgot-password/page.tsx
Normal file
@ -0,0 +1,5 @@
|
||||
import { ForgotPasswordPageContent } from "@/components/auth/forgot-password-page-content"
|
||||
|
||||
export default function ForgotPasswordPage() {
|
||||
return <ForgotPasswordPageContent />
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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 <NativeLogin returnTo={returnTo} />
|
||||
@ -22,6 +22,7 @@ function LoginContent() {
|
||||
<LoginForm
|
||||
loginHref={loginHref}
|
||||
signupHref={signupHref}
|
||||
forgotPasswordHref={forgotPasswordHref}
|
||||
error={error}
|
||||
/>
|
||||
)
|
||||
|
||||
16
app/signup/layout.tsx
Normal file
16
app/signup/layout.tsx
Normal 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
19
app/signup/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
80
components/auth/auth-card.tsx
Normal file
80
components/auth/auth-card.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
159
components/auth/auth-flow-page.tsx
Normal file
159
components/auth/auth-flow-page.tsx
Normal 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 }
|
||||
395
components/auth/flow-challenge-form.tsx
Normal file
395
components/auth/flow-challenge-form.tsx
Normal 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)
|
||||
}
|
||||
26
components/auth/forgot-password-page-content.tsx
Normal file
26
components/auth/forgot-password-page-content.tsx
Normal 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>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@ -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 (
|
||||
<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={LOGIN_CARD_CLASS}>
|
||||
<CardHeader className="gap-4 px-0 text-center sm:px-0">
|
||||
<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>
|
||||
<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>
|
||||
) : null}
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="flex justify-center px-0 sm:px-0">
|
||||
<div className="ultimail-login-connect-border">
|
||||
<a href={loginHref} className="ultimail-login-connect-btn">
|
||||
<Sparkles className="size-4 shrink-0" strokeWidth={2} aria-hidden />
|
||||
Se connecter avec UltiSpace
|
||||
</a>
|
||||
</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>
|
||||
<AuthCard
|
||||
description="Connecte-toi avec ton compte UltiSpace pour accéder à ta suite."
|
||||
error={error ? decodeURIComponent(error) : null}
|
||||
footer={
|
||||
<div className="flex w-full flex-col gap-3 text-center text-sm text-muted-foreground">
|
||||
<p>
|
||||
Pas encore de compte ?{" "}
|
||||
<Link className="font-medium text-primary underline" href={signupHref}>
|
||||
Créer un compte
|
||||
</Link>
|
||||
</p>
|
||||
<p>
|
||||
<Link className="font-medium text-primary underline" href={forgotPasswordHref}>
|
||||
Mot de passe oublié ?
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="flex justify-center">
|
||||
<div className="ultimail-login-connect-border w-full">
|
||||
<a href={loginHref} className="ultimail-login-connect-btn">
|
||||
<Sparkles className="size-4 shrink-0" strokeWidth={2} aria-hidden />
|
||||
Se connecter avec UltiSpace
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AuthCard>
|
||||
)
|
||||
}
|
||||
|
||||
35
components/auth/signup-page-content.tsx
Normal file
35
components/auth/signup-page-content.tsx
Normal 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>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@ -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 }) {
|
||||
) : (
|
||||
<>
|
||||
<a
|
||||
href={getAuthentikEnrollmentUrl()}
|
||||
href={getSignupUrl()}
|
||||
className="landing-cta landing-cta--ghost hidden h-9 px-4 text-sm sm:inline-flex"
|
||||
>
|
||||
Créer un compte
|
||||
|
||||
@ -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
|
||||
<Icon icon="mdi:arrow-right" className="size-5" aria-hidden />
|
||||
</Link>
|
||||
<a
|
||||
href={getAuthentikEnrollmentUrl()}
|
||||
href={getSignupUrl()}
|
||||
className="landing-cta landing-cta--ghost h-12 px-7 text-base"
|
||||
>
|
||||
Créer un compte
|
||||
|
||||
@ -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() {
|
||||
) : (
|
||||
<>
|
||||
<a
|
||||
href={getAuthentikEnrollmentUrl()}
|
||||
href={getSignupUrl()}
|
||||
className="landing-cta landing-cta--primary h-12 px-7 text-base"
|
||||
>
|
||||
Créer un compte
|
||||
|
||||
@ -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({
|
||||
) : (
|
||||
<>
|
||||
<a
|
||||
href={getAuthentikEnrollmentUrl()}
|
||||
href={getSignupUrl()}
|
||||
className="landing-cta landing-cta--ghost hidden h-9 px-4 text-sm sm:inline-flex"
|
||||
>
|
||||
Créer un compte
|
||||
|
||||
7
lib/auth/auth-flow-slugs.ts
Normal file
7
lib/auth/auth-flow-slugs.ts
Normal 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]
|
||||
@ -22,6 +22,7 @@ const TAB_PAGE: Record<AuthentikUserSettingsTab, string> = {
|
||||
/** 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",
|
||||
|
||||
142
lib/auth/flow-api.ts
Normal file
142
lib/auth/flow-api.ts
Normal 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 }
|
||||
@ -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()
|
||||
|
||||
@ -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 {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user