feat: enhance authentication and password reset flows with new components and layout
Some checks are pending
E2E / Playwright e2e (push) Waiting to run
Some checks are pending
E2E / Playwright e2e (push) Waiting to run
- Replaced LoginForm with LoginPageContent for improved login handling and user experience. - Introduced ResetPasswordPage and ResetPasswordLayout components to facilitate password reset functionality. - Added new flow stages for authentication, including PasswordStage and SourceOAuthStage, to streamline user interactions. - Updated FlowChallengeForm to integrate new stages and improve error handling during authentication processes. - Refactored existing components to support the new authentication flow structure, enhancing maintainability and user experience.
This commit is contained in:
parent
496b1dfc1f
commit
de5b5a60ef
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useSearchParams } from "next/navigation"
|
import { useSearchParams } from "next/navigation"
|
||||||
import { Suspense } from "react"
|
import { Suspense } from "react"
|
||||||
import { LoginForm } from "@/components/auth/login-form"
|
import { LoginPageContent } from "@/components/auth/login-page-content"
|
||||||
import { useNativeRuntime } from "@/lib/platform"
|
import { useNativeRuntime } from "@/lib/platform"
|
||||||
import { NativeLogin } from "@/components/mobile/native-login"
|
import { NativeLogin } from "@/components/mobile/native-login"
|
||||||
|
|
||||||
@ -10,22 +10,12 @@ function LoginContent() {
|
|||||||
const searchParams = useSearchParams()
|
const searchParams = useSearchParams()
|
||||||
const error = searchParams.get("error")
|
const error = searchParams.get("error")
|
||||||
const returnTo = searchParams.get("returnTo") ?? "/mail/inbox"
|
const returnTo = searchParams.get("returnTo") ?? "/mail/inbox"
|
||||||
const loginHref = `/api/auth/login?returnTo=${encodeURIComponent(returnTo)}`
|
|
||||||
const signupHref = `/signup?returnTo=${encodeURIComponent(returnTo)}`
|
|
||||||
const forgotPasswordHref = `/forgot-password?returnTo=${encodeURIComponent(returnTo)}`
|
|
||||||
|
|
||||||
if (useNativeRuntime()) {
|
if (useNativeRuntime()) {
|
||||||
return <NativeLogin returnTo={returnTo} />
|
return <NativeLogin returnTo={returnTo} />
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return <LoginPageContent returnTo={returnTo} error={error} />
|
||||||
<LoginForm
|
|
||||||
loginHref={loginHref}
|
|
||||||
signupHref={signupHref}
|
|
||||||
forgotPasswordHref={forgotPasswordHref}
|
|
||||||
error={error}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
|
|||||||
16
app/reset-password/layout.tsx
Normal file
16
app/reset-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: "Réinitialiser le mot de passe",
|
||||||
|
})
|
||||||
|
|
||||||
|
export default function ResetPasswordLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
return <LoginChrome>{children}</LoginChrome>
|
||||||
|
}
|
||||||
20
app/reset-password/page.tsx
Normal file
20
app/reset-password/page.tsx
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useSearchParams } from "next/navigation"
|
||||||
|
import { Suspense } from "react"
|
||||||
|
import { ResetPasswordPageContent } from "@/components/auth/reset-password-page-content"
|
||||||
|
|
||||||
|
function ResetPasswordContent() {
|
||||||
|
const searchParams = useSearchParams()
|
||||||
|
const flowQuery = searchParams.toString()
|
||||||
|
|
||||||
|
return <ResetPasswordPageContent flowQuery={flowQuery || undefined} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ResetPasswordPage() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<ResetPasswordContent />
|
||||||
|
</Suspense>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -1,12 +1,17 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||||
import { Loader2 } from "lucide-react"
|
import { Loader2 } from "lucide-react"
|
||||||
import { AuthCard } from "@/components/auth/auth-card"
|
import { AuthCard } from "@/components/auth/auth-card"
|
||||||
import { AuthConnectButton } from "@/components/auth/auth-connect-button"
|
import { AuthConnectButton } from "@/components/auth/auth-connect-button"
|
||||||
import { FlowChallengeForm } from "@/components/auth/flow-challenge-form"
|
import { FlowChallengeForm } from "@/components/auth/flow-challenge-form"
|
||||||
import {
|
import {
|
||||||
AUTH_FLOW_SLUGS,
|
AUTH_FLOW_SLUGS,
|
||||||
|
isAuthenticationFlow,
|
||||||
|
type AuthFlowSlug,
|
||||||
|
} from "@/lib/auth/auth-flow-slugs"
|
||||||
|
import {
|
||||||
|
completeAuthFlow,
|
||||||
flowComponent,
|
flowComponent,
|
||||||
flowRedirectUrl,
|
flowRedirectUrl,
|
||||||
flowTitle,
|
flowTitle,
|
||||||
@ -14,12 +19,12 @@ import {
|
|||||||
isRecoveryEmailSent,
|
isRecoveryEmailSent,
|
||||||
respondAuthFlow,
|
respondAuthFlow,
|
||||||
startAuthFlow,
|
startAuthFlow,
|
||||||
type AuthFlowSlug,
|
|
||||||
type FlowChallenge,
|
type FlowChallenge,
|
||||||
} from "@/lib/auth/flow-api"
|
} from "@/lib/auth/flow-api"
|
||||||
|
|
||||||
type AuthFlowPageProps = {
|
type AuthFlowPageProps = {
|
||||||
slug: AuthFlowSlug
|
slug: AuthFlowSlug
|
||||||
|
flowQuery?: string
|
||||||
defaultTitle: string
|
defaultTitle: string
|
||||||
defaultDescription: string
|
defaultDescription: string
|
||||||
successTitle: string
|
successTitle: string
|
||||||
@ -28,12 +33,26 @@ type AuthFlowPageProps = {
|
|||||||
successHref: string
|
successHref: string
|
||||||
/** Full document navigation required (OIDC login routes). */
|
/** Full document navigation required (OIDC login routes). */
|
||||||
successExternal?: boolean
|
successExternal?: boolean
|
||||||
|
/** After embedded auth flow, bridge to OIDC session via BFF /flows/complete. */
|
||||||
|
bridgeAuthentication?: boolean
|
||||||
|
returnTo?: string
|
||||||
|
initialError?: string | null
|
||||||
footer: React.ReactNode
|
footer: React.ReactNode
|
||||||
onSuccess?: () => void
|
onSuccess?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function decodeAuthError(value: string | null | undefined): string | null {
|
||||||
|
if (!value) return null
|
||||||
|
try {
|
||||||
|
return decodeURIComponent(value)
|
||||||
|
} catch {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function AuthFlowPage({
|
export function AuthFlowPage({
|
||||||
slug,
|
slug,
|
||||||
|
flowQuery,
|
||||||
defaultTitle,
|
defaultTitle,
|
||||||
defaultDescription,
|
defaultDescription,
|
||||||
successTitle,
|
successTitle,
|
||||||
@ -41,6 +60,9 @@ export function AuthFlowPage({
|
|||||||
successActionLabel,
|
successActionLabel,
|
||||||
successHref,
|
successHref,
|
||||||
successExternal = false,
|
successExternal = false,
|
||||||
|
bridgeAuthentication = false,
|
||||||
|
returnTo = "/mail/inbox",
|
||||||
|
initialError = null,
|
||||||
footer,
|
footer,
|
||||||
onSuccess,
|
onSuccess,
|
||||||
}: AuthFlowPageProps) {
|
}: AuthFlowPageProps) {
|
||||||
@ -48,35 +70,92 @@ export function AuthFlowPage({
|
|||||||
const [challenge, setChallenge] = useState<FlowChallenge | null>(null)
|
const [challenge, setChallenge] = useState<FlowChallenge | null>(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [submitting, setSubmitting] = useState(false)
|
const [submitting, setSubmitting] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [bridging, setBridging] = useState(false)
|
||||||
|
const [flowError, setFlowError] = useState<string | null>(null)
|
||||||
const [done, setDone] = useState(false)
|
const [done, setDone] = useState(false)
|
||||||
const [denied, setDenied] = useState(false)
|
const [denied, setDenied] = useState(false)
|
||||||
|
const bridgedRef = useRef(false)
|
||||||
|
const redirectError = decodeAuthError(initialError)
|
||||||
|
|
||||||
|
const finishAuthentication = useCallback(async () => {
|
||||||
|
if (!bridgeAuthentication || !isAuthenticationFlow(slug) || bridgedRef.current) {
|
||||||
|
onSuccess?.()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
bridgedRef.current = true
|
||||||
|
setBridging(true)
|
||||||
|
setFlowError(null)
|
||||||
|
try {
|
||||||
|
const { redirectUrl } = await completeAuthFlow(returnTo)
|
||||||
|
window.location.href = redirectUrl
|
||||||
|
} catch (err) {
|
||||||
|
bridgedRef.current = false
|
||||||
|
setBridging(false)
|
||||||
|
setFlowError(
|
||||||
|
err instanceof Error ? err.message : "Impossible de finaliser la connexion"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}, [bridgeAuthentication, onSuccess, returnTo, slug])
|
||||||
|
|
||||||
|
const redirectToOidcLogin = useCallback(() => {
|
||||||
|
const params = new URLSearchParams({ returnTo })
|
||||||
|
window.location.assign(`/api/auth/login?${params.toString()}`)
|
||||||
|
}, [returnTo])
|
||||||
|
|
||||||
const bootstrap = useCallback(async () => {
|
const bootstrap = useCallback(async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setError(null)
|
|
||||||
try {
|
try {
|
||||||
const step = await startAuthFlow(slug)
|
const step = await startAuthFlow(slug, flowQuery)
|
||||||
setSessionId(step.sessionId)
|
setSessionId(step.sessionId)
|
||||||
setChallenge(step.challenge)
|
setChallenge(step.challenge)
|
||||||
setDone(step.done)
|
setDone(step.done)
|
||||||
setDenied(step.denied)
|
setDenied(step.denied)
|
||||||
if (step.done && !step.denied) {
|
if (step.done && !step.denied) {
|
||||||
|
if (bridgeAuthentication) {
|
||||||
|
// Start returned immediate redirect — no flow session cookie for /complete.
|
||||||
|
redirectToOidcLogin()
|
||||||
|
return
|
||||||
|
}
|
||||||
onSuccess?.()
|
onSuccess?.()
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "Impossible de démarrer le parcours")
|
setFlowError(
|
||||||
|
err instanceof Error ? err.message : "Impossible de démarrer le parcours"
|
||||||
|
)
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}, [onSuccess, slug])
|
}, [bridgeAuthentication, flowQuery, onSuccess, redirectToOidcLogin, slug])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void bootstrap()
|
void bootstrap()
|
||||||
}, [bootstrap])
|
}, [bootstrap])
|
||||||
|
|
||||||
|
const handleSubmit = useCallback(
|
||||||
|
async (payload: Record<string, unknown>) => {
|
||||||
|
if (!sessionId) return
|
||||||
|
setSubmitting(true)
|
||||||
|
setFlowError(null)
|
||||||
|
try {
|
||||||
|
const step = await respondAuthFlow(slug, payload, flowQuery)
|
||||||
|
setSessionId(step.sessionId)
|
||||||
|
setChallenge(step.challenge)
|
||||||
|
setDone(step.done)
|
||||||
|
setDenied(step.denied)
|
||||||
|
if (step.done && !step.denied) {
|
||||||
|
await finishAuthentication()
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setFlowError(err instanceof Error ? err.message : "Échec de l'étape")
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[finishAuthentication, flowQuery, sessionId, slug]
|
||||||
|
)
|
||||||
|
|
||||||
const validationErrors = useMemo(() => flowValidationErrors(challenge), [challenge])
|
const validationErrors = useMemo(() => flowValidationErrors(challenge), [challenge])
|
||||||
const formError = error ?? validationErrors._form ?? null
|
const formError = flowError ?? redirectError ?? validationErrors._form ?? null
|
||||||
|
|
||||||
const title = useMemo(() => {
|
const title = useMemo(() => {
|
||||||
if ((done && !denied) || isRecoveryEmailSent(slug, challenge)) return successTitle
|
if ((done && !denied) || isRecoveryEmailSent(slug, challenge)) return successTitle
|
||||||
@ -92,37 +171,18 @@ export function AuthFlowPage({
|
|||||||
return defaultDescription
|
return defaultDescription
|
||||||
}, [challenge, defaultDescription, denied, done, slug, successDescription])
|
}, [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 component = flowComponent(challenge)
|
||||||
const redirectUrl = flowRedirectUrl(challenge)
|
const redirectUrl = flowRedirectUrl(challenge)
|
||||||
const recoveryEmailSent = isRecoveryEmailSent(slug, challenge)
|
const recoveryEmailSent = isRecoveryEmailSent(slug, challenge)
|
||||||
const showSuccess = (done && !denied) || recoveryEmailSent
|
const showSuccess =
|
||||||
|
(done && !denied && !bridgeAuthentication) || recoveryEmailSent
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthCard title={title} description={description} error={formError} footer={footer}>
|
<AuthCard title={title} description={description} error={formError} footer={footer}>
|
||||||
{loading ? (
|
{loading || bridging ? (
|
||||||
<div className="flex justify-center py-8">
|
<div className="flex justify-center py-8">
|
||||||
<Loader2 className="size-6 animate-spin text-muted-foreground" aria-hidden />
|
<Loader2 className="size-6 animate-spin text-muted-foreground" aria-hidden />
|
||||||
<span className="sr-only">Chargement…</span>
|
<span className="sr-only">{bridging ? "Finalisation…" : "Chargement…"}</span>
|
||||||
</div>
|
</div>
|
||||||
) : showSuccess ? (
|
) : showSuccess ? (
|
||||||
<div className="flex flex-col items-center gap-4">
|
<div className="flex flex-col items-center gap-4">
|
||||||
|
|||||||
@ -13,6 +13,26 @@ import { AUTH_FLOW_SLUGS } from "@/lib/auth/auth-flow-slugs"
|
|||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label"
|
||||||
import type { FlowChallenge } from "@/lib/auth/flow-api"
|
import type { FlowChallenge } from "@/lib/auth/flow-api"
|
||||||
|
import { flowRedirectUrl } from "@/lib/auth/flow-api"
|
||||||
|
import {
|
||||||
|
FLOW_STAGE_REGISTRY,
|
||||||
|
isKnownFlowComponent,
|
||||||
|
readPrimaryAction,
|
||||||
|
} from "@/components/auth/flow-stage-registry"
|
||||||
|
import { PasswordStage, UserLoginStage } from "@/components/auth/flow-stages/password-stage"
|
||||||
|
import { WebAuthnStage } from "@/components/auth/flow-stages/webauthn-stage"
|
||||||
|
import { SourceOAuthStage } from "@/components/auth/flow-stages/source-oauth-stage"
|
||||||
|
import {
|
||||||
|
AccessDeniedStage,
|
||||||
|
FlowRedirectStage,
|
||||||
|
} from "@/components/auth/flow-stages/access-denied-stage"
|
||||||
|
|
||||||
|
FLOW_STAGE_REGISTRY["ak-stage-password"] = PasswordStage
|
||||||
|
FLOW_STAGE_REGISTRY["ak-stage-user-login"] = UserLoginStage
|
||||||
|
FLOW_STAGE_REGISTRY["ak-stage-authenticator-webauthn"] = WebAuthnStage
|
||||||
|
FLOW_STAGE_REGISTRY["ak-stage-source"] = SourceOAuthStage
|
||||||
|
FLOW_STAGE_REGISTRY["ak-stage-access-denied"] = AccessDeniedStage
|
||||||
|
FLOW_STAGE_REGISTRY["xak-flow-redirect"] = FlowRedirectStage
|
||||||
|
|
||||||
type PromptField = {
|
type PromptField = {
|
||||||
field_key: string
|
field_key: string
|
||||||
@ -51,6 +71,7 @@ export function FlowChallengeForm({
|
|||||||
flowSlug === AUTH_FLOW_SLUGS.enrollment &&
|
flowSlug === AUTH_FLOW_SLUGS.enrollment &&
|
||||||
component === "ak-stage-prompt" &&
|
component === "ak-stage-prompt" &&
|
||||||
isSignupCredentialsStep(promptFields)
|
isSignupCredentialsStep(promptFields)
|
||||||
|
const RegistryStage = FLOW_STAGE_REGISTRY[component]
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!signupCredentials) {
|
if (!signupCredentials) {
|
||||||
@ -78,6 +99,12 @@ export function FlowChallengeForm({
|
|||||||
if (component === "ak-stage-email") {
|
if (component === "ak-stage-email") {
|
||||||
next.email = ""
|
next.email = ""
|
||||||
}
|
}
|
||||||
|
if (component === "ak-stage-authenticator-validate") {
|
||||||
|
next.code = ""
|
||||||
|
}
|
||||||
|
if (component === "ak-stage-password") {
|
||||||
|
next.password = ""
|
||||||
|
}
|
||||||
setValues(next)
|
setValues(next)
|
||||||
}, [challenge, component, promptFields])
|
}, [challenge, component, promptFields])
|
||||||
|
|
||||||
@ -108,17 +135,66 @@ export function FlowChallengeForm({
|
|||||||
payload.email = values.email ?? ""
|
payload.email = values.email ?? ""
|
||||||
} else if (component === "ak-stage-authenticator-validate") {
|
} else if (component === "ak-stage-authenticator-validate") {
|
||||||
payload.code = values.code ?? ""
|
payload.code = values.code ?? ""
|
||||||
} else {
|
} else if (component === "ak-stage-password") {
|
||||||
|
payload.password = values.password ?? ""
|
||||||
|
} else if (!RegistryStage) {
|
||||||
Object.assign(payload, values)
|
Object.assign(payload, values)
|
||||||
}
|
}
|
||||||
|
|
||||||
await onSubmit(payload)
|
await onSubmit(payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!component || component === "ak-stage-access-denied") {
|
if (!component) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const autoSubmitStage =
|
||||||
|
component === "ak-stage-user-login" ||
|
||||||
|
component === "ak-stage-authenticator-webauthn" ||
|
||||||
|
component === "xak-flow-redirect" ||
|
||||||
|
(component === "ak-stage-source" && Boolean(flowRedirectUrl(challenge)))
|
||||||
|
|
||||||
|
if (component === "ak-stage-access-denied") {
|
||||||
|
return <AccessDeniedStage />
|
||||||
|
}
|
||||||
|
|
||||||
|
if (RegistryStage) {
|
||||||
|
if (component === "ak-stage-password") {
|
||||||
|
return (
|
||||||
|
<form className="space-y-4" autoComplete="on" onSubmit={handleSubmit}>
|
||||||
|
<RegistryStage
|
||||||
|
challenge={challenge}
|
||||||
|
values={values}
|
||||||
|
fieldErrors={fieldErrors}
|
||||||
|
submitting={submitting}
|
||||||
|
onChange={(key, value) => setValues((prev) => ({ ...prev, [key]: value }))}
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
/>
|
||||||
|
<AuthConnectButton type="submit" disabled={submitting}>
|
||||||
|
{submitting ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="size-4 animate-spin" aria-hidden />
|
||||||
|
<span>Patientez…</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
primaryAction
|
||||||
|
)}
|
||||||
|
</AuthConnectButton>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<RegistryStage
|
||||||
|
challenge={challenge}
|
||||||
|
values={values}
|
||||||
|
fieldErrors={fieldErrors}
|
||||||
|
submitting={submitting}
|
||||||
|
onChange={(key, value) => setValues((prev) => ({ ...prev, [key]: value }))}
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form className="space-y-4" autoComplete="on" onSubmit={handleSubmit}>
|
<form className="space-y-4" autoComplete="on" onSubmit={handleSubmit}>
|
||||||
{component === "ak-stage-prompt" ? (
|
{component === "ak-stage-prompt" ? (
|
||||||
@ -181,13 +257,13 @@ export function FlowChallengeForm({
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{!isKnownComponent(component) ? (
|
{!isKnownFlowComponent(component) ? (
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Étape non prise en charge ({component}). Utilisez le portail Authentik.
|
Étape non prise en charge ({component}). Utilisez le portail Authentik.
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{isKnownComponent(component) ? (
|
{isKnownFlowComponent(component) && !autoSubmitStage ? (
|
||||||
<AuthConnectButton
|
<AuthConnectButton
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={submitting || (signupCredentials && !signupCanSubmit)}
|
disabled={submitting || (signupCredentials && !signupCanSubmit)}
|
||||||
@ -414,12 +490,6 @@ function isPromptField(value: unknown): value is PromptField {
|
|||||||
typeof (value as PromptField).field_key === "string"
|
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 {
|
function readPasswordFields(challenge: FlowChallenge | null): boolean {
|
||||||
return challenge?.password_fields === true
|
return challenge?.password_fields === true
|
||||||
}
|
}
|
||||||
@ -441,11 +511,3 @@ function autoCompleteFor(fieldKey: string, type?: string): string | undefined {
|
|||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
function isKnownComponent(component: string): boolean {
|
|
||||||
return [
|
|
||||||
"ak-stage-prompt",
|
|
||||||
"ak-stage-identification",
|
|
||||||
"ak-stage-email",
|
|
||||||
"ak-stage-authenticator-validate",
|
|
||||||
].includes(component)
|
|
||||||
}
|
|
||||||
|
|||||||
39
components/auth/flow-stage-registry.ts
Normal file
39
components/auth/flow-stage-registry.ts
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import type { FlowChallenge } from "@/lib/auth/flow-api"
|
||||||
|
|
||||||
|
export type StageRendererProps = {
|
||||||
|
challenge: FlowChallenge | null
|
||||||
|
values: Record<string, string>
|
||||||
|
fieldErrors: Record<string, string>
|
||||||
|
submitting: boolean
|
||||||
|
onChange: (key: string, value: string) => void
|
||||||
|
onSubmit: (payload: Record<string, unknown>) => void | Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type StageRenderer = React.ComponentType<StageRendererProps>
|
||||||
|
|
||||||
|
export function readPrimaryAction(challenge: FlowChallenge | null): string {
|
||||||
|
const action = challenge?.primary_action
|
||||||
|
return typeof action === "string" && action.trim() ? action : "Continuer"
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isKnownFlowComponent(component: string): boolean {
|
||||||
|
return component in FLOW_STAGE_REGISTRY || LEGACY_KNOWN_COMPONENTS.has(component)
|
||||||
|
}
|
||||||
|
|
||||||
|
const LEGACY_KNOWN_COMPONENTS = new Set([
|
||||||
|
"ak-stage-prompt",
|
||||||
|
"ak-stage-identification",
|
||||||
|
"ak-stage-email",
|
||||||
|
"ak-stage-authenticator-validate",
|
||||||
|
"ak-stage-password",
|
||||||
|
"ak-stage-user-login",
|
||||||
|
"ak-stage-authenticator-webauthn",
|
||||||
|
"ak-stage-source",
|
||||||
|
"xak-flow-redirect",
|
||||||
|
"ak-stage-access-denied",
|
||||||
|
])
|
||||||
|
|
||||||
|
/** Registry for Phase 3 stages. */
|
||||||
|
export const FLOW_STAGE_REGISTRY: Record<string, StageRenderer> = {}
|
||||||
19
components/auth/flow-stages/access-denied-stage.tsx
Normal file
19
components/auth/flow-stages/access-denied-stage.tsx
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import type { StageRendererProps } from "@/components/auth/flow-stage-registry"
|
||||||
|
|
||||||
|
export function AccessDeniedStage() {
|
||||||
|
return (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Accès refusé. Vérifiez vos identifiants ou contactez le support.
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FlowRedirectStage({ challenge }: StageRendererProps) {
|
||||||
|
return (
|
||||||
|
<p className="text-center text-sm text-muted-foreground">
|
||||||
|
Finalisation de la connexion…
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
31
components/auth/flow-stages/password-stage.tsx
Normal file
31
components/auth/flow-stages/password-stage.tsx
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useRef } from "react"
|
||||||
|
import { PasswordFieldBlock } from "@/components/auth/password-field-block"
|
||||||
|
import type { StageRendererProps } from "@/components/auth/flow-stage-registry"
|
||||||
|
|
||||||
|
export function PasswordStage({ values, fieldErrors, onChange }: StageRendererProps) {
|
||||||
|
return (
|
||||||
|
<PasswordFieldBlock
|
||||||
|
id="password"
|
||||||
|
label="Mot de passe"
|
||||||
|
value={values.password ?? ""}
|
||||||
|
error={fieldErrors.password}
|
||||||
|
required
|
||||||
|
autoComplete="current-password"
|
||||||
|
onChange={(value) => onChange("password", value)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UserLoginStage({ submitting, onSubmit }: StageRendererProps) {
|
||||||
|
const submittedRef = useRef(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (submitting || submittedRef.current) return
|
||||||
|
submittedRef.current = true
|
||||||
|
void onSubmit({ component: "ak-stage-user-login" })
|
||||||
|
}, [onSubmit, submitting])
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
67
components/auth/flow-stages/source-oauth-stage.tsx
Normal file
67
components/auth/flow-stages/source-oauth-stage.tsx
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect } from "react"
|
||||||
|
import { AuthConnectButton } from "@/components/auth/auth-connect-button"
|
||||||
|
import type { StageRendererProps } from "@/components/auth/flow-stage-registry"
|
||||||
|
import { flowRedirectUrl } from "@/lib/auth/flow-api"
|
||||||
|
|
||||||
|
type FlowSource = {
|
||||||
|
pk?: string
|
||||||
|
name?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function readSources(challenge: StageRendererProps["challenge"]): FlowSource[] {
|
||||||
|
const raw = challenge?.sources
|
||||||
|
if (!Array.isArray(raw)) return []
|
||||||
|
return raw.filter(
|
||||||
|
(item): item is FlowSource =>
|
||||||
|
typeof item === "object" && item !== null && typeof (item as FlowSource).pk === "string"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SourceOAuthStage({ challenge, submitting, onSubmit }: StageRendererProps) {
|
||||||
|
const redirect = flowRedirectUrl(challenge)
|
||||||
|
const sources = readSources(challenge)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (redirect) {
|
||||||
|
window.location.href = redirect
|
||||||
|
}
|
||||||
|
}, [redirect])
|
||||||
|
|
||||||
|
if (redirect) {
|
||||||
|
return (
|
||||||
|
<p className="text-center text-sm text-muted-foreground">
|
||||||
|
Redirection vers le fournisseur…
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sources.length === 0) {
|
||||||
|
return (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Connexion tierce indisponible. Utilisez votre identifiant UltiSpace.
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{sources.map((source) => (
|
||||||
|
<AuthConnectButton
|
||||||
|
key={source.pk}
|
||||||
|
type="button"
|
||||||
|
disabled={submitting}
|
||||||
|
onClick={() =>
|
||||||
|
void onSubmit({
|
||||||
|
component: "ak-stage-source",
|
||||||
|
selected_source: source.pk,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{source.name ?? "Continuer"}
|
||||||
|
</AuthConnectButton>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
100
components/auth/flow-stages/webauthn-stage.tsx
Normal file
100
components/auth/flow-stages/webauthn-stage.tsx
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useRef } from "react"
|
||||||
|
import { Loader2 } from "lucide-react"
|
||||||
|
import type { StageRendererProps } from "@/components/auth/flow-stage-registry"
|
||||||
|
|
||||||
|
function readRequestOptions(
|
||||||
|
challenge: StageRendererProps["challenge"]
|
||||||
|
): PublicKeyCredentialRequestOptions | null {
|
||||||
|
const raw = challenge?.request_options ?? challenge?.requestOptions
|
||||||
|
if (!raw || typeof raw !== "object") return null
|
||||||
|
const nested = raw as { publicKey?: PublicKeyCredentialRequestOptions }
|
||||||
|
if (nested.publicKey) return nested.publicKey
|
||||||
|
return raw as PublicKeyCredentialRequestOptions
|
||||||
|
}
|
||||||
|
|
||||||
|
function bufferFromBase64URL(value: string): ArrayBuffer {
|
||||||
|
const padded = value.replace(/-/g, "+").replace(/_/g, "/")
|
||||||
|
const pad = padded.length % 4 === 0 ? "" : "=".repeat(4 - (padded.length % 4))
|
||||||
|
const binary = atob(padded + pad)
|
||||||
|
const bytes = new Uint8Array(binary.length)
|
||||||
|
for (let i = 0; i < binary.length; i += 1) {
|
||||||
|
bytes[i] = binary.charCodeAt(i)
|
||||||
|
}
|
||||||
|
return bytes.buffer
|
||||||
|
}
|
||||||
|
|
||||||
|
function bufferToBase64URL(buffer: ArrayBuffer): string {
|
||||||
|
const bytes = new Uint8Array(buffer)
|
||||||
|
let binary = ""
|
||||||
|
for (let i = 0; i < bytes.length; i += 1) {
|
||||||
|
binary += String.fromCharCode(bytes[i]!)
|
||||||
|
}
|
||||||
|
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRequestOptions(
|
||||||
|
options: PublicKeyCredentialRequestOptions
|
||||||
|
): PublicKeyCredentialRequestOptions {
|
||||||
|
const challenge = options.challenge
|
||||||
|
if (typeof challenge === "string") {
|
||||||
|
return { ...options, challenge: bufferFromBase64URL(challenge) }
|
||||||
|
}
|
||||||
|
return options
|
||||||
|
}
|
||||||
|
|
||||||
|
function serializeCredential(credential: PublicKeyCredential): Record<string, unknown> {
|
||||||
|
const response = credential.response as AuthenticatorAssertionResponse
|
||||||
|
return {
|
||||||
|
id: credential.id,
|
||||||
|
rawId: bufferToBase64URL(credential.rawId),
|
||||||
|
type: credential.type,
|
||||||
|
response: {
|
||||||
|
clientDataJSON: bufferToBase64URL(response.clientDataJSON),
|
||||||
|
authenticatorData: bufferToBase64URL(response.authenticatorData),
|
||||||
|
signature: bufferToBase64URL(response.signature),
|
||||||
|
userHandle: response.userHandle
|
||||||
|
? bufferToBase64URL(response.userHandle)
|
||||||
|
: null,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WebAuthnStage({ challenge, submitting, onSubmit }: StageRendererProps) {
|
||||||
|
const startedRef = useRef(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (startedRef.current || submitting) return
|
||||||
|
const options = readRequestOptions(challenge)
|
||||||
|
if (!options || typeof window === "undefined" || !window.PublicKeyCredential) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
startedRef.current = true
|
||||||
|
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const credential = (await navigator.credentials.get({
|
||||||
|
publicKey: normalizeRequestOptions(options),
|
||||||
|
})) as PublicKeyCredential | null
|
||||||
|
if (!credential) {
|
||||||
|
startedRef.current = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await onSubmit({
|
||||||
|
component: "ak-stage-authenticator-webauthn",
|
||||||
|
response: serializeCredential(credential),
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
startedRef.current = false
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
}, [challenge, onSubmit, submitting])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center gap-3 py-4 text-sm text-muted-foreground">
|
||||||
|
<Loader2 className="size-5 animate-spin" aria-hidden />
|
||||||
|
<p>Suivez l'invite de votre clé de sécurité ou biométrie…</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
55
components/auth/login-page-content.tsx
Normal file
55
components/auth/login-page-content.tsx
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
"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 LoginPageContentProps = {
|
||||||
|
returnTo?: string
|
||||||
|
error?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LoginPageContent({
|
||||||
|
returnTo = "/mail/inbox",
|
||||||
|
error = null,
|
||||||
|
}: LoginPageContentProps) {
|
||||||
|
const signupHref = `/signup?returnTo=${encodeURIComponent(returnTo)}`
|
||||||
|
const forgotPasswordHref = `/forgot-password?returnTo=${encodeURIComponent(returnTo)}`
|
||||||
|
const oidcFallbackHref = `/api/auth/login?returnTo=${encodeURIComponent(returnTo)}`
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthFlowPage
|
||||||
|
slug={AUTH_FLOW_SLUGS.authentication}
|
||||||
|
flowQuery={undefined}
|
||||||
|
defaultTitle="Connexion"
|
||||||
|
defaultDescription="Connecte-toi avec ton compte UltiSpace pour accéder à ta suite."
|
||||||
|
successTitle="Connexion réussie"
|
||||||
|
successDescription="Redirection vers votre espace…"
|
||||||
|
successActionLabel="Continuer"
|
||||||
|
successHref={returnTo}
|
||||||
|
bridgeAuthentication
|
||||||
|
returnTo={returnTo}
|
||||||
|
initialError={error}
|
||||||
|
footer={
|
||||||
|
<div className="flex w-full flex-col gap-3 text-center text-sm text-muted-foreground">
|
||||||
|
<p>
|
||||||
|
<a className="font-medium text-primary underline" href={oidcFallbackHref}>
|
||||||
|
Connexion via redirect UltiSpace
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
<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>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
31
components/auth/reset-password-page-content.tsx
Normal file
31
components/auth/reset-password-page-content.tsx
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
"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 ResetPasswordPageContentProps = {
|
||||||
|
flowQuery?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ResetPasswordPageContent({ flowQuery }: ResetPasswordPageContentProps) {
|
||||||
|
return (
|
||||||
|
<AuthFlowPage
|
||||||
|
slug={AUTH_FLOW_SLUGS.recovery}
|
||||||
|
flowQuery={flowQuery}
|
||||||
|
defaultTitle="Nouveau mot de passe"
|
||||||
|
defaultDescription="Choisissez un nouveau mot de passe pour votre compte UltiSpace."
|
||||||
|
successTitle="Mot de passe mis à jour"
|
||||||
|
successDescription="Votre mot de passe a été modifié. Connectez-vous pour continuer."
|
||||||
|
successActionLabel="Se connecter"
|
||||||
|
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>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -2,6 +2,11 @@
|
|||||||
export const AUTH_FLOW_SLUGS = {
|
export const AUTH_FLOW_SLUGS = {
|
||||||
enrollment: "ulti-enrollment",
|
enrollment: "ulti-enrollment",
|
||||||
recovery: "ulti-recovery",
|
recovery: "ulti-recovery",
|
||||||
|
authentication: "default-authentication-flow",
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
export type AuthFlowSlug = (typeof AUTH_FLOW_SLUGS)[keyof typeof AUTH_FLOW_SLUGS]
|
export type AuthFlowSlug = (typeof AUTH_FLOW_SLUGS)[keyof typeof AUTH_FLOW_SLUGS]
|
||||||
|
|
||||||
|
export function isAuthenticationFlow(slug: AuthFlowSlug): boolean {
|
||||||
|
return slug === AUTH_FLOW_SLUGS.authentication
|
||||||
|
}
|
||||||
|
|||||||
@ -9,6 +9,10 @@ export type FlowStepResponse = {
|
|||||||
denied: boolean
|
denied: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type FlowCompleteResponse = {
|
||||||
|
redirectUrl: string
|
||||||
|
}
|
||||||
|
|
||||||
export type FlowApiError = {
|
export type FlowApiError = {
|
||||||
code?: string
|
code?: string
|
||||||
message?: string
|
message?: string
|
||||||
@ -18,6 +22,12 @@ function flowApiBase(slug: AuthFlowSlug): string {
|
|||||||
return `/api/v1/auth/flows/${encodeURIComponent(slug)}`
|
return `/api/v1/auth/flows/${encodeURIComponent(slug)}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildQuerySuffix(query?: string): string {
|
||||||
|
const trimmed = query?.trim()
|
||||||
|
if (!trimmed) return ""
|
||||||
|
return `?query=${encodeURIComponent(trimmed)}`
|
||||||
|
}
|
||||||
|
|
||||||
async function parseFlowResponse(res: Response): Promise<FlowStepResponse> {
|
async function parseFlowResponse(res: Response): Promise<FlowStepResponse> {
|
||||||
const body = (await res.json()) as FlowStepResponse & FlowApiError
|
const body = (await res.json()) as FlowStepResponse & FlowApiError
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@ -26,8 +36,11 @@ async function parseFlowResponse(res: Response): Promise<FlowStepResponse> {
|
|||||||
return body
|
return body
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function startAuthFlow(slug: AuthFlowSlug): Promise<FlowStepResponse> {
|
export async function startAuthFlow(
|
||||||
const res = await fetch(`${flowApiBase(slug)}/start`, {
|
slug: AuthFlowSlug,
|
||||||
|
query?: string
|
||||||
|
): Promise<FlowStepResponse> {
|
||||||
|
const res = await fetch(`${flowApiBase(slug)}/start${buildQuerySuffix(query)}`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
headers: { Accept: "application/json" },
|
headers: { Accept: "application/json" },
|
||||||
@ -37,9 +50,10 @@ export async function startAuthFlow(slug: AuthFlowSlug): Promise<FlowStepRespons
|
|||||||
|
|
||||||
export async function respondAuthFlow(
|
export async function respondAuthFlow(
|
||||||
slug: AuthFlowSlug,
|
slug: AuthFlowSlug,
|
||||||
payload: Record<string, unknown>
|
payload: Record<string, unknown>,
|
||||||
|
query?: string
|
||||||
): Promise<FlowStepResponse> {
|
): Promise<FlowStepResponse> {
|
||||||
const res = await fetch(`${flowApiBase(slug)}/respond`, {
|
const res = await fetch(`${flowApiBase(slug)}/respond${buildQuerySuffix(query)}`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
headers: {
|
headers: {
|
||||||
@ -51,6 +65,24 @@ export async function respondAuthFlow(
|
|||||||
return parseFlowResponse(res)
|
return parseFlowResponse(res)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Bridge embedded authentication to OIDC session (sets Authentik cookies + login URL). */
|
||||||
|
export async function completeAuthFlow(returnTo: string): Promise<FlowCompleteResponse> {
|
||||||
|
const res = await fetch("/api/v1/auth/flows/complete", {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "include",
|
||||||
|
headers: {
|
||||||
|
Accept: "application/json",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ returnTo }),
|
||||||
|
})
|
||||||
|
const body = (await res.json()) as FlowCompleteResponse & FlowApiError
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(body.message ?? `flow complete failed (${res.status})`)
|
||||||
|
}
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
|
||||||
export function flowComponent(challenge: FlowChallenge | null | undefined): string {
|
export function flowComponent(challenge: FlowChallenge | null | undefined): string {
|
||||||
if (!challenge) return ""
|
if (!challenge) return ""
|
||||||
const value = challenge.component
|
const value = challenge.component
|
||||||
|
|||||||
@ -41,6 +41,9 @@ export const SIGNUP_PATH = "/signup"
|
|||||||
/** In-app forgot-password page (Phase 2 custom UI via ultid BFF). */
|
/** In-app forgot-password page (Phase 2 custom UI via ultid BFF). */
|
||||||
export const FORGOT_PASSWORD_PATH = "/forgot-password"
|
export const FORGOT_PASSWORD_PATH = "/forgot-password"
|
||||||
|
|
||||||
|
/** In-app password reset after e-mail link (Phase 3, is_restored flow). */
|
||||||
|
export const RESET_PASSWORD_PATH = "/reset-password"
|
||||||
|
|
||||||
export function getSignupUrl(): string {
|
export function getSignupUrl(): string {
|
||||||
return SIGNUP_PATH
|
return SIGNUP_PATH
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
const AUTH_PUBLIC_PREFIXES = ["/login", "/signup", "/forgot-password", "/auth/", "/api/auth/"]
|
const AUTH_PUBLIC_PREFIXES = ["/login", "/signup", "/forgot-password", "/reset-password", "/auth/", "/api/auth/"]
|
||||||
|
|
||||||
/** Routes without session enforcement (login, public shares, interactive demos). */
|
/** Routes without session enforcement (login, public shares, interactive demos). */
|
||||||
export function isAuthPublicPath(pathname: string): boolean {
|
export function isAuthPublicPath(pathname: string): boolean {
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Loading…
Reference in New Issue
Block a user