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 requestUrl = new URL(request.url)
|
||||||
const returnTo = requestUrl.searchParams.get("returnTo") ?? "/mail/inbox"
|
const returnTo = requestUrl.searchParams.get("returnTo") ?? "/mail/inbox"
|
||||||
const intent = requestUrl.searchParams.get("intent")
|
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 =
|
const prompt =
|
||||||
requestUrl.searchParams.get("prompt") ??
|
promptParam ??
|
||||||
(intent === "add_account" ? "login select_account" : "select_account")
|
(bridge || embedded
|
||||||
|
? null
|
||||||
|
: intent === "add_account"
|
||||||
|
? "login select_account"
|
||||||
|
: "select_account")
|
||||||
|
|
||||||
const jar = await cookies()
|
const jar = await cookies()
|
||||||
const existingUser = platformUserFromToken(
|
const existingUser = platformUserFromToken(
|
||||||
@ -54,12 +64,16 @@ export async function GET(request: Request) {
|
|||||||
state,
|
state,
|
||||||
code_challenge: challenge,
|
code_challenge: challenge,
|
||||||
code_challenge_method: "S256",
|
code_challenge_method: "S256",
|
||||||
prompt,
|
|
||||||
})
|
})
|
||||||
|
if (prompt) {
|
||||||
|
params.set("prompt", prompt)
|
||||||
|
}
|
||||||
|
|
||||||
const response = NextResponse.redirect(
|
const authorizeUrl = `${cfg.authorizationEndpoint}?${params.toString()}`
|
||||||
`${cfg.authorizationEndpoint}?${params.toString()}`
|
|
||||||
)
|
const response = embedded
|
||||||
|
? NextResponse.json(buildEmbeddedContext(authorizeUrl))
|
||||||
|
: NextResponse.redirect(authorizeUrl)
|
||||||
const cookieOpts = oauthCookieOptions()
|
const cookieOpts = oauthCookieOptions()
|
||||||
response.cookies.set(PKCE_COOKIE, verifier, cookieOpts)
|
response.cookies.set(PKCE_COOKIE, verifier, cookieOpts)
|
||||||
response.cookies.set(STATE_COOKIE, state, cookieOpts)
|
response.cookies.set(STATE_COOKIE, state, cookieOpts)
|
||||||
@ -73,3 +87,31 @@ export async function GET(request: Request) {
|
|||||||
|
|
||||||
return response
|
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,
|
isAccessTokenValid,
|
||||||
isIdTokenJwtValid,
|
isIdTokenJwtValid,
|
||||||
resolveBearerToken,
|
resolveBearerToken,
|
||||||
|
resolveSessionExpiresAt,
|
||||||
} from "@/lib/auth/session"
|
} from "@/lib/auth/session"
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
@ -23,7 +24,7 @@ export async function GET() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isAccessTokenValid(accessToken, expiresAtRaw)) {
|
if (isAccessTokenValid(accessToken, expiresAtRaw)) {
|
||||||
const expiresAt = Number(expiresAtRaw)
|
const expiresAt = resolveSessionExpiresAt(accessToken, expiresAtRaw)
|
||||||
const user = platformUserFromToken(accessToken!)
|
const user = platformUserFromToken(accessToken!)
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
authenticated: true,
|
authenticated: true,
|
||||||
@ -47,7 +48,7 @@ export async function GET() {
|
|||||||
bearer = resolveBearerToken(tokens)
|
bearer = resolveBearerToken(tokens)
|
||||||
} catch {
|
} catch {
|
||||||
if (accessToken && isIdTokenJwtValid(accessToken)) {
|
if (accessToken && isIdTokenJwtValid(accessToken)) {
|
||||||
const expiresAt = Number(expiresAtRaw) || computeExpiresAt(3600)
|
const expiresAt = resolveSessionExpiresAt(accessToken, expiresAtRaw)
|
||||||
const user = platformUserFromToken(accessToken)
|
const user = platformUserFromToken(accessToken)
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
authenticated: true,
|
authenticated: true,
|
||||||
|
|||||||
@ -61,10 +61,11 @@ export function FilePoliciesSection() {
|
|||||||
<SettingsGrid columns={1} className="space-y-4">
|
<SettingsGrid columns={1} className="space-y-4">
|
||||||
<SettingsGrid columns={2} className="sm:grid-cols-3">
|
<SettingsGrid columns={2} className="sm:grid-cols-3">
|
||||||
<SettingsField label="Taille max upload">
|
<SettingsField label="Taille max upload">
|
||||||
<InputGroup>
|
<InputGroup className="w-fit max-w-full">
|
||||||
<InputGroupInput
|
<InputGroupInput
|
||||||
type="number"
|
type="number"
|
||||||
min={1}
|
min={1}
|
||||||
|
className="w-20 flex-none tabular-nums"
|
||||||
value={filePolicies.max_upload_mib}
|
value={filePolicies.max_upload_mib}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setFilePolicies({ max_upload_mib: Number(e.target.value) || 1 })
|
setFilePolicies({ max_upload_mib: Number(e.target.value) || 1 })
|
||||||
@ -76,10 +77,11 @@ export function FilePoliciesSection() {
|
|||||||
</InputGroup>
|
</InputGroup>
|
||||||
</SettingsField>
|
</SettingsField>
|
||||||
<SettingsField label="Expiration liens par défaut">
|
<SettingsField label="Expiration liens par défaut">
|
||||||
<InputGroup>
|
<InputGroup className="w-fit max-w-full">
|
||||||
<InputGroupInput
|
<InputGroupInput
|
||||||
type="number"
|
type="number"
|
||||||
min={1}
|
min={1}
|
||||||
|
className="w-20 flex-none tabular-nums"
|
||||||
value={filePolicies.default_link_expiry_days}
|
value={filePolicies.default_link_expiry_days}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setFilePolicies({
|
setFilePolicies({
|
||||||
@ -93,10 +95,11 @@ export function FilePoliciesSection() {
|
|||||||
</InputGroup>
|
</InputGroup>
|
||||||
</SettingsField>
|
</SettingsField>
|
||||||
<SettingsField label="Rétention corbeille">
|
<SettingsField label="Rétention corbeille">
|
||||||
<InputGroup>
|
<InputGroup className="w-fit max-w-full">
|
||||||
<InputGroupInput
|
<InputGroupInput
|
||||||
type="number"
|
type="number"
|
||||||
min={1}
|
min={1}
|
||||||
|
className="w-20 flex-none tabular-nums"
|
||||||
value={filePolicies.retention_trash_days}
|
value={filePolicies.retention_trash_days}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setFilePolicies({ retention_trash_days: Number(e.target.value) || 1 })
|
setFilePolicies({ retention_trash_days: Number(e.target.value) || 1 })
|
||||||
|
|||||||
@ -16,6 +16,7 @@ import {
|
|||||||
InputGroupInput,
|
InputGroupInput,
|
||||||
InputGroupText,
|
InputGroupText,
|
||||||
} from "@/components/ui/input-group"
|
} from "@/components/ui/input-group"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
export function QuotasSection() {
|
export function QuotasSection() {
|
||||||
const storageQuotas = useOrgSettingsStore((s) => s.storageQuotas)
|
const storageQuotas = useOrgSettingsStore((s) => s.storageQuotas)
|
||||||
@ -60,7 +61,6 @@ export function QuotasSection() {
|
|||||||
min={50}
|
min={50}
|
||||||
max={100}
|
max={100}
|
||||||
fallback={90}
|
fallback={90}
|
||||||
className="max-w-xs"
|
|
||||||
value={storageQuotas.warn_threshold_pct}
|
value={storageQuotas.warn_threshold_pct}
|
||||||
onChange={(v) => setStorageQuotas({ warn_threshold_pct: v })}
|
onChange={(v) => setStorageQuotas({ warn_threshold_pct: v })}
|
||||||
/>
|
/>
|
||||||
@ -95,7 +95,6 @@ export function QuotasSection() {
|
|||||||
min={50}
|
min={50}
|
||||||
max={100}
|
max={100}
|
||||||
fallback={80}
|
fallback={80}
|
||||||
className="max-w-xs"
|
|
||||||
value={usageQuotas.llm_cost_warn_threshold_pct}
|
value={usageQuotas.llm_cost_warn_threshold_pct}
|
||||||
onChange={(v) => setUsageQuotas({ llm_cost_warn_threshold_pct: v })}
|
onChange={(v) => setUsageQuotas({ llm_cost_warn_threshold_pct: v })}
|
||||||
/>
|
/>
|
||||||
@ -179,13 +178,14 @@ function SettingsNumberField({
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<SettingsField label={label}>
|
<SettingsField label={label}>
|
||||||
<InputGroup className={className}>
|
<InputGroup className={cn("w-fit max-w-full", className)}>
|
||||||
<InputGroupInput
|
<InputGroupInput
|
||||||
type="number"
|
type="number"
|
||||||
min={min}
|
min={min}
|
||||||
max={max}
|
max={max}
|
||||||
step={step}
|
step={step}
|
||||||
value={value}
|
value={value}
|
||||||
|
className="w-20 flex-none tabular-nums"
|
||||||
onChange={(e) => onChange(Number(e.target.value) || fallback)}
|
onChange={(e) => onChange(Number(e.target.value) || fallback)}
|
||||||
/>
|
/>
|
||||||
<InputGroupAddon align="inline-end">
|
<InputGroupAddon align="inline-end">
|
||||||
|
|||||||
@ -35,6 +35,7 @@ import { calendarColor } from "@/lib/agenda/agenda-events"
|
|||||||
import { useAgendaSettingsStore, useAgendaUIStore } from "@/lib/agenda/agenda-store"
|
import { useAgendaSettingsStore, useAgendaUIStore } from "@/lib/agenda/agenda-store"
|
||||||
import type { AgendaCalendar } from "@/lib/agenda/agenda-types"
|
import type { AgendaCalendar } from "@/lib/agenda/agenda-types"
|
||||||
import { useMergedAgendaCalendars } from "@/lib/agenda/use-visible-agenda-calendars"
|
import { useMergedAgendaCalendars } from "@/lib/agenda/use-visible-agenda-calendars"
|
||||||
|
import { usePersistHydrated } from "@/hooks/use-persist-hydrated"
|
||||||
import { useIsMobile } from "@/hooks/use-mobile"
|
import { useIsMobile } from "@/hooks/use-mobile"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
@ -48,9 +49,11 @@ export function AgendaSidebar({
|
|||||||
onCreateEvent: () => void
|
onCreateEvent: () => void
|
||||||
}) {
|
}) {
|
||||||
const isMobile = useIsMobile()
|
const isMobile = useIsMobile()
|
||||||
|
const settingsHydrated = usePersistHydrated(useAgendaSettingsStore)
|
||||||
const sidebarCollapsed = useAgendaUIStore((s) => s.sidebarCollapsed)
|
const sidebarCollapsed = useAgendaUIStore((s) => s.sidebarCollapsed)
|
||||||
const setSidebarCollapsed = useAgendaUIStore((s) => s.setSidebarCollapsed)
|
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 weekStart = useAgendaSettingsStore((s) => s.weekStart)
|
||||||
const calendarViews = useAgendaSettingsStore((s) => s.calendarViews)
|
const calendarViews = useAgendaSettingsStore((s) => s.calendarViews)
|
||||||
const activeCalendarViewId = useAgendaSettingsStore((s) => s.activeCalendarViewId)
|
const activeCalendarViewId = useAgendaSettingsStore((s) => s.activeCalendarViewId)
|
||||||
@ -144,7 +147,7 @@ export function AgendaSidebar({
|
|||||||
<Plus className="size-4" />
|
<Plus className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{calendarViews.length > 0 ? (
|
{settingsHydrated && calendarViews.length > 0 ? (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@ -11,14 +11,17 @@ import {
|
|||||||
type AuthFlowSlug,
|
type AuthFlowSlug,
|
||||||
} from "@/lib/auth/auth-flow-slugs"
|
} from "@/lib/auth/auth-flow-slugs"
|
||||||
import {
|
import {
|
||||||
completeAuthFlow,
|
fetchEmbeddedAuthContext,
|
||||||
flowComponent,
|
flowComponent,
|
||||||
flowRedirectUrl,
|
flowRedirectUrl,
|
||||||
flowTitle,
|
flowTitle,
|
||||||
flowValidationErrors,
|
flowValidationErrors,
|
||||||
|
isOAuthAuthorizeRedirect,
|
||||||
isRecoveryEmailSent,
|
isRecoveryEmailSent,
|
||||||
respondAuthFlow,
|
respondAuthFlow,
|
||||||
|
respondDirectFlow,
|
||||||
startAuthFlow,
|
startAuthFlow,
|
||||||
|
startDirectFlow,
|
||||||
type FlowChallenge,
|
type FlowChallenge,
|
||||||
} from "@/lib/auth/flow-api"
|
} from "@/lib/auth/flow-api"
|
||||||
|
|
||||||
@ -66,7 +69,6 @@ export function AuthFlowPage({
|
|||||||
footer,
|
footer,
|
||||||
onSuccess,
|
onSuccess,
|
||||||
}: AuthFlowPageProps) {
|
}: AuthFlowPageProps) {
|
||||||
const [sessionId, setSessionId] = useState<string | null>(null)
|
|
||||||
const [challenge, setChallenge] = useState<FlowChallenge | null>(null)
|
const [challenge, setChallenge] = useState<FlowChallenge | null>(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [submitting, setSubmitting] = useState(false)
|
const [submitting, setSubmitting] = useState(false)
|
||||||
@ -74,46 +76,66 @@ export function AuthFlowPage({
|
|||||||
const [flowError, setFlowError] = useState<string | null>(null)
|
const [flowError, setFlowError] = useState<string | null>(null)
|
||||||
const [done, setDone] = useState(false)
|
const [done, setDone] = useState(false)
|
||||||
const [denied, setDenied] = useState(false)
|
const [denied, setDenied] = useState(false)
|
||||||
|
const flowQueryRef = useRef<string | undefined>(flowQuery)
|
||||||
|
const bootstrappedRef = useRef(false)
|
||||||
const bridgedRef = 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 redirectError = decodeAuthError(initialError)
|
||||||
|
|
||||||
const finishAuthentication = useCallback(async () => {
|
const finishAuthentication = useCallback(
|
||||||
if (!bridgeAuthentication || !isAuthenticationFlow(slug) || bridgedRef.current) {
|
(finalChallenge?: FlowChallenge | null) => {
|
||||||
|
if (!directMode || bridgedRef.current) {
|
||||||
onSuccess?.()
|
onSuccess?.()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
bridgedRef.current = true
|
bridgedRef.current = true
|
||||||
setBridging(true)
|
setBridging(true)
|
||||||
setFlowError(null)
|
const target = flowRedirectUrl(finalChallenge ?? challenge)
|
||||||
try {
|
if (target && isOAuthAuthorizeRedirect(target)) {
|
||||||
const { redirectUrl } = await completeAuthFlow(returnTo)
|
window.location.href = target
|
||||||
window.location.href = redirectUrl
|
return
|
||||||
} catch (err) {
|
|
||||||
bridgedRef.current = false
|
|
||||||
setBridging(false)
|
|
||||||
setFlowError(
|
|
||||||
err instanceof Error ? err.message : "Impossible de finaliser la connexion"
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}, [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(() => {
|
useEffect(() => {
|
||||||
const params = new URLSearchParams({ returnTo })
|
if (bootstrappedRef.current) return
|
||||||
window.location.assign(`/api/auth/login?${params.toString()}`)
|
bootstrappedRef.current = true
|
||||||
}, [returnTo])
|
|
||||||
|
|
||||||
const bootstrap = useCallback(async () => {
|
void (async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
const step = await startAuthFlow(slug, flowQuery)
|
let step: Awaited<ReturnType<typeof startDirectFlow>>
|
||||||
setSessionId(step.sessionId)
|
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)
|
setChallenge(step.challenge)
|
||||||
setDone(step.done)
|
setDone(step.done)
|
||||||
setDenied(step.denied)
|
setDenied(step.denied)
|
||||||
if (step.done && !step.denied) {
|
if (step.done && !step.denied) {
|
||||||
if (bridgeAuthentication) {
|
if (directMode) {
|
||||||
// Start returned immediate redirect — no flow session cookie for /complete.
|
finishAuthentication(step.challenge)
|
||||||
redirectToOidcLogin()
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
onSuccess?.()
|
onSuccess?.()
|
||||||
@ -125,25 +147,30 @@ export function AuthFlowPage({
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}, [bridgeAuthentication, flowQuery, onSuccess, redirectToOidcLogin, slug])
|
})()
|
||||||
|
// Intentionally once per mount — avoid re-start loop from challenge deps.
|
||||||
useEffect(() => {
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- bootstrap runs once
|
||||||
void bootstrap()
|
}, [])
|
||||||
}, [bootstrap])
|
|
||||||
|
|
||||||
const handleSubmit = useCallback(
|
const handleSubmit = useCallback(
|
||||||
async (payload: Record<string, unknown>) => {
|
async (payload: Record<string, unknown>) => {
|
||||||
if (!sessionId) return
|
if (!startedRef.current) return
|
||||||
setSubmitting(true)
|
setSubmitting(true)
|
||||||
setFlowError(null)
|
setFlowError(null)
|
||||||
try {
|
try {
|
||||||
const step = await respondAuthFlow(slug, payload, flowQuery)
|
const step = directMode
|
||||||
setSessionId(step.sessionId)
|
? await respondDirectFlow(
|
||||||
|
executorBaseRef.current ?? "/auth/api/v3/flows/executor",
|
||||||
|
slug,
|
||||||
|
payload,
|
||||||
|
flowQueryRef.current
|
||||||
|
)
|
||||||
|
: await respondAuthFlow(slug, payload, flowQueryRef.current ?? flowQuery)
|
||||||
setChallenge(step.challenge)
|
setChallenge(step.challenge)
|
||||||
setDone(step.done)
|
setDone(step.done)
|
||||||
setDenied(step.denied)
|
setDenied(step.denied)
|
||||||
if (step.done && !step.denied) {
|
if (step.done && !step.denied) {
|
||||||
await finishAuthentication()
|
finishAuthentication(step.challenge)
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setFlowError(err instanceof Error ? err.message : "Échec de l'étape")
|
setFlowError(err instanceof Error ? err.message : "Échec de l'étape")
|
||||||
@ -151,7 +178,7 @@ export function AuthFlowPage({
|
|||||||
setSubmitting(false)
|
setSubmitting(false)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[finishAuthentication, flowQuery, sessionId, slug]
|
[directMode, finishAuthentication, flowQuery, slug]
|
||||||
)
|
)
|
||||||
|
|
||||||
const validationErrors = useMemo(() => flowValidationErrors(challenge), [challenge])
|
const validationErrors = useMemo(() => flowValidationErrors(challenge), [challenge])
|
||||||
|
|||||||
@ -2,18 +2,20 @@
|
|||||||
|
|
||||||
import { useCallback, useEffect, useState, type ReactNode } from "react"
|
import { useCallback, useEffect, useState, type ReactNode } from "react"
|
||||||
import { usePathname, useRouter } from "next/navigation"
|
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 { isOidcConfigured } from "@/lib/auth/oidc-config"
|
||||||
|
import { clearClientAuthState } from "@/lib/auth/clear-client-auth-state"
|
||||||
import {
|
import {
|
||||||
fetchSession,
|
fetchSession,
|
||||||
applySessionToStore,
|
applySessionToStore,
|
||||||
type SessionPayload,
|
type SessionPayload,
|
||||||
} from "@/lib/auth/session-sync"
|
} from "@/lib/auth/session-sync"
|
||||||
import {
|
import {
|
||||||
isSessionExpired,
|
|
||||||
useSessionGuardStore,
|
useSessionGuardStore,
|
||||||
} from "@/lib/auth/session-guard-store"
|
} from "@/lib/auth/session-guard-store"
|
||||||
import { isAuthPublicPath } from "@/lib/auth/public-paths"
|
import { isAuthPublicPath } from "@/lib/auth/public-paths"
|
||||||
|
import { getOidcEndSessionPath, POST_LOGOUT_PATH } from "@/lib/auth/oidc-config"
|
||||||
import { useNativeRuntime } from "@/lib/platform"
|
import { useNativeRuntime } from "@/lib/platform"
|
||||||
import {
|
import {
|
||||||
ensureNativeAccessToken,
|
ensureNativeAccessToken,
|
||||||
@ -34,7 +36,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
const pathname = usePathname()
|
const pathname = usePathname()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const logout = useAuthStore((s) => s.logout)
|
const logout = useAuthStore((s) => s.logout)
|
||||||
const isAuthenticated = useAuthStore((s) => s.isAuthenticated)
|
const authenticated = useAuthStore((s) => s.isAuthenticated())
|
||||||
const [ready, setReady] = useState(() => !isOidcConfigured())
|
const [ready, setReady] = useState(() => !isOidcConfigured())
|
||||||
|
|
||||||
const applySession = useCallback(
|
const applySession = useCallback(
|
||||||
@ -53,6 +55,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
}
|
}
|
||||||
const data = await fetchSession()
|
const data = await fetchSession()
|
||||||
if (data && applySession(data)) return true
|
if (data && applySession(data)) return true
|
||||||
|
if (useAuthStore.getState().isAuthenticated()) return true
|
||||||
logout()
|
logout()
|
||||||
return false
|
return false
|
||||||
}, [applySession, logout, native])
|
}, [applySession, logout, native])
|
||||||
@ -89,11 +92,12 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const hadMemoryAuth = useAuthStore.getState().isAuthenticated()
|
if (useAuthStore.getState().isAuthenticated()) {
|
||||||
logout()
|
setReady(true)
|
||||||
if (hadMemoryAuth && !isPublicPath(pathname) && !isSessionExpired()) {
|
return
|
||||||
useSessionGuardStore.getState().setExpired()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logout()
|
||||||
setReady(true)
|
setReady(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -111,7 +115,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true
|
cancelled = true
|
||||||
}
|
}
|
||||||
}, [applySession, logout, pathname, native])
|
}, [applySession, logout, native])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!ready || !isOidcConfigured()) return
|
if (!ready || !isOidcConfigured()) return
|
||||||
@ -130,13 +134,14 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!ready || !isOidcConfigured()) return
|
if (!ready || !isOidcConfigured()) return
|
||||||
if (isPublicPath(pathname)) return
|
if (isPublicPath(pathname)) return
|
||||||
if (isAuthenticated()) return
|
if (authenticated) return
|
||||||
// NativeAuthGate shows picker/login inline — avoid fighting redirects.
|
// NativeAuthGate shows picker/login inline — avoid fighting redirects.
|
||||||
if (native) return
|
if (native) return
|
||||||
|
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
void syncSession().then((ok) => {
|
void syncSession().then((ok) => {
|
||||||
if (cancelled || ok) return
|
if (cancelled || ok) return
|
||||||
|
if (useAuthStore.getState().isAuthenticated()) return
|
||||||
if (useSessionGuardStore.getState().status === "expired") return
|
if (useSessionGuardStore.getState().status === "expired") return
|
||||||
const returnTo = encodeURIComponent(pathname)
|
const returnTo = encodeURIComponent(pathname)
|
||||||
router.replace(`/login?returnTo=${returnTo}`)
|
router.replace(`/login?returnTo=${returnTo}`)
|
||||||
@ -145,14 +150,14 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true
|
cancelled = true
|
||||||
}
|
}
|
||||||
}, [ready, pathname, isAuthenticated, router, syncSession])
|
}, [ready, pathname, authenticated, router, syncSession, native])
|
||||||
|
|
||||||
return <>{children}</>
|
return <>{children}</>
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useAuthLogout() {
|
export function useAuthLogout() {
|
||||||
const logout = useAuthStore((s) => s.logout)
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
const native = useNativeRuntime()
|
const native = useNativeRuntime()
|
||||||
|
|
||||||
return async () => {
|
return async () => {
|
||||||
@ -165,16 +170,16 @@ export function useAuthLogout() {
|
|||||||
/* best effort */
|
/* best effort */
|
||||||
}
|
}
|
||||||
await nativeLogout()
|
await nativeLogout()
|
||||||
} else {
|
clearClientAuthState(queryClient)
|
||||||
|
router.replace(POST_LOGOUT_PATH)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
await fetch("/api/auth/logout", { method: "POST", credentials: "include" })
|
await fetch("/api/auth/logout", { method: "POST", credentials: "include" })
|
||||||
}
|
clearClientAuthState(queryClient)
|
||||||
logout()
|
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
localStorage.removeItem(AUTH_STORAGE_KEY)
|
// Clear Ultimail session then terminate Authentik SSO (provider invalidation flow).
|
||||||
for (const legacy of LEGACY_AUTH_KEYS) {
|
window.location.assign(getOidcEndSessionPath())
|
||||||
localStorage.removeItem(legacy)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
router.replace("/login")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,10 +1,41 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import Link from "next/link"
|
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 { 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 { 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() {
|
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 (
|
return (
|
||||||
<AuthFlowPage
|
<AuthFlowPage
|
||||||
slug={AUTH_FLOW_SLUGS.recovery}
|
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."
|
successDescription="Si un compte existe pour cette adresse, vous recevrez un e-mail avec les instructions."
|
||||||
successActionLabel="Retour à la connexion"
|
successActionLabel="Retour à la connexion"
|
||||||
successHref="/login"
|
successHref="/login"
|
||||||
footer={
|
footer={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>
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@ -1,8 +1,18 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { AuthFlowPage } from "@/components/auth/auth-flow-page"
|
import { useEffect } from "react"
|
||||||
import { AUTH_FLOW_SLUGS } from "@/lib/auth/auth-flow-slugs"
|
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 = {
|
type LoginPageContentProps = {
|
||||||
returnTo?: string
|
returnTo?: string
|
||||||
@ -13,43 +23,72 @@ export function LoginPageContent({
|
|||||||
returnTo = "/mail/inbox",
|
returnTo = "/mail/inbox",
|
||||||
error = null,
|
error = null,
|
||||||
}: LoginPageContentProps) {
|
}: LoginPageContentProps) {
|
||||||
const signupHref = `/signup?returnTo=${encodeURIComponent(returnTo)}`
|
const native = useNativeRuntime()
|
||||||
const forgotPasswordHref = `/forgot-password?returnTo=${encodeURIComponent(returnTo)}`
|
const themeMode = useClientThemeStore((s) => s.themeMode)
|
||||||
const oidcFallbackHref = `/api/auth/login?returnTo=${encodeURIComponent(returnTo)}`
|
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 (
|
useEffect(() => {
|
||||||
<AuthFlowPage
|
if (!error) {
|
||||||
slug={AUTH_FLOW_SLUGS.authentication}
|
window.location.replace(oidcHref)
|
||||||
flowQuery={undefined}
|
}
|
||||||
defaultTitle="Connexion"
|
}, [error, oidcHref])
|
||||||
defaultDescription="Connecte-toi avec ton compte UltiSpace pour accéder à ta suite."
|
|
||||||
successTitle="Connexion réussie"
|
const footer = (
|
||||||
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">
|
<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>
|
<p>
|
||||||
Pas encore de compte ?{" "}
|
Pas encore de compte ?{" "}
|
||||||
|
{native ? (
|
||||||
<Link className="font-medium text-primary underline" href={signupHref}>
|
<Link className="font-medium text-primary underline" href={signupHref}>
|
||||||
Créer un compte
|
Créer un compte
|
||||||
</Link>
|
</Link>
|
||||||
|
) : (
|
||||||
|
<a className="font-medium text-primary underline" href={signupHref}>
|
||||||
|
Créer un compte
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
|
{native ? (
|
||||||
<Link className="font-medium text-primary underline" href={forgotPasswordHref}>
|
<Link className="font-medium text-primary underline" href={forgotPasswordHref}>
|
||||||
Mot de passe oublié ?
|
Mot de passe oublié ?
|
||||||
</Link>
|
</Link>
|
||||||
|
) : (
|
||||||
|
<a className="font-medium text-primary underline" href={forgotPasswordHref}>
|
||||||
|
Mot de passe oublié ?
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,17 +1,39 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import Link from "next/link"
|
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 { 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 { AUTH_FLOW_SLUGS } from "@/lib/auth/auth-flow-slugs"
|
||||||
|
import { useClientThemeStore } from "@/lib/stores/client-theme-store"
|
||||||
|
import { useNativeRuntime } from "@/lib/platform"
|
||||||
|
|
||||||
type SignupPageContentProps = {
|
type SignupPageContentProps = {
|
||||||
returnTo?: string
|
returnTo?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SignupPageContent({ returnTo = "/mail/inbox" }: SignupPageContentProps) {
|
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 loginHref = `/login?returnTo=${encodeURIComponent(returnTo)}`
|
||||||
const oidcHref = `/api/auth/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 (
|
return (
|
||||||
<AuthFlowPage
|
<AuthFlowPage
|
||||||
slug={AUTH_FLOW_SLUGS.enrollment}
|
slug={AUTH_FLOW_SLUGS.enrollment}
|
||||||
@ -33,3 +55,29 @@ 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
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"
|
"use client"
|
||||||
|
|
||||||
import { useMemo } from "react"
|
import { useMemo, useState } from "react"
|
||||||
import { useTheme } from "next-themes"
|
import { useTheme } from "next-themes"
|
||||||
import { ExternalLink } from "lucide-react"
|
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { AuthentikEmbedDialog } from "@/components/compte/authentik-embed-dialog"
|
||||||
import { CompteSettingsCard } from "@/components/compte/compte-settings-card"
|
import { CompteSettingsCard } from "@/components/compte/compte-settings-card"
|
||||||
import {
|
import {
|
||||||
buildAuthentikUrl,
|
buildAuthentikUrl,
|
||||||
@ -29,6 +29,7 @@ export function CompteAuthentikPanel({
|
|||||||
actionLabel,
|
actionLabel,
|
||||||
icon,
|
icon,
|
||||||
}: CompteAuthentikPanelProps) {
|
}: CompteAuthentikPanelProps) {
|
||||||
|
const [embedOpen, setEmbedOpen] = useState(false)
|
||||||
const themeMode = useClientThemeStore((s) => s.themeMode)
|
const themeMode = useClientThemeStore((s) => s.themeMode)
|
||||||
const { resolvedTheme } = useTheme()
|
const { resolvedTheme } = useTheme()
|
||||||
const authentikTheme = resolveAuthentikTheme(themeMode, resolvedTheme)
|
const authentikTheme = resolveAuthentikTheme(themeMode, resolvedTheme)
|
||||||
@ -50,6 +51,7 @@ export function CompteAuthentikPanel({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<CompteSettingsCard>
|
<CompteSettingsCard>
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||||
<PanelHeader icon={icon} title={title} description={description} />
|
<PanelHeader icon={icon} title={title} description={description} />
|
||||||
@ -57,18 +59,21 @@ export function CompteAuthentikPanel({
|
|||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="h-9 shrink-0 rounded-full px-4 text-sm font-medium"
|
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}
|
{actionLabel}
|
||||||
<ExternalLink className="size-3.5" aria-hidden />
|
|
||||||
</a>
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-3 text-xs text-muted-foreground">
|
|
||||||
Ouverture du portail d'identité Authentik dans un nouvel onglet.
|
|
||||||
</p>
|
|
||||||
</CompteSettingsCard>
|
</CompteSettingsCard>
|
||||||
|
|
||||||
|
<AuthentikEmbedDialog
|
||||||
|
url={url}
|
||||||
|
title={title}
|
||||||
|
description={description}
|
||||||
|
open={embedOpen}
|
||||||
|
onOpenChange={setEmbedOpen}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useEffect, useMemo, useRef } from "react"
|
import { useEffect, useMemo, useRef } from "react"
|
||||||
|
import { usePersistHydrated } from "@/hooks/use-persist-hydrated"
|
||||||
import { useAgendaCalendars } from "@/lib/api/hooks/use-calendar-queries"
|
import { useAgendaCalendars } from "@/lib/api/hooks/use-calendar-queries"
|
||||||
import { useMailAccounts } from "@/lib/api/hooks/use-mail-queries"
|
import { useMailAccounts } from "@/lib/api/hooks/use-mail-queries"
|
||||||
import {
|
import {
|
||||||
@ -13,7 +14,9 @@ import { useAgendaSettingsStore } from "@/lib/agenda/agenda-store"
|
|||||||
import type { AgendaCalendar } from "@/lib/agenda/agenda-types"
|
import type { AgendaCalendar } from "@/lib/agenda/agenda-types"
|
||||||
|
|
||||||
export function useMergedAgendaCalendars() {
|
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 { data: apiCalendars = [], isLoading, isError } = useAgendaCalendars()
|
||||||
|
|
||||||
const calendars = useMemo(
|
const calendars = useMemo(
|
||||||
@ -21,7 +24,7 @@ export function useMergedAgendaCalendars() {
|
|||||||
[apiCalendars, externalCalendars],
|
[apiCalendars, externalCalendars],
|
||||||
)
|
)
|
||||||
|
|
||||||
return { calendars, apiCalendars, externalCalendars, isLoading, isError }
|
return { calendars, apiCalendars, externalCalendars, isLoading, isError, settingsHydrated }
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useVisibleAgendaCalendars() {
|
export function useVisibleAgendaCalendars() {
|
||||||
|
|||||||
@ -182,7 +182,7 @@ export function useCreateAgendaCalendar() {
|
|||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (input: { display_name: string; color?: string }) =>
|
mutationFn: (input: { display_name: string; color?: string }) =>
|
||||||
apiClient.post<{ id: string }>("/calendar", input),
|
apiClient.post<{ id: string }>("/calendar/", input),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
void queryClient.invalidateQueries({ queryKey: ["agenda", "calendars"] })
|
void queryClient.invalidateQueries({ queryKey: ["agenda", "calendars"] })
|
||||||
},
|
},
|
||||||
|
|||||||
@ -26,23 +26,30 @@ export function useAgendaCalendars() {
|
|||||||
const { ready, authenticated } = useAuthReady()
|
const { ready, authenticated } = useAuthReady()
|
||||||
const isDemoAgenda = useIsDemoAgenda()
|
const isDemoAgenda = useIsDemoAgenda()
|
||||||
const demoVersion = useDemoAgendaStore((s) => s.version)
|
const demoVersion = useDemoAgendaStore((s) => s.version)
|
||||||
return useQuery({
|
const queryEnabled = ready && authenticated
|
||||||
|
const query = useQuery({
|
||||||
queryKey: isDemoAgenda
|
queryKey: isDemoAgenda
|
||||||
? [...DEMO_AGENDA_QUERY_ROOT, "calendars", demoVersion]
|
? [...DEMO_AGENDA_QUERY_ROOT, "calendars", demoVersion]
|
||||||
: agendaCalendarsKey,
|
: agendaCalendarsKey,
|
||||||
enabled: ready && authenticated,
|
enabled: queryEnabled,
|
||||||
staleTime: 5 * 60_000,
|
staleTime: 5 * 60_000,
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (isDemoAgenda) {
|
if (isDemoAgenda) {
|
||||||
return useDemoAgendaStore.getState().listCalendars()
|
return useDemoAgendaStore.getState().listCalendars()
|
||||||
}
|
}
|
||||||
const res = await apiClient.get<AgendaCalendarsResponse>("/calendar")
|
const res = await apiClient.get<AgendaCalendarsResponse>("/calendar/")
|
||||||
return res.calendars ?? []
|
return res.calendars ?? []
|
||||||
},
|
},
|
||||||
initialData: isDemoAgenda
|
initialData: isDemoAgenda
|
||||||
? () => useDemoAgendaStore.getState().listCalendars()
|
? () => useDemoAgendaStore.getState().listCalendars()
|
||||||
: undefined,
|
: 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). */
|
/** Flows Authentik par défaut pour self-service (modifiables côté admin). */
|
||||||
export const AUTHENTIK_SELF_SERVICE_FLOWS = {
|
export const AUTHENTIK_SELF_SERVICE_FLOWS = {
|
||||||
|
enrollment: "ulti-enrollment",
|
||||||
passwordChange: "default-password-change",
|
passwordChange: "default-password-change",
|
||||||
recovery: "ulti-recovery",
|
recovery: "ulti-recovery",
|
||||||
totpSetup: "default-authenticator-totp-setup",
|
totpSetup: "default-authenticator-totp-setup",
|
||||||
@ -106,6 +107,18 @@ export function authentikPasswordChangeFlowUrl(
|
|||||||
return authentikFlowUrl(AUTHENTIK_SELF_SERVICE_FLOWS.passwordChange, theme)
|
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. */
|
/** URL Authentik (flow ou onglet réglages) avec thème. */
|
||||||
export function buildAuthentikUrl(options: AuthentikUrlOptions): string | null {
|
export function buildAuthentikUrl(options: AuthentikUrlOptions): string | null {
|
||||||
const { tab, flowSlug, theme } = options
|
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)) {
|
if (data && applySessionToStore(data)) {
|
||||||
return useAuthStore.getState().accessToken
|
return useAuthStore.getState().accessToken
|
||||||
}
|
}
|
||||||
|
if (useAuthStore.getState().isAuthenticated()) {
|
||||||
|
return useAuthStore.getState().accessToken
|
||||||
|
}
|
||||||
useAuthStore.getState().logout()
|
useAuthStore.getState().logout()
|
||||||
return null
|
return null
|
||||||
})().finally(() => {
|
})().finally(() => {
|
||||||
|
|||||||
@ -65,22 +65,122 @@ export async function respondAuthFlow(
|
|||||||
return parseFlowResponse(res)
|
return parseFlowResponse(res)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Bridge embedded authentication to OIDC session (sets Authentik cookies + login URL). */
|
export function isOAuthAuthorizeRedirect(target: string): boolean {
|
||||||
export async function completeAuthFlow(returnTo: string): Promise<FlowCompleteResponse> {
|
return target.includes("/application/o/authorize")
|
||||||
const res = await fetch("/api/v1/auth/flows/complete", {
|
}
|
||||||
|
|
||||||
|
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",
|
method: "POST",
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
headers: {
|
headers: directExecutorHeaders({ "Content-Type": "application/json" }),
|
||||||
Accept: "application/json",
|
body: JSON.stringify(payload),
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ returnTo }),
|
|
||||||
})
|
})
|
||||||
const body = (await res.json()) as FlowCompleteResponse & FlowApiError
|
return parseDirectChallenge(res)
|
||||||
if (!res.ok) {
|
|
||||||
throw new Error(body.message ?? `flow complete failed (${res.status})`)
|
|
||||||
}
|
|
||||||
return body
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function flowComponent(challenge: FlowChallenge | null | undefined): string {
|
export function flowComponent(challenge: FlowChallenge | null | undefined): string {
|
||||||
|
|||||||
@ -16,3 +16,16 @@ export function buildOidcLoginUrl(options?: {
|
|||||||
}
|
}
|
||||||
return `/api/auth/login?${params.toString()}`
|
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/). */
|
/** Authentik enrollment flow (same origin as the suite — nginx /auth/). */
|
||||||
const AUTHENTIK_ENROLLMENT_PATH = "/auth/if/flow/ulti-enrollment/"
|
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). */
|
/** In-app signup page (Phase 2 custom UI via ultid BFF). */
|
||||||
export const SIGNUP_PATH = "/signup"
|
export const SIGNUP_PATH = "/signup"
|
||||||
|
|
||||||
@ -45,12 +54,15 @@ export const FORGOT_PASSWORD_PATH = "/forgot-password"
|
|||||||
export const RESET_PASSWORD_PATH = "/reset-password"
|
export const RESET_PASSWORD_PATH = "/reset-password"
|
||||||
|
|
||||||
export function getSignupUrl(): string {
|
export function getSignupUrl(): string {
|
||||||
return SIGNUP_PATH
|
return getAuthentikEnrollmentUrl()
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getForgotPasswordUrl(): string {
|
export function getForgotPasswordUrl(): string {
|
||||||
|
if (useNativeRuntime()) {
|
||||||
return FORGOT_PASSWORD_PATH
|
return FORGOT_PASSWORD_PATH
|
||||||
}
|
}
|
||||||
|
return AUTHENTIK_RECOVERY_PATH
|
||||||
|
}
|
||||||
|
|
||||||
export function getResetPasswordUrl(): string {
|
export function getResetPasswordUrl(): string {
|
||||||
return RESET_PASSWORD_PATH
|
return RESET_PASSWORD_PATH
|
||||||
@ -65,6 +77,11 @@ export function getAuthentikEnrollmentUrl(): string {
|
|||||||
return AUTHENTIK_ENROLLMENT_PATH
|
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 = {
|
type OidcDiscovery = {
|
||||||
authorization_endpoint: string
|
authorization_endpoint: string
|
||||||
token_endpoint: string
|
token_endpoint: string
|
||||||
|
|||||||
@ -45,6 +45,20 @@ export function computeExpiresAt(expiresIn: number): number {
|
|||||||
return Date.now() + expiresIn * 1000
|
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 {
|
export function isIdTokenJwtValid(accessToken: string | undefined): boolean {
|
||||||
if (!accessToken) return false
|
if (!accessToken) return false
|
||||||
const claims = decodeJwtPayload(accessToken)
|
const claims = decodeJwtPayload(accessToken)
|
||||||
|
|||||||
@ -17,6 +17,7 @@ export function useChromeIdentity(): {
|
|||||||
const isDemoApp = useIsDemoApp()
|
const isDemoApp = useIsDemoApp()
|
||||||
const authHydrated = usePersistHydrated(useAuthStore)
|
const authHydrated = usePersistHydrated(useAuthStore)
|
||||||
const accountHydrated = usePersistHydrated(useAccountStore)
|
const accountHydrated = usePersistHydrated(useAccountStore)
|
||||||
|
const isAuthenticated = useAuthStore((s) => s.isAuthenticated())
|
||||||
const platformUser = useAuthStore((s) => s.user)
|
const platformUser = useAuthStore((s) => s.user)
|
||||||
const mailAccount = useActiveAccount()
|
const mailAccount = useActiveAccount()
|
||||||
const { data: currentUser } = useCurrentUser()
|
const { data: currentUser } = useCurrentUser()
|
||||||
@ -31,6 +32,7 @@ export function useChromeIdentity(): {
|
|||||||
|
|
||||||
// Keep SSR and first client render identical until persist stores rehydrate.
|
// Keep SSR and first client render identical until persist stores rehydrate.
|
||||||
if (!authHydrated) return null
|
if (!authHydrated) return null
|
||||||
|
if (!isAuthenticated) return null
|
||||||
if (!platformUser && !accountHydrated) return null
|
if (!platformUser && !accountHydrated) return null
|
||||||
|
|
||||||
if (platformUser) {
|
if (platformUser) {
|
||||||
|
|||||||
@ -2,10 +2,12 @@
|
|||||||
|
|
||||||
import { create } from 'zustand'
|
import { create } from 'zustand'
|
||||||
import { persist } from 'zustand/middleware'
|
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 { 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'
|
import type { ApiMailAccount } from '@/lib/api/types'
|
||||||
|
|
||||||
type AccountStoreState = {
|
type AccountStoreState = {
|
||||||
@ -52,18 +54,15 @@ export function useActiveAccount(): ApiMailAccount | null {
|
|||||||
|
|
||||||
export function useSignOutAll() {
|
export function useSignOutAll() {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
|
const native = useNativeRuntime()
|
||||||
|
|
||||||
return async () => {
|
return async () => {
|
||||||
await fetch("/api/auth/logout", { method: "POST", credentials: "include" })
|
await fetch("/api/auth/logout", { method: "POST", credentials: "include" })
|
||||||
if (typeof window !== "undefined") {
|
clearClientAuthState(queryClient)
|
||||||
localStorage.removeItem(AUTH_STORAGE_KEY)
|
if (native) {
|
||||||
for (const legacy of LEGACY_AUTH_KEYS) {
|
window.location.href = POST_LOGOUT_PATH
|
||||||
localStorage.removeItem(legacy)
|
return
|
||||||
}
|
}
|
||||||
}
|
window.location.assign(getOidcEndSessionPath())
|
||||||
useAuthStore.getState().logout()
|
|
||||||
queryClient.clear()
|
|
||||||
useAccountStore.setState({ activeAccountId: null, otherAccountsExpanded: true })
|
|
||||||
window.location.href = "/login"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -72,6 +72,10 @@ function buildDebouncedJsonStorage(): PersistStorage<unknown> {
|
|||||||
window.addEventListener("pagehide", flushAll)
|
window.addEventListener("pagehide", flushAll)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const flushPersistStorage = () => {
|
||||||
|
flushAll()
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
getItem: (name) => base.getItem(name),
|
getItem: (name) => base.getItem(name),
|
||||||
setItem: (name, value) => {
|
setItem: (name, value) => {
|
||||||
@ -93,8 +97,17 @@ function buildDebouncedJsonStorage(): PersistStorage<unknown> {
|
|||||||
pending.delete(name)
|
pending.delete(name)
|
||||||
return base.removeItem(name)
|
return base.removeItem(name)
|
||||||
},
|
},
|
||||||
|
flush: flushPersistStorage,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Shared instance for all zustand `persist` stores in this app. */
|
/** Shared instance for all zustand `persist` stores in this app. */
|
||||||
export const debouncedPersistJSONStorage = buildDebouncedJsonStorage()
|
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"
|
"localhost"
|
||||||
).trim()
|
).trim()
|
||||||
const s = suiteSecureSuffix()
|
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 {
|
return {
|
||||||
NEXT_PUBLIC_APP_URL: origin,
|
NEXT_PUBLIC_APP_URL: origin,
|
||||||
NEXT_PUBLIC_WS_URL: `ws${s}://${host}/ws`,
|
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