Spaces:
Running on CPU Upgrade
Running on CPU Upgrade
| /* eslint-disable react-hooks/exhaustive-deps, react-hooks/set-state-in-effect, react-hooks/immutability, react-refresh/only-export-components */ | |
| import { createContext, useContext, useState, useEffect, useRef, useCallback } from 'react'; | |
| const AuthContext = createContext(null); | |
| const TOKEN_KEY = 'wss_token'; | |
| const REFRESH_KEY = 'wss_refresh_token'; | |
| // Parse the expiry time from a JWT without any library | |
| function getTokenExpiry(token) { | |
| try { | |
| const payload = JSON.parse(atob(token.split('.')[1])); | |
| return payload.exp ? payload.exp * 1000 : null; // convert to ms | |
| } catch { | |
| return null; | |
| } | |
| } | |
| export const AuthProvider = ({ children }) => { | |
| const [user, setUser] = useState(null); | |
| const [token, setToken] = useState(localStorage.getItem(TOKEN_KEY)); | |
| const [refreshToken, setRefreshToken] = useState(localStorage.getItem(REFRESH_KEY)); | |
| const [loading, setLoading] = useState(true); | |
| const proactiveRefreshRef = useRef(null); // holds the setTimeout handle | |
| // ββ Core logout β clear everything βββββββββββββββββββββββββββββββββββββββ | |
| const doLogout = useCallback(() => { | |
| if (proactiveRefreshRef.current) clearTimeout(proactiveRefreshRef.current); | |
| setToken(null); | |
| setRefreshToken(null); | |
| setUser(null); | |
| localStorage.removeItem(TOKEN_KEY); | |
| localStorage.removeItem(REFRESH_KEY); | |
| }, []); | |
| // ββ Refresh access token (uses body refresh_token + HttpOnly cookie fallback) | |
| const refreshAccessToken = useCallback(async () => { | |
| const storedRefresh = localStorage.getItem(REFRESH_KEY); | |
| try { | |
| const res = await fetch('/api/auth/refresh', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| credentials: 'include', // send HttpOnly cookie as fallback | |
| body: JSON.stringify({ refresh_token: storedRefresh || '' }), | |
| }); | |
| if (res.ok) { | |
| const data = await res.json(); | |
| setToken(data.access_token); | |
| localStorage.setItem(TOKEN_KEY, data.access_token); | |
| if (data.refresh_token) { | |
| setRefreshToken(data.refresh_token); | |
| localStorage.setItem(REFRESH_KEY, data.refresh_token); | |
| } | |
| return data.access_token; | |
| } | |
| // Refresh failed β do NOT auto-logout. Just return null and let callers decide. | |
| return null; | |
| } catch { | |
| return null; | |
| } | |
| }, []); | |
| // ββ Schedule a proactive refresh 5 minutes before the access token expires ββ | |
| const scheduleProactiveRefresh = useCallback((accessToken) => { | |
| if (proactiveRefreshRef.current) clearTimeout(proactiveRefreshRef.current); | |
| const expiry = getTokenExpiry(accessToken); | |
| if (!expiry) return; | |
| const now = Date.now(); | |
| const refreshAt = expiry - 5 * 60 * 1000; // 5 min before expiry | |
| const delay = Math.max(refreshAt - now, 10_000); // min 10s | |
| proactiveRefreshRef.current = setTimeout(async () => { | |
| const newToken = await refreshAccessToken(); | |
| if (newToken) { | |
| scheduleProactiveRefresh(newToken); // reschedule for the new token | |
| } | |
| // If refresh failed but user is still browsing, keep them logged in | |
| // until they actually hit a 401 (e.g. backend was briefly down) | |
| }, delay); | |
| }, [refreshAccessToken]); | |
| // ββ Fetch user profile from backend βββββββββββββββββββββββββββββββββββββββ | |
| const fetchProfile = useCallback(async (activeToken, retryCount = 0) => { | |
| try { | |
| const res = await fetch('/api/auth/profile', { | |
| headers: { 'Authorization': `Bearer ${activeToken}` }, | |
| }); | |
| if (res.ok) { | |
| const data = await res.json(); | |
| setUser(data.user); | |
| setLoading(false); | |
| scheduleProactiveRefresh(activeToken); | |
| return; | |
| } | |
| if (res.status === 401) { | |
| // Try to silently refresh before giving up | |
| const newToken = await refreshAccessToken(); | |
| if (newToken) { | |
| const retryRes = await fetch('/api/auth/profile', { | |
| headers: { 'Authorization': `Bearer ${newToken}` }, | |
| }); | |
| if (retryRes.ok) { | |
| const data = await retryRes.json(); | |
| setUser(data.user); | |
| setLoading(false); | |
| scheduleProactiveRefresh(newToken); | |
| return; | |
| } | |
| } | |
| // Only logout if both the access token AND refresh failed (401 Unauthorized) | |
| doLogout(); | |
| setLoading(false); | |
| return; | |
| } | |
| // If backend is offline (502, 504), retry max 2 times then finish loading | |
| if (res.status >= 500 && retryCount < 2) { | |
| setTimeout(() => fetchProfile(activeToken, retryCount + 1), 1500); | |
| return; | |
| } | |
| setLoading(false); | |
| } catch { | |
| if (retryCount < 2) { | |
| setTimeout(() => fetchProfile(activeToken, retryCount + 1), 1500); | |
| return; | |
| } | |
| setLoading(false); | |
| } | |
| }, [refreshAccessToken, scheduleProactiveRefresh, doLogout]); | |
| // ββ Sync token changes to localStorage & fetch profile ββββββββββββββββββββ | |
| useEffect(() => { | |
| if (token) { | |
| localStorage.setItem(TOKEN_KEY, token); | |
| fetchProfile(token); | |
| } else { | |
| localStorage.removeItem(TOKEN_KEY); | |
| setUser(null); | |
| setLoading(false); | |
| } | |
| }, [token]); // intentionally only re-run when token changes β fetchProfile excluded to avoid infinite loop | |
| // ββ Login β called after successful credentials check βββββββββββββββββββββ | |
| const login = useCallback((newToken, newRefreshToken, userData) => { | |
| if (proactiveRefreshRef.current) clearTimeout(proactiveRefreshRef.current); | |
| setToken(newToken); | |
| setRefreshToken(newRefreshToken); | |
| setUser(userData); | |
| localStorage.setItem(TOKEN_KEY, newToken); | |
| if (newRefreshToken) localStorage.setItem(REFRESH_KEY, newRefreshToken); | |
| scheduleProactiveRefresh(newToken); | |
| }, [scheduleProactiveRefresh]); | |
| const logout = useCallback(() => { | |
| doLogout(); | |
| }, [doLogout]); | |
| const reloadUser = useCallback(() => { | |
| if (token) { | |
| fetchProfile(token); | |
| } | |
| }, [token, fetchProfile]); | |
| // ββ Cleanup proactive refresh on unmount ββββββββββββββββββββββββββββββββββ | |
| useEffect(() => { | |
| return () => { | |
| if (proactiveRefreshRef.current) clearTimeout(proactiveRefreshRef.current); | |
| }; | |
| }, []); | |
| return ( | |
| <AuthContext.Provider value={{ user, token, refreshToken, loading, login, logout, refreshAccessToken, reloadUser }}> | |
| {children} | |
| </AuthContext.Provider> | |
| ); | |
| }; | |
| export const useAuth = () => useContext(AuthContext); | |