Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 60 additions & 12 deletions frontend/src/context/AuthContext.jsx
Original file line number Diff line number Diff line change
@@ -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);
Expand All @@ -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);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Expand All @@ -48,6 +79,9 @@ export const AuthProvider = ({ children }) => {
*/
const login = useCallback((userData) => {
setUser(userData);
setAuthUnknown(false);
setAuthError("");
writeSessionHint();
}, []);

/**
Expand All @@ -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;
}
Expand All @@ -88,6 +134,8 @@ export const AuthProvider = ({ children }) => {
setUser,
isAuthenticated,
loading,
authError,
authUnknown,
login,
logout,
refreshUser
Expand Down
36 changes: 20 additions & 16 deletions frontend/src/services/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
// 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);
Expand Down