feat(auth): enhance authentication flows with embedded support and UI improvements
Some checks are pending
E2E / Playwright e2e (push) Waiting to run
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:
parent
ee05c804f9
commit
9ea2d3325d
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 })
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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,76 +76,101 @@ 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) {
|
||||
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
|
||||
}
|
||||
const finishAuthentication = useCallback(
|
||||
(finalChallenge?: FlowChallenge | null) => {
|
||||
if (!directMode || bridgedRef.current) {
|
||||
onSuccess?.()
|
||||
return
|
||||
}
|
||||
} catch (err) {
|
||||
setFlowError(
|
||||
err instanceof Error ? err.message : "Impossible de démarrer le parcours"
|
||||
)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [bridgeAuthentication, flowQuery, onSuccess, redirectToOidcLogin, slug])
|
||||
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(() => {
|
||||
void bootstrap()
|
||||
}, [bootstrap])
|
||||
if (bootstrappedRef.current) return
|
||||
bootstrappedRef.current = true
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (payload: Record<string, unknown>) => {
|
||||
if (!sessionId) return
|
||||
setSubmitting(true)
|
||||
setFlowError(null)
|
||||
void (async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const step = await respondAuthFlow(slug, payload, 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) {
|
||||
await finishAuthentication()
|
||||
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")
|
||||
@ -151,7 +178,7 @@ export function AuthFlowPage({
|
||||
setSubmitting(false)
|
||||
}
|
||||
},
|
||||
[finishAuthentication, flowQuery, sessionId, slug]
|
||||
[directMode, finishAuthentication, flowQuery, slug]
|
||||
)
|
||||
|
||||
const validationErrors = useMemo(() => flowValidationErrors(challenge), [challenge])
|
||||
|
||||
@ -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 {
|
||||
await fetch("/api/auth/logout", { method: "POST", credentials: "include" })
|
||||
clearClientAuthState(queryClient)
|
||||
router.replace(POST_LOGOUT_PATH)
|
||||
return
|
||||
}
|
||||
logout()
|
||||
|
||||
await fetch("/api/auth/logout", { method: "POST", credentials: "include" })
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,26 +1,70 @@
|
||||
"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}
|
||||
defaultTitle="Mot de passe oublié"
|
||||
defaultDescription="Indiquez votre adresse e-mail pour recevoir un lien de réinitialisation."
|
||||
successTitle="E-mail envoyé"
|
||||
successDescription="Si un compte existe pour cette adresse, vous recevrez un e-mail avec les instructions."
|
||||
successActionLabel="Retour à la connexion"
|
||||
successHref="/login"
|
||||
footer={loginFooter}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthFlowPage
|
||||
slug={AUTH_FLOW_SLUGS.recovery}
|
||||
defaultTitle="Mot de passe oublié"
|
||||
defaultDescription="Indiquez votre adresse e-mail pour recevoir un lien de réinitialisation."
|
||||
successTitle="E-mail envoyé"
|
||||
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>
|
||||
}
|
||||
/>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
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>
|
||||
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 (
|
||||
<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>
|
||||
}
|
||||
/>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,27 +1,65 @@
|
||||
"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}
|
||||
defaultTitle="Créer un compte"
|
||||
defaultDescription="Choisissez votre identifiant @ultisuite.fr et configurez votre profil."
|
||||
successTitle="Compte créé"
|
||||
successDescription="Votre compte UltiSpace est prêt. Connectez-vous pour accéder à la suite."
|
||||
successActionLabel="Se connecter"
|
||||
successHref={oidcHref}
|
||||
successExternal
|
||||
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>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthFlowPage
|
||||
slug={AUTH_FLOW_SLUGS.enrollment}
|
||||
defaultTitle="Créer un compte"
|
||||
defaultDescription="Choisissez votre identifiant @ultisuite.fr et configurez votre profil."
|
||||
successTitle="Compte créé"
|
||||
successDescription="Votre compte UltiSpace est prêt. Connectez-vous pour accéder à la suite."
|
||||
successActionLabel="Se connecter"
|
||||
successHref={oidcHref}
|
||||
successExternal
|
||||
<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 ?{" "}
|
||||
@ -30,6 +68,16 @@ export function SignupPageContent({ returnTo = "/mail/inbox" }: SignupPageConten
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
95
components/compte/authentik-embed-dialog.tsx
Normal file
95
components/compte/authentik-embed-dialog.tsx
Normal 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'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>
|
||||
)
|
||||
}
|
||||
@ -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,25 +51,29 @@ 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} />
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="h-9 shrink-0 rounded-full px-4 text-sm font-medium"
|
||||
asChild
|
||||
>
|
||||
<a href={url} target="_blank" rel="noreferrer">
|
||||
<>
|
||||
<CompteSettingsCard>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<PanelHeader icon={icon} title={title} description={description} />
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="h-9 shrink-0 rounded-full px-4 text-sm font-medium"
|
||||
onClick={() => setEmbedOpen(true)}
|
||||
>
|
||||
{actionLabel}
|
||||
<ExternalLink className="size-3.5" aria-hidden />
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
<p className="mt-3 text-xs text-muted-foreground">
|
||||
Ouverture du portail d'identité Authentik dans un nouvel onglet.
|
||||
</p>
|
||||
</CompteSettingsCard>
|
||||
</Button>
|
||||
</div>
|
||||
</CompteSettingsCard>
|
||||
|
||||
<AuthentikEmbedDialog
|
||||
url={url}
|
||||
title={title}
|
||||
description={description}
|
||||
open={embedOpen}
|
||||
onOpenChange={setEmbedOpen}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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"] })
|
||||
},
|
||||
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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
|
||||
|
||||
21
lib/auth/clear-client-auth-state.ts
Normal file
21
lib/auth/clear-client-auth-state.ts
Normal 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()
|
||||
}
|
||||
@ -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(() => {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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\/)/, "")
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
return FORGOT_PASSWORD_PATH
|
||||
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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@ -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?.()
|
||||
}
|
||||
|
||||
@ -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`,
|
||||
|
||||
169
scripts/authentik-dom-dump-auth.mjs
Normal file
169
scripts/authentik-dom-dump-auth.mjs
Normal 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()
|
||||
80
scripts/authentik-dom-dump.mjs
Normal file
80
scripts/authentik-dom-dump.mjs
Normal 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
Loading…
Reference in New Issue
Block a user