because I got bored of customising my CV for every job
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(client): add refresh token support and session management

+159 -26
+152 -26
apps/client/src/contexts/TokenProvider.tsx
··· 1 - import { useApolloClient } from "@apollo/client"; 2 1 import { raise } from "@cv/utils"; 2 + import { useQueryClient } from "@tanstack/react-query"; 3 3 import { 4 4 createContext, 5 5 type ReactNode, 6 + useCallback, 6 7 useContext, 7 8 useEffect, 9 + useRef, 8 10 useState, 9 11 } from "react"; 10 - import { AUTH_TOKEN_KEY } from "@/constants/auth"; 11 - import { useMeMinimalQuery } from "@/generated/graphql"; 12 + import { 13 + AUTH_REFRESH_TOKEN_KEY, 14 + AUTH_TOKEN_EXPIRY_KEY, 15 + AUTH_TOKEN_KEY, 16 + } from "@/constants/auth"; 17 + import { 18 + useMeMinimalQuery, 19 + useRefreshTokenMutation, 20 + } from "@/generated/graphql"; 21 + import { setGraphQLToken } from "@/utils/graphql-fetcher"; 22 + 23 + interface TokenData { 24 + accessToken: string; 25 + refreshToken: string; 26 + expiresAt: string; // ISO date string 27 + } 12 28 13 29 interface TokenContextType { 14 30 token: string | null; 15 - setToken: (token: string) => void; 31 + setTokens: (data: TokenData) => void; 16 32 clearToken: () => void; 17 33 isAuthenticated: boolean; 18 34 isLoading: boolean; ··· 21 37 const TokenContext = createContext<TokenContextType | null>(null); 22 38 23 39 export function TokenProvider({ children }: { children: ReactNode }) { 24 - const [token, setTokenState] = useState<string | null>(null); 40 + // Initialize tokens from localStorage on mount 41 + const [token, setTokenState] = useState<string | null>(() => 42 + localStorage.getItem(AUTH_TOKEN_KEY), 43 + ); 44 + const [refreshToken, setRefreshTokenState] = useState<string | null>(() => 45 + localStorage.getItem(AUTH_REFRESH_TOKEN_KEY), 46 + ); 47 + const [expiresAt, setExpiresAtState] = useState<string | null>(() => 48 + localStorage.getItem(AUTH_TOKEN_EXPIRY_KEY), 49 + ); 50 + 25 51 const [isAuthenticated, setIsAuthenticated] = useState(false); 26 52 const [isLoading, setIsLoading] = useState(true); 27 - const apolloClient = useApolloClient(); 53 + const queryClient = useQueryClient(); 54 + const refreshTimeoutRef = useRef<NodeJS.Timeout | null>(null); 55 + 56 + // Refresh token mutation 57 + const refreshTokenMutation = useRefreshTokenMutation(); 58 + 59 + // Sync token to GraphQL fetcher and localStorage whenever it changes 60 + const clearToken = useCallback(() => { 61 + setTokenState(null); 62 + setRefreshTokenState(null); 63 + setExpiresAtState(null); 64 + setIsAuthenticated(false); 65 + queryClient.clear(); 66 + 67 + if (refreshTimeoutRef.current) { 68 + clearTimeout(refreshTimeoutRef.current); 69 + refreshTimeoutRef.current = null; 70 + } 71 + }, [queryClient]); 28 72 29 73 useEffect(() => { 30 - const storedToken = localStorage.getItem(AUTH_TOKEN_KEY); 31 - setTokenState(storedToken); 32 - }, []); 74 + // Sync token with GraphQL fetcher 75 + setGraphQLToken(token, clearToken); 76 + 77 + // Sync to localStorage 78 + if (token) { 79 + localStorage.setItem(AUTH_TOKEN_KEY, token); 80 + } else { 81 + localStorage.removeItem(AUTH_TOKEN_KEY); 82 + } 83 + }, [token, clearToken]); 84 + 85 + useEffect(() => { 86 + if (refreshToken) { 87 + localStorage.setItem(AUTH_REFRESH_TOKEN_KEY, refreshToken); 88 + } else { 89 + localStorage.removeItem(AUTH_REFRESH_TOKEN_KEY); 90 + } 91 + }, [refreshToken]); 92 + 93 + useEffect(() => { 94 + if (expiresAt) { 95 + localStorage.setItem(AUTH_TOKEN_EXPIRY_KEY, expiresAt); 96 + } else { 97 + localStorage.removeItem(AUTH_TOKEN_EXPIRY_KEY); 98 + } 99 + }, [expiresAt]); 33 100 34 101 const { 35 102 data: meData, 36 - loading: meLoading, 103 + isLoading: meLoading, 37 104 error: meError, 38 - } = useMeMinimalQuery({ skip: !token }); 105 + } = useMeMinimalQuery(); 39 106 107 + // Function to refresh the access token 108 + const refreshAccessToken = useCallback(async () => { 109 + if (!refreshToken) { 110 + return; 111 + } 112 + 113 + try { 114 + const response = await refreshTokenMutation.mutateAsync({ 115 + refresh_token: refreshToken, 116 + }); 117 + 118 + // Update tokens with new values 119 + setTokenState(response.refreshToken.access_token); 120 + setRefreshTokenState(response.refreshToken.refresh_token); 121 + setExpiresAtState(response.refreshToken.expires_at); 122 + } catch (error) { 123 + console.error("Failed to refresh token:", error); 124 + // If refresh fails, clear all tokens and log out 125 + setTokenState(null); 126 + setRefreshTokenState(null); 127 + setExpiresAtState(null); 128 + setIsAuthenticated(false); 129 + queryClient.clear(); 130 + } 131 + }, [refreshToken, refreshTokenMutation, queryClient]); 132 + 133 + // Schedule token refresh before expiry 134 + useEffect(() => { 135 + // Clear any existing timeout 136 + if (refreshTimeoutRef.current) { 137 + clearTimeout(refreshTimeoutRef.current); 138 + refreshTimeoutRef.current = null; 139 + } 140 + 141 + if (!(token && refreshToken && expiresAt)) { 142 + return; 143 + } 144 + 145 + // Calculate time until token expires 146 + const expiryTime = new Date(expiresAt).getTime(); 147 + const now = Date.now(); 148 + const timeUntilExpiry = expiryTime - now; 149 + 150 + // Refresh 1 minute before expiry (or immediately if already expired) 151 + const refreshTime = Math.max(0, timeUntilExpiry - 60000); 152 + 153 + refreshTimeoutRef.current = setTimeout(() => { 154 + refreshAccessToken(); 155 + }, refreshTime); 156 + 157 + return () => { 158 + if (refreshTimeoutRef.current) { 159 + clearTimeout(refreshTimeoutRef.current); 160 + } 161 + }; 162 + }, [token, refreshToken, expiresAt, refreshAccessToken]); 163 + 164 + // Handle authentication state 40 165 useEffect(() => { 41 166 if (!token) { 42 167 setIsLoading(false); ··· 58 183 if (meError) { 59 184 setIsAuthenticated(false); 60 185 setIsLoading(false); 61 - localStorage.removeItem(AUTH_TOKEN_KEY); 62 - setTokenState(null); 186 + // Try to refresh the token if we have a refresh token 187 + if (refreshToken) { 188 + refreshAccessToken(); 189 + } else { 190 + // No refresh token, clear everything 191 + setTokenState(null); 192 + setRefreshTokenState(null); 193 + setExpiresAtState(null); 194 + } 63 195 } 64 - }, [token, meData, meLoading, meError]); 196 + }, [token, meData, meLoading, meError, refreshToken, refreshAccessToken]); 65 197 66 - const setToken = (newToken: string) => { 67 - localStorage.setItem(AUTH_TOKEN_KEY, newToken); 68 - setTokenState(newToken); 198 + const setTokens = useCallback((data: TokenData) => { 199 + setTokenState(data.accessToken); 200 + setRefreshTokenState(data.refreshToken); 201 + setExpiresAtState(data.expiresAt); 69 202 setIsLoading(true); 70 - }; 71 - 72 - const clearToken = () => { 73 - localStorage.removeItem(AUTH_TOKEN_KEY); 74 - setTokenState(null); 75 - setIsAuthenticated(false); 76 - apolloClient.clearStore(); 77 - }; 203 + }, []); 78 204 79 205 return ( 80 206 <TokenContext.Provider 81 - value={{ token, setToken, clearToken, isAuthenticated, isLoading }} 207 + value={{ token, setTokens, clearToken, isAuthenticated, isLoading }} 82 208 > 83 209 {children} 84 210 </TokenContext.Provider>
+7
apps/client/src/features/auth/queries/refresh-token.graphql
··· 1 + mutation RefreshToken($refresh_token: String!) { 2 + refreshToken(refresh_token: $refresh_token) { 3 + access_token 4 + refresh_token 5 + expires_at 6 + } 7 + }