feat(auth): enhance authentication flows with embedded support and UI improvements
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.
This commit is contained in:
R3D347HR4Y 2026-06-21 00:12:45 +02:00
parent ee05c804f9
commit 9ea2d3325d
29 changed files with 983 additions and 214 deletions

View File

@ -37,9 +37,19 @@ export async function GET(request: Request) {
const requestUrl = new URL(request.url)
const returnTo = requestUrl.searchParams.get("returnTo") ?? "/mail/inbox"
const intent = requestUrl.searchParams.get("intent")
const bridge = requestUrl.searchParams.get("bridge") === "1"
// Embedded mode: the browser drives Authentik's flow executor (same origin) and authenticates
// the session in place, then navigates to the authorize URL. We just hand back the URL + the
// executor base, set the PKCE/state cookies, and never force a prompt.
const embedded = requestUrl.searchParams.get("embedded") === "1"
const promptParam = requestUrl.searchParams.get("prompt")
const prompt =
requestUrl.searchParams.get("prompt") ??
(intent === "add_account" ? "login select_account" : "select_account")
promptParam ??
(bridge || embedded
? null
: intent === "add_account"
? "login select_account"
: "select_account")
const jar = await cookies()
const existingUser = platformUserFromToken(
@ -54,12 +64,16 @@ export async function GET(request: Request) {
state,
code_challenge: challenge,
code_challenge_method: "S256",
prompt,
})
if (prompt) {
params.set("prompt", prompt)
}
const response = NextResponse.redirect(
`${cfg.authorizationEndpoint}?${params.toString()}`
)
const authorizeUrl = `${cfg.authorizationEndpoint}?${params.toString()}`
const response = embedded
? NextResponse.json(buildEmbeddedContext(authorizeUrl))
: NextResponse.redirect(authorizeUrl)
const cookieOpts = oauthCookieOptions()
response.cookies.set(PKCE_COOKIE, verifier, cookieOpts)
response.cookies.set(STATE_COOKIE, state, cookieOpts)
@ -73,3 +87,31 @@ export async function GET(request: Request) {
return response
}
/**
* Build the same-origin flow executor base + `next` query from a public authorize URL.
* The browser drives the flow at `${executorBase}/${slug}/?query=${flowQuery}`, then navigates
* to the returned `to` (the authorize URL) to obtain the code with its authenticated session.
*/
function buildEmbeddedContext(authorizeUrl: string): {
authorizeUrl: string
flowQuery: string
executorBase: string
} {
let executorBase = "/auth/api/v3/flows/executor"
let next = authorizeUrl
try {
const parsed = new URL(authorizeUrl)
const idx = parsed.pathname.indexOf("/application/")
const prefix = idx >= 0 ? parsed.pathname.slice(0, idx) : "/auth"
executorBase = `${prefix}/api/v3/flows/executor`
next = `${parsed.pathname}${parsed.search}`
} catch {
// keep defaults
}
return {
authorizeUrl,
flowQuery: `next=${encodeURIComponent(next)}`,
executorBase,
}
}

View File

@ -10,6 +10,7 @@ import {
isAccessTokenValid,
isIdTokenJwtValid,
resolveBearerToken,
resolveSessionExpiresAt,
} from "@/lib/auth/session"
export async function GET() {
@ -23,7 +24,7 @@ export async function GET() {
}
if (isAccessTokenValid(accessToken, expiresAtRaw)) {
const expiresAt = Number(expiresAtRaw)
const expiresAt = resolveSessionExpiresAt(accessToken, expiresAtRaw)
const user = platformUserFromToken(accessToken!)
return NextResponse.json({
authenticated: true,
@ -47,7 +48,7 @@ export async function GET() {
bearer = resolveBearerToken(tokens)
} catch {
if (accessToken && isIdTokenJwtValid(accessToken)) {
const expiresAt = Number(expiresAtRaw) || computeExpiresAt(3600)
const expiresAt = resolveSessionExpiresAt(accessToken, expiresAtRaw)
const user = platformUserFromToken(accessToken)
return NextResponse.json({
authenticated: true,

View File

@ -61,10 +61,11 @@ export function FilePoliciesSection() {
<SettingsGrid columns={1} className="space-y-4">
<SettingsGrid columns={2} className="sm:grid-cols-3">
<SettingsField label="Taille max upload">
<InputGroup>
<InputGroup className="w-fit max-w-full">
<InputGroupInput
type="number"
min={1}
className="w-20 flex-none tabular-nums"
value={filePolicies.max_upload_mib}
onChange={(e) =>
setFilePolicies({ max_upload_mib: Number(e.target.value) || 1 })
@ -76,10 +77,11 @@ export function FilePoliciesSection() {
</InputGroup>
</SettingsField>
<SettingsField label="Expiration liens par défaut">
<InputGroup>
<InputGroup className="w-fit max-w-full">
<InputGroupInput
type="number"
min={1}
className="w-20 flex-none tabular-nums"
value={filePolicies.default_link_expiry_days}
onChange={(e) =>
setFilePolicies({
@ -93,10 +95,11 @@ export function FilePoliciesSection() {
</InputGroup>
</SettingsField>
<SettingsField label="Rétention corbeille">
<InputGroup>
<InputGroup className="w-fit max-w-full">
<InputGroupInput
type="number"
min={1}
className="w-20 flex-none tabular-nums"
value={filePolicies.retention_trash_days}
onChange={(e) =>
setFilePolicies({ retention_trash_days: Number(e.target.value) || 1 })

View File

@ -16,6 +16,7 @@ import {
InputGroupInput,
InputGroupText,
} from "@/components/ui/input-group"
import { cn } from "@/lib/utils"
export function QuotasSection() {
const storageQuotas = useOrgSettingsStore((s) => s.storageQuotas)
@ -60,7 +61,6 @@ export function QuotasSection() {
min={50}
max={100}
fallback={90}
className="max-w-xs"
value={storageQuotas.warn_threshold_pct}
onChange={(v) => setStorageQuotas({ warn_threshold_pct: v })}
/>
@ -95,7 +95,6 @@ export function QuotasSection() {
min={50}
max={100}
fallback={80}
className="max-w-xs"
value={usageQuotas.llm_cost_warn_threshold_pct}
onChange={(v) => setUsageQuotas({ llm_cost_warn_threshold_pct: v })}
/>
@ -179,13 +178,14 @@ function SettingsNumberField({
}) {
return (
<SettingsField label={label}>
<InputGroup className={className}>
<InputGroup className={cn("w-fit max-w-full", className)}>
<InputGroupInput
type="number"
min={min}
max={max}
step={step}
value={value}
className="w-20 flex-none tabular-nums"
onChange={(e) => onChange(Number(e.target.value) || fallback)}
/>
<InputGroupAddon align="inline-end">

View File

@ -35,6 +35,7 @@ import { calendarColor } from "@/lib/agenda/agenda-events"
import { useAgendaSettingsStore, useAgendaUIStore } from "@/lib/agenda/agenda-store"
import type { AgendaCalendar } from "@/lib/agenda/agenda-types"
import { useMergedAgendaCalendars } from "@/lib/agenda/use-visible-agenda-calendars"
import { usePersistHydrated } from "@/hooks/use-persist-hydrated"
import { useIsMobile } from "@/hooks/use-mobile"
import { cn } from "@/lib/utils"
@ -48,9 +49,11 @@ export function AgendaSidebar({
onCreateEvent: () => void
}) {
const isMobile = useIsMobile()
const settingsHydrated = usePersistHydrated(useAgendaSettingsStore)
const sidebarCollapsed = useAgendaUIStore((s) => s.sidebarCollapsed)
const setSidebarCollapsed = useAgendaUIStore((s) => s.setSidebarCollapsed)
const hiddenIds = useAgendaSettingsStore((s) => s.hiddenCalendarIds)
const storedHiddenIds = useAgendaSettingsStore((s) => s.hiddenCalendarIds)
const hiddenIds = settingsHydrated ? storedHiddenIds : []
const weekStart = useAgendaSettingsStore((s) => s.weekStart)
const calendarViews = useAgendaSettingsStore((s) => s.calendarViews)
const activeCalendarViewId = useAgendaSettingsStore((s) => s.activeCalendarViewId)
@ -144,7 +147,7 @@ export function AgendaSidebar({
<Plus className="size-4" />
</Button>
</div>
{calendarViews.length > 0 ? (
{settingsHydrated && calendarViews.length > 0 ? (
<>
<button
type="button"

View File

@ -11,14 +11,17 @@ import {
type AuthFlowSlug,
} from "@/lib/auth/auth-flow-slugs"
import {
completeAuthFlow,
fetchEmbeddedAuthContext,
flowComponent,
flowRedirectUrl,
flowTitle,
flowValidationErrors,
isOAuthAuthorizeRedirect,
isRecoveryEmailSent,
respondAuthFlow,
respondDirectFlow,
startAuthFlow,
startDirectFlow,
type FlowChallenge,
} from "@/lib/auth/flow-api"
@ -66,7 +69,6 @@ export function AuthFlowPage({
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)
@ -74,46 +76,66 @@ export function AuthFlowPage({
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(async () => {
if (!bridgeAuthentication || !isAuthenticationFlow(slug) || bridgedRef.current) {
const finishAuthentication = useCallback(
(finalChallenge?: FlowChallenge | null) => {
if (!directMode || 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"
)
const target = flowRedirectUrl(finalChallenge ?? challenge)
if (target && isOAuthAuthorizeRedirect(target)) {
window.location.href = target
return
}
}, [bridgeAuthentication, onSuccess, returnTo, slug])
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]
)
const redirectToOidcLogin = useCallback(() => {
const params = new URLSearchParams({ returnTo })
window.location.assign(`/api/auth/login?${params.toString()}`)
}, [returnTo])
useEffect(() => {
if (bootstrappedRef.current) return
bootstrappedRef.current = true
const bootstrap = useCallback(async () => {
void (async () => {
setLoading(true)
try {
const step = await startAuthFlow(slug, flowQuery)
setSessionId(step.sessionId)
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 (bridgeAuthentication) {
// Start returned immediate redirect — no flow session cookie for /complete.
redirectToOidcLogin()
if (directMode) {
finishAuthentication(step.challenge)
return
}
onSuccess?.()
@ -125,25 +147,30 @@ export function AuthFlowPage({
} finally {
setLoading(false)
}
}, [bridgeAuthentication, flowQuery, onSuccess, redirectToOidcLogin, slug])
useEffect(() => {
void bootstrap()
}, [bootstrap])
})()
// 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 (!sessionId) return
if (!startedRef.current) return
setSubmitting(true)
setFlowError(null)
try {
const step = await respondAuthFlow(slug, payload, flowQuery)
setSessionId(step.sessionId)
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) {
await finishAuthentication()
finishAuthentication(step.challenge)
}
} catch (err) {
setFlowError(err instanceof Error ? err.message : "Échec de l'étape")
@ -151,7 +178,7 @@ export function AuthFlowPage({
setSubmitting(false)
}
},
[finishAuthentication, flowQuery, sessionId, slug]
[directMode, finishAuthentication, flowQuery, slug]
)
const validationErrors = useMemo(() => flowValidationErrors(challenge), [challenge])

View File

@ -2,18 +2,20 @@
import { useCallback, useEffect, useState, type ReactNode } from "react"
import { usePathname, useRouter } from "next/navigation"
import { useAuthStore, AUTH_STORAGE_KEY, LEGACY_AUTH_KEYS } from "@/lib/api/auth-store"
import { useQueryClient } from "@tanstack/react-query"
import { useAuthStore } from "@/lib/api/auth-store"
import { isOidcConfigured } from "@/lib/auth/oidc-config"
import { clearClientAuthState } from "@/lib/auth/clear-client-auth-state"
import {
fetchSession,
applySessionToStore,
type SessionPayload,
} from "@/lib/auth/session-sync"
import {
isSessionExpired,
useSessionGuardStore,
} from "@/lib/auth/session-guard-store"
import { isAuthPublicPath } from "@/lib/auth/public-paths"
import { getOidcEndSessionPath, POST_LOGOUT_PATH } from "@/lib/auth/oidc-config"
import { useNativeRuntime } from "@/lib/platform"
import {
ensureNativeAccessToken,
@ -34,7 +36,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const pathname = usePathname()
const router = useRouter()
const logout = useAuthStore((s) => s.logout)
const isAuthenticated = useAuthStore((s) => s.isAuthenticated)
const authenticated = useAuthStore((s) => s.isAuthenticated())
const [ready, setReady] = useState(() => !isOidcConfigured())
const applySession = useCallback(
@ -53,6 +55,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}
const data = await fetchSession()
if (data && applySession(data)) return true
if (useAuthStore.getState().isAuthenticated()) return true
logout()
return false
}, [applySession, logout, native])
@ -89,11 +92,12 @@ export function AuthProvider({ children }: { children: ReactNode }) {
return
}
const hadMemoryAuth = useAuthStore.getState().isAuthenticated()
logout()
if (hadMemoryAuth && !isPublicPath(pathname) && !isSessionExpired()) {
useSessionGuardStore.getState().setExpired()
if (useAuthStore.getState().isAuthenticated()) {
setReady(true)
return
}
logout()
setReady(true)
}
@ -111,7 +115,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
return () => {
cancelled = true
}
}, [applySession, logout, pathname, native])
}, [applySession, logout, native])
useEffect(() => {
if (!ready || !isOidcConfigured()) return
@ -130,13 +134,14 @@ export function AuthProvider({ children }: { children: ReactNode }) {
useEffect(() => {
if (!ready || !isOidcConfigured()) return
if (isPublicPath(pathname)) return
if (isAuthenticated()) return
if (authenticated) return
// NativeAuthGate shows picker/login inline — avoid fighting redirects.
if (native) return
let cancelled = false
void syncSession().then((ok) => {
if (cancelled || ok) return
if (useAuthStore.getState().isAuthenticated()) return
if (useSessionGuardStore.getState().status === "expired") return
const returnTo = encodeURIComponent(pathname)
router.replace(`/login?returnTo=${returnTo}`)
@ -145,14 +150,14 @@ export function AuthProvider({ children }: { children: ReactNode }) {
return () => {
cancelled = true
}
}, [ready, pathname, isAuthenticated, router, syncSession])
}, [ready, pathname, authenticated, router, syncSession, native])
return <>{children}</>
}
export function useAuthLogout() {
const logout = useAuthStore((s) => s.logout)
const router = useRouter()
const queryClient = useQueryClient()
const native = useNativeRuntime()
return async () => {
@ -165,16 +170,16 @@ export function useAuthLogout() {
/* best effort */
}
await nativeLogout()
} else {
clearClientAuthState(queryClient)
router.replace(POST_LOGOUT_PATH)
return
}
await fetch("/api/auth/logout", { method: "POST", credentials: "include" })
}
logout()
clearClientAuthState(queryClient)
if (typeof window !== "undefined") {
localStorage.removeItem(AUTH_STORAGE_KEY)
for (const legacy of LEGACY_AUTH_KEYS) {
localStorage.removeItem(legacy)
// Clear Ultimail session then terminate Authentik SSO (provider invalidation flow).
window.location.assign(getOidcEndSessionPath())
}
}
router.replace("/login")
}
}

View File

@ -1,10 +1,41 @@
"use client"
import Link from "next/link"
import { useEffect } from "react"
import { useTheme } from "next-themes"
import { Loader2 } from "lucide-react"
import { AuthCard } from "@/components/auth/auth-card"
import { AuthFlowPage } from "@/components/auth/auth-flow-page"
import {
authentikRecoveryFlowUrl,
resolveAuthentikTheme,
} from "@/lib/auth/authentik-user-url"
import { AUTH_FLOW_SLUGS } from "@/lib/auth/auth-flow-slugs"
import { useClientThemeStore } from "@/lib/stores/client-theme-store"
import { useNativeRuntime } from "@/lib/platform"
export function ForgotPasswordPageContent() {
const native = useNativeRuntime()
const themeMode = useClientThemeStore((s) => s.themeMode)
const { resolvedTheme } = useTheme()
const authentikTheme = resolveAuthentikTheme(themeMode, resolvedTheme)
const flowUrl = authentikRecoveryFlowUrl(authentikTheme)
useEffect(() => {
if (!native && flowUrl) {
window.location.replace(flowUrl)
}
}, [native, flowUrl])
const loginFooter = (
<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>
)
if (native) {
return (
<AuthFlowPage
slug={AUTH_FLOW_SLUGS.recovery}
@ -14,13 +45,26 @@ export function ForgotPasswordPageContent() {
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>
}
footer={loginFooter}
/>
)
}
return (
<AuthCard
title="Mot de passe oublié"
description="Redirection vers la réinitialisation…"
footer={loginFooter}
>
<div className="flex flex-col items-center gap-4 py-8">
<Loader2 className="size-6 animate-spin text-muted-foreground" aria-hidden />
<span className="sr-only">Redirection</span>
{!flowUrl ? (
<p className="text-center text-sm text-destructive" role="alert">
Configuration Authentik indisponible.
</p>
) : null}
</div>
</AuthCard>
)
}

View File

@ -1,8 +1,18 @@
"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"
import { useEffect } from "react"
import { useTheme } from "next-themes"
import { Loader2 } from "lucide-react"
import { AuthCard } from "@/components/auth/auth-card"
import { AuthConnectButton } from "@/components/auth/auth-connect-button"
import {
authentikRecoveryFlowUrl,
resolveAuthentikTheme,
} from "@/lib/auth/authentik-user-url"
import { getAuthentikEnrollmentUrl, getForgotPasswordUrl } from "@/lib/auth/oidc-config"
import { useClientThemeStore } from "@/lib/stores/client-theme-store"
import { useNativeRuntime } from "@/lib/platform"
type LoginPageContentProps = {
returnTo?: string
@ -13,43 +23,72 @@ 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)}`
const native = useNativeRuntime()
const themeMode = useClientThemeStore((s) => s.themeMode)
const { resolvedTheme } = useTheme()
const authentikTheme = resolveAuthentikTheme(themeMode, resolvedTheme)
const signupHref = native
? `/signup?returnTo=${encodeURIComponent(returnTo)}`
: getAuthentikEnrollmentUrl()
const forgotPasswordHref = native
? `/forgot-password?returnTo=${encodeURIComponent(returnTo)}`
: (authentikRecoveryFlowUrl(authentikTheme) ?? getForgotPasswordUrl())
const oidcHref = `/api/auth/login?returnTo=${encodeURIComponent(returnTo)}`
const decodedError = error ? decodeURIComponent(error) : null
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={
useEffect(() => {
if (!error) {
window.location.replace(oidcHref)
}
}, [error, oidcHref])
const 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 ?{" "}
{native ? (
<Link className="font-medium text-primary underline" href={signupHref}>
Créer un compte
</Link>
) : (
<a className="font-medium text-primary underline" href={signupHref}>
Créer un compte
</a>
)}
</p>
<p>
{native ? (
<Link className="font-medium text-primary underline" href={forgotPasswordHref}>
Mot de passe oublié ?
</Link>
) : (
<a className="font-medium text-primary underline" href={forgotPasswordHref}>
Mot de passe oublié ?
</a>
)}
</p>
</div>
)
if (error) {
return (
<AuthCard
title="Connexion"
description="Connecte-toi avec ton compte UltiSpace pour accéder à ta suite."
error={decodedError}
footer={footer}
>
<AuthConnectButton href={oidcHref}>Réessayer</AuthConnectButton>
</AuthCard>
)
}
/>
return (
<AuthCard title="Connexion" description="Redirection vers UltiSpace…" footer={footer}>
<div className="flex justify-center py-8">
<Loader2 className="size-6 animate-spin text-muted-foreground" aria-hidden />
<span className="sr-only">Redirection</span>
</div>
</AuthCard>
)
}

View File

@ -1,17 +1,39 @@
"use client"
import Link from "next/link"
import { useEffect } from "react"
import { useTheme } from "next-themes"
import { Loader2 } from "lucide-react"
import { AuthCard } from "@/components/auth/auth-card"
import { AuthFlowPage } from "@/components/auth/auth-flow-page"
import {
authentikEnrollmentFlowUrl,
resolveAuthentikTheme,
} from "@/lib/auth/authentik-user-url"
import { AUTH_FLOW_SLUGS } from "@/lib/auth/auth-flow-slugs"
import { useClientThemeStore } from "@/lib/stores/client-theme-store"
import { useNativeRuntime } from "@/lib/platform"
type SignupPageContentProps = {
returnTo?: string
}
export function SignupPageContent({ returnTo = "/mail/inbox" }: SignupPageContentProps) {
const native = useNativeRuntime()
const themeMode = useClientThemeStore((s) => s.themeMode)
const { resolvedTheme } = useTheme()
const authentikTheme = resolveAuthentikTheme(themeMode, resolvedTheme)
const flowUrl = authentikEnrollmentFlowUrl(authentikTheme)
const loginHref = `/login?returnTo=${encodeURIComponent(returnTo)}`
const oidcHref = `/api/auth/login?returnTo=${encodeURIComponent(returnTo)}`
useEffect(() => {
if (!native && flowUrl) {
window.location.replace(flowUrl)
}
}, [native, flowUrl])
if (native) {
return (
<AuthFlowPage
slug={AUTH_FLOW_SLUGS.enrollment}
@ -32,4 +54,30 @@ export function SignupPageContent({ returnTo = "/mail/inbox" }: SignupPageConten
}
/>
)
}
return (
<AuthCard
title="Créer un compte"
description="Redirection vers l'inscription…"
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>
}
>
<div className="flex flex-col items-center gap-4 py-8">
<Loader2 className="size-6 animate-spin text-muted-foreground" aria-hidden />
<span className="sr-only">Redirection</span>
{!flowUrl ? (
<p className="text-center text-sm text-destructive" role="alert">
Configuration Authentik indisponible.
</p>
) : null}
</div>
</AuthCard>
)
}

View File

@ -0,0 +1,95 @@
"use client"
import { useCallback, useEffect, useState } from "react"
import { Loader2 } from "lucide-react"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
export type AuthentikEmbedDialogProps = {
url: string
title: string
open: boolean
onOpenChange: (open: boolean) => void
description?: string
}
export function AuthentikEmbedDialog({
url,
title,
open,
onOpenChange,
description,
}: AuthentikEmbedDialogProps) {
const [isLoading, setIsLoading] = useState(true)
useEffect(() => {
if (open) {
setIsLoading(true)
}
}, [open, url])
const handleIframeLoad = useCallback(() => {
setIsLoading(false)
}, [])
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className="gap-0 overflow-hidden border-mail-border bg-mail-surface p-0 sm:max-w-2xl dark:bg-mail-surface-elevated"
showCloseButton
>
<DialogHeader className="space-y-1 border-b border-mail-border px-6 py-4 pr-12">
<DialogTitle className="text-base font-semibold">{title}</DialogTitle>
{description ? (
<DialogDescription>{description}</DialogDescription>
) : null}
</DialogHeader>
<div className="relative bg-mail-surface dark:bg-mail-surface-elevated">
{isLoading ? (
<div
className="absolute inset-0 z-10 flex items-center justify-center bg-mail-surface/80 dark:bg-mail-surface-elevated/80"
aria-busy="true"
aria-live="polite"
>
<Loader2
className="size-6 animate-spin text-muted-foreground"
aria-hidden
/>
<span className="sr-only">Chargement du portail d&apos;identité</span>
</div>
) : null}
<iframe
key={url}
src={url}
title={title}
referrerPolicy="no-referrer-when-downgrade"
allow="clipboard-write"
onLoad={handleIframeLoad}
className="block w-full rounded-none border-0 bg-mail-surface dark:bg-mail-surface-elevated"
style={{ height: "min(72vh, 640px)" }}
/>
</div>
<DialogFooter className="flex-row items-center justify-between gap-3 border-t border-mail-border px-6 py-3 sm:justify-between">
<p className="text-xs text-muted-foreground">
Géré via votre identité UltiSuite
</p>
<DialogClose asChild>
<Button type="button" variant="outline" className="h-8 rounded-full px-4 text-sm">
Fermer
</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@ -1,9 +1,9 @@
"use client"
import { useMemo } from "react"
import { useMemo, useState } from "react"
import { useTheme } from "next-themes"
import { ExternalLink } from "lucide-react"
import { Button } from "@/components/ui/button"
import { AuthentikEmbedDialog } from "@/components/compte/authentik-embed-dialog"
import { CompteSettingsCard } from "@/components/compte/compte-settings-card"
import {
buildAuthentikUrl,
@ -29,6 +29,7 @@ export function CompteAuthentikPanel({
actionLabel,
icon,
}: CompteAuthentikPanelProps) {
const [embedOpen, setEmbedOpen] = useState(false)
const themeMode = useClientThemeStore((s) => s.themeMode)
const { resolvedTheme } = useTheme()
const authentikTheme = resolveAuthentikTheme(themeMode, resolvedTheme)
@ -50,6 +51,7 @@ export function CompteAuthentikPanel({
}
return (
<>
<CompteSettingsCard>
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<PanelHeader icon={icon} title={title} description={description} />
@ -57,18 +59,21 @@ export function CompteAuthentikPanel({
type="button"
variant="outline"
className="h-9 shrink-0 rounded-full px-4 text-sm font-medium"
asChild
onClick={() => setEmbedOpen(true)}
>
<a href={url} target="_blank" rel="noreferrer">
{actionLabel}
<ExternalLink className="size-3.5" aria-hidden />
</a>
</Button>
</div>
<p className="mt-3 text-xs text-muted-foreground">
Ouverture du portail d&apos;identité Authentik dans un nouvel onglet.
</p>
</CompteSettingsCard>
<AuthentikEmbedDialog
url={url}
title={title}
description={description}
open={embedOpen}
onOpenChange={setEmbedOpen}
/>
</>
)
}

View File

@ -1,6 +1,7 @@
"use client"
import { useEffect, useMemo, useRef } from "react"
import { usePersistHydrated } from "@/hooks/use-persist-hydrated"
import { useAgendaCalendars } from "@/lib/api/hooks/use-calendar-queries"
import { useMailAccounts } from "@/lib/api/hooks/use-mail-queries"
import {
@ -13,7 +14,9 @@ import { useAgendaSettingsStore } from "@/lib/agenda/agenda-store"
import type { AgendaCalendar } from "@/lib/agenda/agenda-types"
export function useMergedAgendaCalendars() {
const externalCalendars = useAgendaSettingsStore((s) => s.externalCalendars)
const settingsHydrated = usePersistHydrated(useAgendaSettingsStore)
const storedExternalCalendars = useAgendaSettingsStore((s) => s.externalCalendars)
const externalCalendars = settingsHydrated ? storedExternalCalendars : []
const { data: apiCalendars = [], isLoading, isError } = useAgendaCalendars()
const calendars = useMemo(
@ -21,7 +24,7 @@ export function useMergedAgendaCalendars() {
[apiCalendars, externalCalendars],
)
return { calendars, apiCalendars, externalCalendars, isLoading, isError }
return { calendars, apiCalendars, externalCalendars, isLoading, isError, settingsHydrated }
}
export function useVisibleAgendaCalendars() {

View File

@ -182,7 +182,7 @@ export function useCreateAgendaCalendar() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (input: { display_name: string; color?: string }) =>
apiClient.post<{ id: string }>("/calendar", input),
apiClient.post<{ id: string }>("/calendar/", input),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["agenda", "calendars"] })
},

View File

@ -26,23 +26,30 @@ export function useAgendaCalendars() {
const { ready, authenticated } = useAuthReady()
const isDemoAgenda = useIsDemoAgenda()
const demoVersion = useDemoAgendaStore((s) => s.version)
return useQuery({
const queryEnabled = ready && authenticated
const query = useQuery({
queryKey: isDemoAgenda
? [...DEMO_AGENDA_QUERY_ROOT, "calendars", demoVersion]
: agendaCalendarsKey,
enabled: ready && authenticated,
enabled: queryEnabled,
staleTime: 5 * 60_000,
queryFn: async () => {
if (isDemoAgenda) {
return useDemoAgendaStore.getState().listCalendars()
}
const res = await apiClient.get<AgendaCalendarsResponse>("/calendar")
const res = await apiClient.get<AgendaCalendarsResponse>("/calendar/")
return res.calendars ?? []
},
initialData: isDemoAgenda
? () => useDemoAgendaStore.getState().listCalendars()
: undefined,
})
// Disabled queries still return cached data — hide until auth persist hydrated.
return {
...query,
data: queryEnabled ? query.data : undefined,
isLoading: queryEnabled && query.isLoading,
}
}
/**

View File

@ -21,6 +21,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 = {
enrollment: "ulti-enrollment",
passwordChange: "default-password-change",
recovery: "ulti-recovery",
totpSetup: "default-authenticator-totp-setup",
@ -106,6 +107,18 @@ export function authentikPasswordChangeFlowUrl(
return authentikFlowUrl(AUTHENTIK_SELF_SERVICE_FLOWS.passwordChange, theme)
}
export function authentikEnrollmentFlowUrl(
theme?: "light" | "dark"
): string | null {
return authentikFlowUrl(AUTHENTIK_SELF_SERVICE_FLOWS.enrollment, theme)
}
export function authentikRecoveryFlowUrl(
theme?: "light" | "dark"
): string | null {
return authentikFlowUrl(AUTHENTIK_SELF_SERVICE_FLOWS.recovery, theme)
}
/** URL Authentik (flow ou onglet réglages) avec thème. */
export function buildAuthentikUrl(options: AuthentikUrlOptions): string | null {
const { tab, flowSlug, theme } = options

View File

@ -0,0 +1,21 @@
"use client"
import type { QueryClient } from "@tanstack/react-query"
import { AUTH_STORAGE_KEY, LEGACY_AUTH_KEYS, useAuthStore } from "@/lib/api/auth-store"
import { flushPersistStorage } from "@/lib/stores/debounced-json-storage"
import { useAccountStore } from "@/lib/stores/account-store"
/** Drop in-memory auth, cached API data, and persisted profile chrome. */
export function clearClientAuthState(queryClient?: QueryClient) {
useAuthStore.getState().logout()
queryClient?.clear()
useAccountStore.setState({ activeAccountId: null, otherAccountsExpanded: true })
if (typeof window === "undefined") return
localStorage.removeItem(AUTH_STORAGE_KEY)
for (const legacy of LEGACY_AUTH_KEYS) {
localStorage.removeItem(legacy)
}
flushPersistStorage()
}

View File

@ -20,6 +20,9 @@ export async function ensureAccessToken(): Promise<string | null> {
if (data && applySessionToStore(data)) {
return useAuthStore.getState().accessToken
}
if (useAuthStore.getState().isAuthenticated()) {
return useAuthStore.getState().accessToken
}
useAuthStore.getState().logout()
return null
})().finally(() => {

View File

@ -65,22 +65,122 @@ export async function respondAuthFlow(
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", {
export function isOAuthAuthorizeRedirect(target: string): boolean {
return target.includes("/application/o/authorize")
}
export type EmbeddedAuthContext = {
authorizeUrl: string
flowQuery: string
executorBase: string
}
/**
* Prepare the embedded authentication context: sets PKCE/state cookies (read later by the OIDC
* callback) and returns the same-origin Authentik flow executor base + the `next` query that ties
* the login to the pending OIDC authorize request.
*/
export async function fetchEmbeddedAuthContext(
returnTo: string
): Promise<EmbeddedAuthContext> {
const params = new URLSearchParams({ embedded: "1", returnTo })
const res = await fetch(`/api/auth/login?${params.toString()}`, {
credentials: "include",
headers: { Accept: "application/json" },
})
const body = (await res.json()) as Partial<EmbeddedAuthContext> & {
error?: string
}
if (!res.ok || !body.authorizeUrl || !body.executorBase || !body.flowQuery) {
throw new Error(body.error ?? `embedded auth context failed (${res.status})`)
}
return {
authorizeUrl: body.authorizeUrl,
flowQuery: body.flowQuery,
executorBase: body.executorBase,
}
}
function directExecutorUrl(
executorBase: string,
slug: AuthFlowSlug,
query?: string
): string {
let url = `${executorBase}/${encodeURIComponent(slug)}/`
const trimmed = query?.trim()
if (trimmed) {
url += `?query=${encodeURIComponent(trimmed)}`
}
return url
}
/** Read a non-HttpOnly cookie value from the document (browser only). */
function readCookie(name: string): string | null {
if (typeof document === "undefined") return null
const escaped = name.replace(/([.$?*|{}()[\]\\/+^])/g, "\\$1")
const match = document.cookie.match(new RegExp(`(?:^|;\\s*)${escaped}=([^;]*)`))
return match ? decodeURIComponent(match[1]!) : null
}
/**
* Headers for direct Authentik executor calls. When the browser already holds an authenticated
* `authentik_session`, Authentik (DRF SessionAuthentication) enforces CSRF on POST. Mirror
* Authentik's own SPA by forwarding the `authentik_csrf` cookie as the `X-authentik-CSRF` header.
*/
function directExecutorHeaders(extra?: Record<string, string>): Record<string, string> {
const headers: Record<string, string> = { Accept: "application/json", ...extra }
const csrf = readCookie("authentik_csrf")
if (csrf) headers["X-authentik-CSRF"] = csrf
return headers
}
async function parseDirectChallenge(res: Response): Promise<FlowStepResponse> {
let data: FlowChallenge
try {
data = (await res.json()) as FlowChallenge
} catch {
throw new Error(`flow request failed (${res.status})`)
}
const component = typeof data.component === "string" ? data.component : ""
if (!component && !res.ok) {
const message = (data as FlowApiError)?.message
throw new Error(message ?? `flow request failed (${res.status})`)
}
return {
sessionId: "",
challenge: data,
done: component === "xak-flow-redirect",
denied: component === "ak-stage-access-denied",
}
}
/** Start the Authentik flow directly in the browser (same-origin), so the browser holds the session. */
export async function startDirectFlow(
executorBase: string,
slug: AuthFlowSlug,
query?: string
): Promise<FlowStepResponse> {
const res = await fetch(directExecutorUrl(executorBase, slug, query), {
credentials: "include",
headers: directExecutorHeaders(),
})
return parseDirectChallenge(res)
}
/** Submit a stage response directly to the Authentik flow executor (payload includes `component`). */
export async function respondDirectFlow(
executorBase: string,
slug: AuthFlowSlug,
payload: Record<string, unknown>,
query?: string
): Promise<FlowStepResponse> {
const res = await fetch(directExecutorUrl(executorBase, slug, query), {
method: "POST",
credentials: "include",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify({ returnTo }),
headers: directExecutorHeaders({ "Content-Type": "application/json" }),
body: JSON.stringify(payload),
})
const body = (await res.json()) as FlowCompleteResponse & FlowApiError
if (!res.ok) {
throw new Error(body.message ?? `flow complete failed (${res.status})`)
}
return body
return parseDirectChallenge(res)
}
export function flowComponent(challenge: FlowChallenge | null | undefined): string {

View File

@ -16,3 +16,16 @@ export function buildOidcLoginUrl(options?: {
}
return `/api/auth/login?${params.toString()}`
}
/** Normalize BFF /flows/complete redirect (MAIL_APP_URL may wrongly include /mail). */
export function resolveAuthLoginRedirect(redirectUrl: string): string {
try {
const target = new URL(redirectUrl, window.location.origin)
if (target.pathname.startsWith("/mail/api/auth/")) {
target.pathname = target.pathname.slice("/mail".length)
}
return target.toString()
} catch {
return redirectUrl.replace(/^\/mail(?=\/api\/auth\/)/, "")
}
}

View File

@ -35,6 +35,15 @@ export function getAuthentikBase(): string {
/** Authentik enrollment flow (same origin as the suite — nginx /auth/). */
const AUTHENTIK_ENROLLMENT_PATH = "/auth/if/flow/ulti-enrollment/"
/** OIDC RP-initiated logout (invalidates Authentik session for the Ulti app). */
export const OIDC_END_SESSION_PATH = "/auth/application/o/ulti/end-session/"
/** Post-logout landing (suite homepage). */
export const POST_LOGOUT_PATH = "/"
/** Authentik recovery flow (same origin as the suite — nginx /auth/). */
const AUTHENTIK_RECOVERY_PATH = "/auth/if/flow/ulti-recovery/"
/** In-app signup page (Phase 2 custom UI via ultid BFF). */
export const SIGNUP_PATH = "/signup"
@ -45,11 +54,14 @@ export const FORGOT_PASSWORD_PATH = "/forgot-password"
export const RESET_PASSWORD_PATH = "/reset-password"
export function getSignupUrl(): string {
return SIGNUP_PATH
return getAuthentikEnrollmentUrl()
}
export function getForgotPasswordUrl(): string {
if (useNativeRuntime()) {
return FORGOT_PASSWORD_PATH
}
return AUTHENTIK_RECOVERY_PATH
}
export function getResetPasswordUrl(): string {
@ -65,6 +77,11 @@ export function getAuthentikEnrollmentUrl(): string {
return AUTHENTIK_ENROLLMENT_PATH
}
/** Browser navigation target after local session teardown (Authentik end-session flow). */
export function getOidcEndSessionPath(): string {
return OIDC_END_SESSION_PATH
}
type OidcDiscovery = {
authorization_endpoint: string
token_endpoint: string

View File

@ -45,6 +45,20 @@ export function computeExpiresAt(expiresIn: number): number {
return Date.now() + expiresIn * 1000
}
export function resolveSessionExpiresAt(
accessToken: string | undefined,
expiresAtRaw: string | undefined
): number {
const expiresAt = Number(expiresAtRaw)
if (Number.isFinite(expiresAt)) return expiresAt
const claims = accessToken ? decodeJwtPayload(accessToken) : null
const exp = claims?.exp
if (typeof exp === "number") return exp * 1000
return computeExpiresAt(3600)
}
export function isIdTokenJwtValid(accessToken: string | undefined): boolean {
if (!accessToken) return false
const claims = decodeJwtPayload(accessToken)

View File

@ -17,6 +17,7 @@ export function useChromeIdentity(): {
const isDemoApp = useIsDemoApp()
const authHydrated = usePersistHydrated(useAuthStore)
const accountHydrated = usePersistHydrated(useAccountStore)
const isAuthenticated = useAuthStore((s) => s.isAuthenticated())
const platformUser = useAuthStore((s) => s.user)
const mailAccount = useActiveAccount()
const { data: currentUser } = useCurrentUser()
@ -31,6 +32,7 @@ export function useChromeIdentity(): {
// Keep SSR and first client render identical until persist stores rehydrate.
if (!authHydrated) return null
if (!isAuthenticated) return null
if (!platformUser && !accountHydrated) return null
if (platformUser) {

View File

@ -2,10 +2,12 @@
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
import { useQueryClient } from '@tanstack/react-query'
import { useAuthStore, AUTH_STORAGE_KEY, LEGACY_AUTH_KEYS } from '@/lib/api/auth-store'
import { useMailAccounts } from '@/lib/api/hooks/use-mail-queries'
import { debouncedPersistJSONStorage } from '@/lib/stores/debounced-json-storage'
import { useQueryClient } from '@tanstack/react-query'
import { clearClientAuthState } from '@/lib/auth/clear-client-auth-state'
import { useMailAccounts } from '@/lib/api/hooks/use-mail-queries'
import { getOidcEndSessionPath, POST_LOGOUT_PATH } from '@/lib/auth/oidc-config'
import { useNativeRuntime } from '@/lib/platform'
import type { ApiMailAccount } from '@/lib/api/types'
type AccountStoreState = {
@ -52,18 +54,15 @@ export function useActiveAccount(): ApiMailAccount | null {
export function useSignOutAll() {
const queryClient = useQueryClient()
const native = useNativeRuntime()
return async () => {
await fetch("/api/auth/logout", { method: "POST", credentials: "include" })
if (typeof window !== "undefined") {
localStorage.removeItem(AUTH_STORAGE_KEY)
for (const legacy of LEGACY_AUTH_KEYS) {
localStorage.removeItem(legacy)
clearClientAuthState(queryClient)
if (native) {
window.location.href = POST_LOGOUT_PATH
return
}
}
useAuthStore.getState().logout()
queryClient.clear()
useAccountStore.setState({ activeAccountId: null, otherAccountsExpanded: true })
window.location.href = "/login"
window.location.assign(getOidcEndSessionPath())
}
}

View File

@ -72,6 +72,10 @@ function buildDebouncedJsonStorage(): PersistStorage<unknown> {
window.addEventListener("pagehide", flushAll)
}
const flushPersistStorage = () => {
flushAll()
}
return {
getItem: (name) => base.getItem(name),
setItem: (name, value) => {
@ -93,8 +97,17 @@ function buildDebouncedJsonStorage(): PersistStorage<unknown> {
pending.delete(name)
return base.removeItem(name)
},
flush: flushPersistStorage,
}
}
/** Shared instance for all zustand `persist` stores in this app. */
export const debouncedPersistJSONStorage = buildDebouncedJsonStorage()
/** Flush pending debounced persist writes (call before hard navigation on logout). */
export function flushPersistStorage() {
const storage = debouncedPersistJSONStorage as PersistStorage<unknown> & {
flush?: () => void
}
storage.flush?.()
}

View File

@ -25,7 +25,10 @@ function suitePublicEnv() {
"localhost"
).trim()
const s = suiteSecureSuffix()
const origin = `http${s}://${host}`
const devPort = process.env.NEXT_DEV_PORT?.trim() || "3004"
const hasPort = host.includes(":")
const origin =
s || hasPort ? `http${s}://${host}` : `http${s}://${host}:${devPort}`
return {
NEXT_PUBLIC_APP_URL: origin,
NEXT_PUBLIC_WS_URL: `ws${s}://${host}/ws`,

View File

@ -0,0 +1,169 @@
// Headless dump of an AUTHENTICATED Authentik page (user-settings SPA, etc.).
//
// The user-settings interface (/auth/if/user/) redirects to login when
// unauthenticated, so a plain dump is useless. This variant logs in first.
//
// Two auth modes:
// 1) Recovery link (no password needed). Generate one on the host with:
// docker exec deploy-authentik-server-1 ak create_recovery_key 1 akadmin
// then pass the returned "/auth/recovery/use-token/<token>/" path.
// 2) Identification + password (set AK_USER / AK_PASS env vars).
//
// Usage:
// AK_RECOVERY="/auth/recovery/use-token/XXer/" \
// node scripts/authentik-dom-dump-auth.mjs "<targetUrl>" "<outName>" "<light|dark>"
// # or
// AK_USER=akadmin AK_PASS=... \
// node scripts/authentik-dom-dump-auth.mjs "<targetUrl>" "<outName>" "<light|dark>"
import { chromium } from "@playwright/test"
import { writeFileSync } from "node:fs"
const target = process.argv[2] ?? "http://localhost/auth/if/user/#/settings"
const outName = process.argv[3] ?? "user-settings"
const theme = process.argv[4] ?? "light"
const origin = process.env.AK_ORIGIN ?? "http://localhost"
const recovery = process.env.AK_RECOVERY ?? ""
const akUser = process.env.AK_USER ?? "akadmin"
const akPass = process.env.AK_PASS ?? ""
const outDir = "/tmp/authentik-dom"
await import("node:fs").then((fs) => fs.mkdirSync(outDir, { recursive: true }))
const browser = await chromium.launch()
const ctx = await browser.newContext({
viewport: { width: 1280, height: 900 },
colorScheme: theme === "dark" ? "dark" : "light",
ignoreHTTPSErrors: true,
})
const page = await ctx.newPage()
// Authentik advertises api.base as https://localhost, but nginx is http-only here.
await page.addInitScript(() => {
Object.defineProperty(window, "authentik", {
configurable: true,
set(v) {
try {
if (v && v.api) {
v.api.base = location.origin + "/auth/"
v.api.relBase = "/auth/"
}
} catch {}
Object.defineProperty(window, "authentik", {
value: v,
writable: true,
configurable: true,
})
},
get() {
return undefined
},
})
})
async function loginWithRecovery() {
const url = recovery.startsWith("http") ? recovery : origin + recovery
await page.goto(url, { waitUntil: "networkidle", timeout: 30000 })
// Recovery flow may present a "use this token" confirmation or land straight
// on the user interface. Give it time and click any primary continue button.
await page.waitForTimeout(2500)
for (let i = 0; i < 4; i++) {
const clicked = await page.evaluate(() => {
function findBtn(root) {
const btn = root.querySelector?.("button.pf-m-primary, button[type=submit]")
if (btn) return btn
const all = root.querySelectorAll?.("*") ?? []
for (const el of all) {
if (el.shadowRoot) {
const f = findBtn(el.shadowRoot)
if (f) return f
}
}
return null
}
const b = findBtn(document)
if (b) {
b.click()
return true
}
return false
})
if (!clicked) break
await page.waitForTimeout(1500)
}
}
async function loginWithPassword() {
await page.goto(`${origin}/auth/if/flow/default-authentication-flow/`, {
waitUntil: "networkidle",
timeout: 30000,
})
await page.waitForTimeout(2000)
// Fill identification, submit, then password, submit — reaching across shadow roots.
const typeAndSubmit = async (value) => {
await page.evaluate((val) => {
function findInput(root) {
const i = root.querySelector?.("input[name=uidField], input[name=password], input")
if (i) return i
for (const el of root.querySelectorAll?.("*") ?? []) {
if (el.shadowRoot) {
const f = findInput(el.shadowRoot)
if (f) return f
}
}
return null
}
const input = findInput(document)
if (input) {
input.value = val
input.dispatchEvent(new Event("input", { bubbles: true }))
}
}, value)
await page.keyboard.press("Enter")
await page.waitForTimeout(2500)
}
await typeAndSubmit(akUser)
await typeAndSubmit(akPass)
}
if (recovery) {
await loginWithRecovery()
} else {
await loginWithPassword()
}
// Now navigate to the authenticated target.
await page.goto(target, { waitUntil: "networkidle", timeout: 30000 })
await page.waitForTimeout(3500)
// Recursively serialize light + shadow DOM into an indented outline.
const outline = await page.evaluate(() => {
function attrs(el) {
return [...el.attributes]
.filter((a) => !["style"].includes(a.name))
.map((a) => (a.value ? `${a.name}="${a.value}"` : a.name))
.join(" ")
}
function walk(node, depth, lines) {
const pad = " ".repeat(depth)
if (node.nodeType === Node.ELEMENT_NODE) {
const tag = node.tagName.toLowerCase()
const a = attrs(node)
lines.push(`${pad}<${tag}${a ? " " + a : ""}>`)
if (node.shadowRoot) {
lines.push(`${pad} #shadow-root`)
for (const c of node.shadowRoot.children) walk(c, depth + 2, lines)
}
for (const c of node.children) walk(c, depth + 1, lines)
}
return lines
}
const lines = []
walk(document.documentElement, 0, lines)
return lines.slice(0, 2000).join("\n")
})
writeFileSync(`${outDir}/${outName}-${theme}.dom.txt`, outline)
await page.screenshot({ path: `${outDir}/${outName}-${theme}.png`, fullPage: true })
console.log(`wrote ${outDir}/${outName}-${theme}.dom.txt (+ .png) — final url: ${page.url()}`)
await browser.close()

View File

@ -0,0 +1,80 @@
// Headless dump of Authentik flow shadow DOM + screenshot.
// Usage: node scripts/authentik-dom-dump.mjs <url> <outName> [theme]
import { chromium } from "@playwright/test"
import { writeFileSync } from "node:fs"
const url = process.argv[2] ?? "http://localhost/auth/if/flow/default-authentication-flow/"
const outName = process.argv[3] ?? "login"
const theme = process.argv[4] ?? "light" // light | dark
const outDir = "/tmp/authentik-dom"
await import("node:fs").then((fs) => fs.mkdirSync(outDir, { recursive: true }))
const browser = await chromium.launch()
const ctx = await browser.newContext({
viewport: { width: 1280, height: 900 },
colorScheme: theme === "dark" ? "dark" : "light",
ignoreHTTPSErrors: true,
})
const page = await ctx.newPage()
// Authentik advertises api.base as https://localhost (X-Forwarded-Proto), but nginx is http-only
// here. Rewrite api.base to a same-origin relative URL before the flow bundle reads it.
await page.addInitScript(() => {
Object.defineProperty(window, "authentik", {
configurable: true,
set(v) {
try {
if (v && v.api) {
v.api.base = location.origin + "/auth/"
v.api.relBase = "/auth/"
}
} catch {}
Object.defineProperty(window, "authentik", {
value: v,
writable: true,
configurable: true,
})
},
get() {
return undefined
},
})
})
await page.goto(url, { waitUntil: "networkidle", timeout: 30000 })
// Give Lit components time to render the stage.
await page.waitForTimeout(2500)
// Recursively serialize light + shadow DOM into an indented outline.
const outline = await page.evaluate(() => {
function attrs(el) {
return [...el.attributes]
.filter((a) => !["style"].includes(a.name))
.map((a) => (a.value ? `${a.name}="${a.value}"` : a.name))
.join(" ")
}
function walk(node, depth, lines) {
const pad = " ".repeat(depth)
if (node.nodeType === Node.ELEMENT_NODE) {
const tag = node.tagName.toLowerCase()
const a = attrs(node)
lines.push(`${pad}<${tag}${a ? " " + a : ""}>`)
if (node.shadowRoot) {
lines.push(`${pad} #shadow-root`)
for (const c of node.shadowRoot.children) walk(c, depth + 2, lines)
}
for (const c of node.children) walk(c, depth + 1, lines)
}
return lines
}
const lines = []
walk(document.documentElement, 0, lines)
// Cap to keep output readable.
return lines.slice(0, 1200).join("\n")
})
writeFileSync(`${outDir}/${outName}-${theme}.dom.txt`, outline)
await page.screenshot({ path: `${outDir}/${outName}-${theme}.png`, fullPage: true })
console.log(`wrote ${outDir}/${outName}-${theme}.dom.txt (+ .png)`)
await browser.close()

File diff suppressed because one or more lines are too long