ultisuite-client/components/auth/auth-flow-page.tsx
R3D347HR4Y de5b5a60ef
Some checks are pending
E2E / Playwright e2e (push) Waiting to run
feat: enhance authentication and password reset flows with new components and layout
- 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.
2026-06-20 01:09:30 +02:00

219 lines
6.7 KiB
TypeScript

"use client"
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { Loader2 } from "lucide-react"
import { AuthCard } from "@/components/auth/auth-card"
import { AuthConnectButton } from "@/components/auth/auth-connect-button"
import { FlowChallengeForm } from "@/components/auth/flow-challenge-form"
import {
AUTH_FLOW_SLUGS,
isAuthenticationFlow,
type AuthFlowSlug,
} from "@/lib/auth/auth-flow-slugs"
import {
completeAuthFlow,
flowComponent,
flowRedirectUrl,
flowTitle,
flowValidationErrors,
isRecoveryEmailSent,
respondAuthFlow,
startAuthFlow,
type FlowChallenge,
} from "@/lib/auth/flow-api"
type AuthFlowPageProps = {
slug: AuthFlowSlug
flowQuery?: string
defaultTitle: string
defaultDescription: string
successTitle: string
successDescription: string
successActionLabel: string
successHref: string
/** Full document navigation required (OIDC login routes). */
successExternal?: boolean
/** After embedded auth flow, bridge to OIDC session via BFF /flows/complete. */
bridgeAuthentication?: boolean
returnTo?: string
initialError?: string | null
footer: React.ReactNode
onSuccess?: () => void
}
function decodeAuthError(value: string | null | undefined): string | null {
if (!value) return null
try {
return decodeURIComponent(value)
} catch {
return value
}
}
export function AuthFlowPage({
slug,
flowQuery,
defaultTitle,
defaultDescription,
successTitle,
successDescription,
successActionLabel,
successHref,
successExternal = false,
bridgeAuthentication = false,
returnTo = "/mail/inbox",
initialError = null,
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 [bridging, setBridging] = useState(false)
const [flowError, setFlowError] = useState<string | null>(null)
const [done, setDone] = 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 () => {
setLoading(true)
try {
const step = await startAuthFlow(slug, flowQuery)
setSessionId(step.sessionId)
setChallenge(step.challenge)
setDone(step.done)
setDenied(step.denied)
if (step.done && !step.denied) {
if (bridgeAuthentication) {
// Start returned immediate redirect — no flow session cookie for /complete.
redirectToOidcLogin()
return
}
onSuccess?.()
}
} catch (err) {
setFlowError(
err instanceof Error ? err.message : "Impossible de démarrer le parcours"
)
} finally {
setLoading(false)
}
}, [bridgeAuthentication, flowQuery, onSuccess, redirectToOidcLogin, slug])
useEffect(() => {
void 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 formError = flowError ?? redirectError ?? 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])
const component = flowComponent(challenge)
const redirectUrl = flowRedirectUrl(challenge)
const recoveryEmailSent = isRecoveryEmailSent(slug, challenge)
const showSuccess =
(done && !denied && !bridgeAuthentication) || recoveryEmailSent
return (
<AuthCard title={title} description={description} error={formError} footer={footer}>
{loading || bridging ? (
<div className="flex justify-center py-8">
<Loader2 className="size-6 animate-spin text-muted-foreground" aria-hidden />
<span className="sr-only">{bridging ? "Finalisation…" : "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}
{successExternal ? (
<AuthConnectButton href={successHref}>
{successActionLabel}
</AuthConnectButton>
) : (
<AuthConnectButton as="link" href={successHref}>
{successActionLabel}
</AuthConnectButton>
)}
</div>
) : (
<FlowChallengeForm
challenge={challenge}
component={component}
submitting={submitting}
fieldErrors={validationErrors}
flowSlug={slug}
onSubmit={handleSubmit}
/>
)}
</AuthCard>
)
}
export { AUTH_FLOW_SLUGS }