ultisuite-client/components/auth/auth-provider.tsx
2026-05-25 13:52:40 +02:00

167 lines
4.2 KiB
TypeScript

"use client"
import { useCallback, useEffect, useState, type ReactNode } from "react"
import { usePathname, useRouter } from "next/navigation"
import { useAuthStore } from "@/lib/api/auth-store"
import { isOidcConfigured } from "@/lib/auth/oidc-config"
import type { PlatformUser } from "@/lib/auth/jwt-claims"
const PUBLIC_PREFIXES = ["/login", "/auth/", "/api/auth/"]
const REFRESH_LEAD_MS = 5 * 60 * 1000
const REFRESH_CHECK_MS = 60 * 1000
function isPublicPath(pathname: string) {
return PUBLIC_PREFIXES.some(
(prefix) => pathname === prefix || pathname.startsWith(prefix)
)
}
type SessionPayload = {
authenticated?: boolean
accessToken?: string
refreshToken?: string | null
expiresAt?: number
user?: PlatformUser | null
}
async function fetchSession(): Promise<SessionPayload | null> {
try {
const res = await fetch("/api/auth/session", { credentials: "include" })
if (!res.ok) return null
return (await res.json()) as SessionPayload
} catch {
return null
}
}
function canTrustPersistedAuth() {
return useAuthStore.persist.hasHydrated() && useAuthStore.getState().isAuthenticated()
}
export function AuthProvider({ children }: { children: ReactNode }) {
const pathname = usePathname()
const router = useRouter()
const login = useAuthStore((s) => s.login)
const logout = useAuthStore((s) => s.logout)
const isAuthenticated = useAuthStore((s) => s.isAuthenticated)
const [ready, setReady] = useState(
() => !isOidcConfigured() || canTrustPersistedAuth()
)
const applySession = useCallback(
(data: SessionPayload) => {
if (data.authenticated && data.accessToken && data.expiresAt) {
login(
data.accessToken,
data.refreshToken ?? "",
data.expiresAt,
data.user ?? null
)
return true
}
return false
},
[login]
)
const syncSession = useCallback(async () => {
const data = await fetchSession()
if (data && applySession(data)) return true
logout()
return false
}, [applySession, logout])
useEffect(() => {
let cancelled = false
async function bootstrap() {
if (!isOidcConfigured()) {
setReady(true)
return
}
if (canTrustPersistedAuth()) {
setReady(true)
}
const data = await fetchSession()
if (cancelled) return
if (data && applySession(data)) {
setReady(true)
return
}
if (data?.authenticated === false || !canTrustPersistedAuth()) {
logout()
}
setReady(true)
}
if (!useAuthStore.persist.hasHydrated()) {
const unsubHydrate = useAuthStore.persist.onFinishHydration(() => {
if (useAuthStore.getState().isAuthenticated()) {
setReady(true)
}
})
void bootstrap()
return () => {
cancelled = true
unsubHydrate()
}
}
void bootstrap()
return () => {
cancelled = true
}
}, [applySession, logout])
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 (isAuthenticated()) return
let cancelled = false
void syncSession().then((ok) => {
if (cancelled || ok) return
const returnTo = encodeURIComponent(pathname)
router.replace(`/login?returnTo=${returnTo}`)
})
return () => {
cancelled = true
}
}, [ready, pathname, isAuthenticated, router, syncSession])
return <>{children}</>
}
export function useAuthLogout() {
const logout = useAuthStore((s) => s.logout)
const router = useRouter()
return async () => {
await fetch("/api/auth/logout", { method: "POST", credentials: "include" })
logout()
if (typeof window !== "undefined") {
localStorage.removeItem("ultimail-auth")
}
router.replace("/login")
}
}