ultisuite-client/components/auth/auth-provider.tsx
R3D347HR4Y 9ea2d3325d
Some checks are pending
E2E / Playwright e2e (push) Waiting to run
feat(auth): enhance authentication flows with embedded support and UI improvements
- Updated login and signup components to utilize AuthCard for better user experience during redirection.
- Introduced AuthentikEmbedDialog for seamless integration of Authentik's identity portal within the application.
- Enhanced password recovery and signup flows with dynamic theme handling and improved loading states.
- Refactored existing components to streamline authentication processes and improve maintainability.
2026-06-21 00:12:45 +02:00

186 lines
5.2 KiB
TypeScript

"use client"
import { useCallback, useEffect, useState, type ReactNode } from "react"
import { usePathname, useRouter } from "next/navigation"
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 {
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,
loadNativeSession,
} from "@/lib/auth/native-session"
import { nativeLogout } from "@/lib/auth/native-auth"
import { getRuntimeConfig } from "@/lib/runtime-config"
import { hydrateNativeRuntimeConfig } from "@/lib/runtime-config/native"
const REFRESH_LEAD_MS = 5 * 60 * 1000
const REFRESH_CHECK_MS = 60 * 1000
function isPublicPath(pathname: string) {
return isAuthPublicPath(pathname)
}
export function AuthProvider({ children }: { children: ReactNode }) {
const pathname = usePathname()
const router = useRouter()
const logout = useAuthStore((s) => s.logout)
const authenticated = useAuthStore((s) => s.isAuthenticated())
const [ready, setReady] = useState(() => !isOidcConfigured())
const applySession = useCallback(
(data: SessionPayload) => applySessionToStore(data),
[]
)
const native = useNativeRuntime()
const syncSession = useCallback(async () => {
if (native) {
const token = await ensureNativeAccessToken()
if (token) return true
logout()
return false
}
const data = await fetchSession()
if (data && applySession(data)) return true
if (useAuthStore.getState().isAuthenticated()) return true
logout()
return false
}, [applySession, logout, native])
useEffect(() => {
let cancelled = false
async function bootstrap() {
if (!isOidcConfigured()) {
setReady(true)
return
}
if (native) {
// Native: session lives in the OS secure store; no server selected yet
// means the user must run the server picker first.
await hydrateNativeRuntimeConfig()
if (cancelled) return
if (getRuntimeConfig()) {
const ok = await loadNativeSession()
if (!cancelled && ok) {
await ensureNativeAccessToken()
}
}
if (!cancelled) setReady(true)
return
}
const data = await fetchSession()
if (cancelled) return
if (data && applySession(data)) {
setReady(true)
return
}
if (useAuthStore.getState().isAuthenticated()) {
setReady(true)
return
}
logout()
setReady(true)
}
if (!useAuthStore.persist.hasHydrated()) {
const unsubHydrate = useAuthStore.persist.onFinishHydration(() => {
void bootstrap()
})
return () => {
cancelled = true
unsubHydrate()
}
}
void bootstrap()
return () => {
cancelled = true
}
}, [applySession, logout, native])
useEffect(() => {
if (!ready || !isOidcConfigured()) return
const interval = setInterval(() => {
const { accessToken, expiresAt } = useAuthStore.getState()
if (!accessToken || !expiresAt) return
if (Date.now() >= expiresAt - REFRESH_LEAD_MS) {
void syncSession()
}
}, REFRESH_CHECK_MS)
return () => clearInterval(interval)
}, [ready, syncSession])
useEffect(() => {
if (!ready || !isOidcConfigured()) return
if (isPublicPath(pathname)) 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}`)
})
return () => {
cancelled = true
}
}, [ready, pathname, authenticated, router, syncSession, native])
return <>{children}</>
}
export function useAuthLogout() {
const router = useRouter()
const queryClient = useQueryClient()
const native = useNativeRuntime()
return async () => {
if (native) {
// Unregister the push device token before dropping the session.
try {
const { unregisterPushOnLogout } = await import("@/lib/native/push")
await unregisterPushOnLogout()
} catch {
/* best effort */
}
await nativeLogout()
clearClientAuthState(queryClient)
router.replace(POST_LOGOUT_PATH)
return
}
await fetch("/api/auth/logout", { method: "POST", credentials: "include" })
clearClientAuthState(queryClient)
if (typeof window !== "undefined") {
// Clear Ultimail session then terminate Authentik SSO (provider invalidation flow).
window.location.assign(getOidcEndSessionPath())
}
}
}