diff --git a/frontend/src/context/AuthContext.jsx b/frontend/src/context/AuthContext.jsx index a968b55..2f5a653 100644 --- a/frontend/src/context/AuthContext.jsx +++ b/frontend/src/context/AuthContext.jsx @@ -1,7 +1,26 @@ +/* eslint-disable react-refresh/only-export-components */ import { createContext, useContext, useState, useEffect, useCallback } from "react"; import { getMe, logoutApi } from "../services/authService"; const AuthContext = createContext(null); +const AUTH_SESSION_HINT_KEY = "codelens.auth.sessionHint"; + +const readSessionHint = () => { + if (typeof window === "undefined") return false; + return window.sessionStorage.getItem(AUTH_SESSION_HINT_KEY) === "true"; +}; + +const writeSessionHint = () => { + if (typeof window !== "undefined") { + window.sessionStorage.setItem(AUTH_SESSION_HINT_KEY, "true"); + } +}; + +const clearSessionHint = () => { + if (typeof window !== "undefined") { + window.sessionStorage.removeItem(AUTH_SESSION_HINT_KEY); + } +}; export const useAuth = () => { const context = useContext(AuthContext); @@ -12,28 +31,40 @@ export const useAuth = () => { export const AuthProvider = ({ children }) => { const [user, setUser] = useState(null); const [loading, setLoading] = useState(true); + const [authError, setAuthError] = useState(""); + const [authUnknown, setAuthUnknown] = useState(readSessionHint); /** - * isAuthenticated: true only when we have a confirmed user object from the server. - * We do NOT rely on localStorage or any client-side token — the server's HttpOnly - * cookie is the single source of truth. + * isAuthenticated remains true for a previously confirmed session when the + * bootstrap profile call hits a transient failure. The server's HttpOnly + * cookie is still the source of truth; authUnknown only prevents route guards + * from treating temporary API downtime as a logout. */ - const isAuthenticated = !!user; + const isAuthenticated = !!user || authUnknown; /** * On mount: ask the server "who am I?" using the HttpOnly cookie. - * If the cookie is valid, the server returns the user object. - * If not (cookie expired/missing), server returns 401 and we stay logged out. + * A real auth failure clears the user; transient failures preserve current + * state so temporary API issues do not force a logout. */ useEffect(() => { const initAuth = async () => { try { const response = await getMe(); - // response.data is the user object from GET /api/auth/me setUser(response.data); - } catch { - // 401 = not logged in (cookie missing/expired) — this is normal, not an error - setUser(null); + setAuthUnknown(false); + setAuthError(""); + writeSessionHint(); + } catch (err) { + if ([401, 403].includes(err?.response?.status)) { + setUser(null); + setAuthUnknown(false); + setAuthError(""); + clearSessionHint(); + } else { + setAuthUnknown(readSessionHint()); + setAuthError("We could not refresh your session. Please retry when the connection is stable."); + } } finally { setLoading(false); } @@ -48,6 +79,9 @@ export const AuthProvider = ({ children }) => { */ const login = useCallback((userData) => { setUser(userData); + setAuthUnknown(false); + setAuthError(""); + writeSessionHint(); }, []); /** @@ -63,21 +97,33 @@ export const AuthProvider = ({ children }) => { // Even if the API call fails, clear local state } finally { setUser(null); + setAuthUnknown(false); + setAuthError(""); + clearSessionHint(); } }, []); /** * Call this to refresh the user object from the server after profile changes - * (e.g., after connecting GitHub, updating Codeforces handle, etc.) + * (e.g., after connecting GitHub, updating Codeforces handle, etc.). + * Only confirmed auth failures clear the current user. */ const refreshUser = useCallback(async () => { try { const response = await getMe(); setUser(response.data); + setAuthUnknown(false); + setAuthError(""); + writeSessionHint(); return response.data; } catch (err) { - if (err?.response?.status === 401) { + if ([401, 403].includes(err?.response?.status)) { setUser(null); + setAuthUnknown(false); + setAuthError(""); + clearSessionHint(); + } else { + setAuthError("We could not refresh your session. Please retry when the connection is stable."); } return null; } @@ -88,6 +134,8 @@ export const AuthProvider = ({ children }) => { setUser, isAuthenticated, loading, + authError, + authUnknown, login, logout, refreshUser diff --git a/frontend/src/services/api.js b/frontend/src/services/api.js index 08ab156..790733a 100644 --- a/frontend/src/services/api.js +++ b/frontend/src/services/api.js @@ -3,36 +3,40 @@ import axios from "axios"; const api = axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL, headers: { "Content-Type": "application/json" }, - withCredentials: true, // CRITICAL: sends HttpOnly cookies with every request automatically + withCredentials: true }); -// ── Request interceptor ─────────────────────────────────────────────────────── +const AUTH_FAILURE_STATUSES = new Set([401, 403]); +const AUTH_ONLY_PATHS = new Set(["/api/auth/me", "/api/user/profile"]); + +const getRequestPathname = (requestUrl) => { + try { + const origin = typeof window !== "undefined" ? window.location.origin : "http://localhost"; + const { pathname } = new URL(requestUrl, origin); + return pathname.replace(/\/+$/, "") || "/"; + } catch { + return ""; + } +}; + // No token injection needed — cookies are sent automatically by the browser. -// We keep this interceptor only as a hook for future request modifications. api.interceptors.request.use((config) => config); -// ── Response interceptor ────────────────────────────────────────────────────── -// IMPORTANT: Only redirect to /login for 401s on OUR auth-sensitive endpoints. -// Do NOT globally redirect on all 401s — this causes logout when the GitHub API -// returns 401 (e.g. expired GitHub OAuth token) via our proxy endpoints. -const AUTH_ONLY_PATHS = ["/api/auth/me", "/api/user/profile"]; - +// Redirect only when the application's own session endpoints confirm auth loss. +// Other 401/403 responses, such as GitHub proxy failures, should be handled by +// the feature page that made the request. api.interceptors.response.use( (response) => response, - async (error) => { + (error) => { const status = error.response?.status; const requestUrl = error.config?.url || ""; - if (status === 401) { - const isAuthPath = AUTH_ONLY_PATHS.some((p) => requestUrl.includes(p)); + if (AUTH_FAILURE_STATUSES.has(status)) { + const isAuthPath = AUTH_ONLY_PATHS.has(getRequestPathname(requestUrl)); if (isAuthPath) { - // Our session is truly gone — redirect to login - // Use replace to avoid the broken page being in browser history window.location.replace("/login"); } - // For all other 401s (GitHub proxy, etc.), just propagate the error - // so the specific page can handle it gracefully (e.g. "Reconnect GitHub") } return Promise.reject(error);