HR-ATS-Portal/frontend/src/auth/AuthProvider.jsx

95 lines
3.0 KiB
JavaScript

import { useCallback, useEffect, useMemo, useSyncExternalStore } from 'react'
import { useNavigate } from 'react-router-dom'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { AuthContext } from './AuthContext'
import { makeCan } from './permissions'
import { qk } from '../lib/queryKeys'
import { setSessionExpiredHandler } from '../lib/apiClient'
import { subscribe, getSession, setSession, mergeUser, clearSession } from '../lib/tokenStore'
import * as authApi from '../api/auth'
import * as usersApi from '../api/users'
const emptySnapshot = () => null
export default function AuthProvider({ children }) {
const session = useSyncExternalStore(subscribe, getSession, emptySnapshot)
const navigate = useNavigate()
const qc = useQueryClient()
// apiClient calls this when a refresh fails outright. Registered here so the
// HTTP layer never has to know about the router.
useEffect(() => {
setSessionExpiredHandler(() => {
clearSession()
qc.clear()
navigate('/auth/login?expired=1', { replace: true })
})
return () => setSessionExpiredHandler(() => {})
}, [navigate, qc])
// Permission bootstrap. This is a query rather than a useEffect fetch on
// purpose: TanStack Query dedupes, so React 19 StrictMode's double mount
// issues ONE request instead of two.
const me = useQuery({
queryKey: qk.auth.me(),
queryFn: () => usersApi.me().then((r) => r.data),
enabled: Boolean(session?.access_token),
staleTime: 5 * 60_000,
retry: false,
})
// Fold `permissions` into the stored session so a page reload has them before
// /users/me resolves — otherwise the nav flickers on every refresh.
useEffect(() => {
if (me.data) mergeUser(me.data)
}, [me.data])
const signIn = useCallback(
async (email, password) => {
const res = await authApi.login(email, password)
setSession(res)
// Refetch /users/me for the new user; the old one's permissions must not leak.
await qc.invalidateQueries({ queryKey: qk.auth.me() })
return res
},
[qc],
)
const signOut = useCallback(() => {
// Client-side only: the backend has no logout endpoint, no denylist and no
// jti tracking, so the refresh token stays valid until its 7-day exp.
clearSession()
qc.clear()
navigate('/auth/login', { replace: true })
}, [navigate, qc])
const user = me.data ?? session?.data ?? null
const permissions = me.data?.permissions ?? session?.data?.permissions ?? null
const status = !session?.access_token
? 'anonymous'
: me.isError
? 'error'
: me.data || permissions
? 'authenticated'
: 'loading'
const value = useMemo(
() => ({
session,
user,
permissions,
status,
can: makeCan(permissions),
isAuthenticated: status === 'authenticated',
signIn,
signOut,
setSession,
}),
[session, user, permissions, status, signIn, signOut],
)
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
}