AuthContext.tsx
3.82 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
// vibe_erp web auth context.
//
// **Scope.** Holds the in-memory copy of the access token + the user
// it was issued to (decoded from the JWT payload), exposes login /
// logout actions, and rerenders subscribers when the token state
// flips. Persistence lives in localStorage via the api client's
// `getAccessToken/setAccessToken` helpers — the context only mirrors
// what's in storage so a hard refresh of the page picks up the
// existing session without a re-login.
//
// **Why decode the JWT in the SPA.** vibe_erp's auth tokens are
// signed (HS256) by the framework, so the SPA cannot *trust* the
// payload — but it can DISPLAY it (username, role list) without a
// round-trip. Anything load-bearing (permission checks) still
// happens server-side. The decode is intentionally trivial: split
// on '.', base64-decode the middle segment, JSON.parse. No JWT lib
// dependency; an invalid token just degrades to "unknown user" and
// the next API call gets a 401, triggering a logout.
//
// **401 handling.** The api client calls registerUnauthorizedHandler
// once at boot. When any request returns 401, the handler clears
// the token and forces a navigate to /login (preserving the
// attempted path so post-login can redirect back).
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from 'react'
import { useLocation, useNavigate } from 'react-router-dom'
import {
auth as authApi,
getAccessToken,
registerUnauthorizedHandler,
setAccessToken,
} from '@/api/client'
interface JwtPayload {
sub?: string
username?: string
roles?: string[]
exp?: number
}
function decodeJwt(token: string): JwtPayload | null {
try {
const parts = token.split('.')
if (parts.length !== 3) return null
const padded = parts[1] + '==='.slice((parts[1].length + 3) % 4)
const json = atob(padded.replace(/-/g, '+').replace(/_/g, '/'))
return JSON.parse(json) as JwtPayload
} catch {
return null
}
}
interface AuthState {
token: string | null
username: string | null
roles: string[]
loading: boolean
login: (username: string, password: string) => Promise<void>
logout: () => void
}
const AuthContext = createContext<AuthState | null>(null)
export function AuthProvider({ children }: { children: ReactNode }) {
const navigate = useNavigate()
const location = useLocation()
const [token, setToken] = useState<string | null>(() => getAccessToken())
const [loading, setLoading] = useState(false)
const payload = useMemo(() => (token ? decodeJwt(token) : null), [token])
const logout = useCallback(() => {
setAccessToken(null)
setToken(null)
if (location.pathname !== '/login') {
navigate('/login', {
replace: true,
state: { from: location.pathname + location.search },
})
}
}, [navigate, location])
// Wire the api client's 401 handler exactly once.
useEffect(() => {
registerUnauthorizedHandler(() => {
// Defer to next tick so the throw inside apiFetch isn't
// swallowed by React's render-phase complaints.
setTimeout(() => logout(), 0)
})
}, [logout])
const login = useCallback(
async (username: string, password: string) => {
setLoading(true)
try {
const pair = await authApi.login(username, password)
setAccessToken(pair.accessToken)
setToken(pair.accessToken)
} finally {
setLoading(false)
}
},
[],
)
const value: AuthState = {
token,
username: payload?.username ?? payload?.sub ?? null,
roles: payload?.roles ?? [],
loading,
login,
logout,
}
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
}
export function useAuth(): AuthState {
const ctx = useContext(AuthContext)
if (!ctx) throw new Error('useAuth must be used inside <AuthProvider>')
return ctx
}