Some checks are pending
E2E / Playwright e2e (push) Waiting to run
- Updated login and signup components to utilize AuthCard for better user experience during redirection. - Introduced AuthentikEmbedDialog for seamless integration of Authentik's identity portal within the application. - Enhanced password recovery and signup flows with dynamic theme handling and improved loading states. - Refactored existing components to streamline authentication processes and improve maintainability.
246 lines
7.9 KiB
TypeScript
246 lines
7.9 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 {
|
|
fetchEmbeddedAuthContext,
|
|
flowComponent,
|
|
flowRedirectUrl,
|
|
flowTitle,
|
|
flowValidationErrors,
|
|
isOAuthAuthorizeRedirect,
|
|
isRecoveryEmailSent,
|
|
respondAuthFlow,
|
|
respondDirectFlow,
|
|
startAuthFlow,
|
|
startDirectFlow,
|
|
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 [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 flowQueryRef = useRef<string | undefined>(flowQuery)
|
|
const bootstrappedRef = useRef(false)
|
|
const bridgedRef = useRef(false)
|
|
// Direct mode: drive Authentik's flow executor in the browser so the browser holds the session,
|
|
// then navigate to the authorize URL to obtain the OIDC code.
|
|
const directMode = bridgeAuthentication && isAuthenticationFlow(slug)
|
|
const executorBaseRef = useRef<string | null>(null)
|
|
const authorizeUrlRef = useRef<string | null>(null)
|
|
const startedRef = useRef(false)
|
|
const redirectError = decodeAuthError(initialError)
|
|
|
|
const finishAuthentication = useCallback(
|
|
(finalChallenge?: FlowChallenge | null) => {
|
|
if (!directMode || bridgedRef.current) {
|
|
onSuccess?.()
|
|
return
|
|
}
|
|
bridgedRef.current = true
|
|
setBridging(true)
|
|
const target = flowRedirectUrl(finalChallenge ?? challenge)
|
|
if (target && isOAuthAuthorizeRedirect(target)) {
|
|
window.location.href = target
|
|
return
|
|
}
|
|
if (authorizeUrlRef.current) {
|
|
window.location.href = authorizeUrlRef.current
|
|
return
|
|
}
|
|
// No authorize continuation available — surface an error rather than silently failing.
|
|
setBridging(false)
|
|
bridgedRef.current = false
|
|
setFlowError("Impossible de finaliser la connexion (authorize introuvable)")
|
|
},
|
|
[challenge, directMode, onSuccess]
|
|
)
|
|
|
|
useEffect(() => {
|
|
if (bootstrappedRef.current) return
|
|
bootstrappedRef.current = true
|
|
|
|
void (async () => {
|
|
setLoading(true)
|
|
try {
|
|
let step: Awaited<ReturnType<typeof startDirectFlow>>
|
|
if (directMode) {
|
|
const ctx = await fetchEmbeddedAuthContext(returnTo)
|
|
executorBaseRef.current = ctx.executorBase
|
|
authorizeUrlRef.current = ctx.authorizeUrl
|
|
flowQueryRef.current = ctx.flowQuery
|
|
step = await startDirectFlow(ctx.executorBase, slug, ctx.flowQuery)
|
|
} else {
|
|
step = await startAuthFlow(slug, flowQueryRef.current ?? flowQuery)
|
|
}
|
|
startedRef.current = true
|
|
setChallenge(step.challenge)
|
|
setDone(step.done)
|
|
setDenied(step.denied)
|
|
if (step.done && !step.denied) {
|
|
if (directMode) {
|
|
finishAuthentication(step.challenge)
|
|
return
|
|
}
|
|
onSuccess?.()
|
|
}
|
|
} catch (err) {
|
|
setFlowError(
|
|
err instanceof Error ? err.message : "Impossible de démarrer le parcours"
|
|
)
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
})()
|
|
// Intentionally once per mount — avoid re-start loop from challenge deps.
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- bootstrap runs once
|
|
}, [])
|
|
|
|
const handleSubmit = useCallback(
|
|
async (payload: Record<string, unknown>) => {
|
|
if (!startedRef.current) return
|
|
setSubmitting(true)
|
|
setFlowError(null)
|
|
try {
|
|
const step = directMode
|
|
? await respondDirectFlow(
|
|
executorBaseRef.current ?? "/auth/api/v3/flows/executor",
|
|
slug,
|
|
payload,
|
|
flowQueryRef.current
|
|
)
|
|
: await respondAuthFlow(slug, payload, flowQueryRef.current ?? flowQuery)
|
|
setChallenge(step.challenge)
|
|
setDone(step.done)
|
|
setDenied(step.denied)
|
|
if (step.done && !step.denied) {
|
|
finishAuthentication(step.challenge)
|
|
}
|
|
} catch (err) {
|
|
setFlowError(err instanceof Error ? err.message : "Échec de l'étape")
|
|
} finally {
|
|
setSubmitting(false)
|
|
}
|
|
},
|
|
[directMode, finishAuthentication, flowQuery, 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 }
|