ultisuite-client/components/auth/auth-flow-page.tsx
R3D347HR4Y 9ea2d3325d
Some checks are pending
E2E / Playwright e2e (push) Waiting to run
feat(auth): enhance authentication flows with embedded support and UI improvements
- 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.
2026-06-21 00:12:45 +02:00

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 }