167 lines
4.2 KiB
TypeScript
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")
|
|
}
|
|
}
|