95 lines
3.0 KiB
JavaScript
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>
|
|
}
|