fix(web):update auth token consumers

This commit is contained in:
Anish Sarkar 2026-06-23 12:57:01 +05:30
parent 411bb0019e
commit 71045e552d
4 changed files with 173 additions and 103 deletions

View file

@ -3,17 +3,18 @@
import { useEffect } from "react"; import { useEffect } from "react";
import { useGlobalLoadingEffect } from "@/hooks/use-global-loading"; import { useGlobalLoadingEffect } from "@/hooks/use-global-loading";
import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service"; import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service";
import { getAndClearRedirectPath, setBearerToken, setRefreshToken } from "@/lib/auth-utils"; import { getAndClearRedirectPath } from "@/lib/auth-utils";
import { buildBackendUrl } from "@/lib/env-config";
import { trackLoginSuccess } from "@/lib/posthog/events"; import { trackLoginSuccess } from "@/lib/posthog/events";
interface TokenHandlerProps { interface TokenHandlerProps {
redirectPath?: string; // Default path to redirect after storing token (if no saved path) redirectPath?: string; // Default path to redirect after storing token (if no saved path)
tokenParamName?: string; // Name of the URL parameter containing the token tokenParamName?: string; // Deprecated: tokens are no longer read from URLs
} }
/** /**
* Client component that extracts a token from URL parameters and stores it in localStorage * Client component that finalizes a cookie session after OAuth/local login.
* After storing the token, it redirects the user back to the page they were on before * After confirming the session, it redirects the user back to the page they were on before
* being redirected to login (if available), or to the default redirectPath. * being redirected to login (if available), or to the default redirectPath.
* *
* @param redirectPath - Default path to redirect after storing token (default: '/dashboard') * @param redirectPath - Default path to redirect after storing token (default: '/dashboard')
@ -21,7 +22,7 @@ interface TokenHandlerProps {
*/ */
const TokenHandler = ({ const TokenHandler = ({
redirectPath = "/dashboard", redirectPath = "/dashboard",
tokenParamName = "token", tokenParamName: _tokenParamName = "token",
}: TokenHandlerProps) => { }: TokenHandlerProps) => {
// Always show loading for this component - spinner animation won't reset // Always show loading for this component - spinner animation won't reset
useGlobalLoadingEffect(true); useGlobalLoadingEffect(true);
@ -30,51 +31,47 @@ const TokenHandler = ({
if (typeof window === "undefined") return; if (typeof window === "undefined") return;
const run = async () => { const run = async () => {
const params = new URLSearchParams(window.location.search); try {
const token = params.get(tokenParamName); const sessionResponse = await fetch(buildBackendUrl("/auth/session"), {
const refreshToken = params.get("refresh_token"); credentials: "include",
});
if (token) { if (!sessionResponse.ok) {
try { window.location.href = "/login";
const alreadyTracked = sessionStorage.getItem("login_success_tracked"); return;
if (!alreadyTracked) {
trackLoginSuccess("google");
}
sessionStorage.removeItem("login_success_tracked");
setBearerToken(token);
if (refreshToken) {
setRefreshToken(refreshToken);
}
// Auto-set active search space in desktop if not already set
if (window.electronAPI?.getActiveSearchSpace) {
try {
const stored = await window.electronAPI.getActiveSearchSpace();
if (!stored) {
const spaces = await searchSpacesApiService.getSearchSpaces();
if (spaces?.length) {
await window.electronAPI.setActiveSearchSpace?.(String(spaces[0].id));
}
}
} catch {
// non-critical
}
}
const savedRedirectPath = getAndClearRedirectPath();
const finalRedirectPath = savedRedirectPath || redirectPath;
window.location.href = finalRedirectPath;
} catch (error) {
console.error("Error storing token in localStorage:", error);
window.location.href = redirectPath;
} }
const alreadyTracked = sessionStorage.getItem("login_success_tracked");
if (!alreadyTracked) {
trackLoginSuccess("google");
}
sessionStorage.removeItem("login_success_tracked");
// Auto-set active search space in desktop if not already set
if (window.electronAPI?.getActiveSearchSpace) {
try {
const stored = await window.electronAPI.getActiveSearchSpace();
if (!stored) {
const spaces = await searchSpacesApiService.getSearchSpaces();
if (spaces?.length) {
await window.electronAPI.setActiveSearchSpace?.(String(spaces[0].id));
}
}
} catch {
// non-critical
}
}
const savedRedirectPath = getAndClearRedirectPath();
const finalRedirectPath = savedRedirectPath || redirectPath;
window.location.href = finalRedirectPath;
} catch (error) {
console.error("Error finalizing session:", error);
window.location.href = redirectPath;
} }
}; };
run(); run();
}, [tokenParamName, redirectPath]); }, [redirectPath]);
// Return null - the global provider handles the loading UI // Return null - the global provider handles the loading UI
return null; return null;

View file

@ -2,16 +2,17 @@
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useEffect } from "react"; import { useEffect } from "react";
import { getBearerToken } from "@/lib/auth-utils"; import { useSession } from "@/hooks/use-session";
export function AuthRedirect() { export function AuthRedirect() {
const router = useRouter(); const router = useRouter();
const session = useSession();
useEffect(() => { useEffect(() => {
if (getBearerToken()) { if (session.status === "authenticated") {
router.replace("/dashboard"); router.replace("/dashboard");
} }
}, [router]); }, [router, session.status]);
return null; return null;
} }

View file

@ -1,8 +1,11 @@
"use client"; "use client";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { usePathname } from "next/navigation";
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import { currentUserAtom } from "@/atoms/user/user-query.atoms"; import { currentUserAtom } from "@/atoms/user/user-query.atoms";
import { useSession } from "@/hooks/use-session";
import { isPublicRoute } from "@/lib/auth-utils";
import { identifyUser, resetUser } from "@/lib/posthog/events"; import { identifyUser, resetUser } from "@/lib/posthog/events";
/** /**
@ -12,7 +15,15 @@ import { identifyUser, resetUser } from "@/lib/posthog/events";
* *
* This should be rendered inside the PostHogProvider. * This should be rendered inside the PostHogProvider.
*/ */
export function PostHogIdentify() { function PostHogReset() {
useEffect(() => {
resetUser();
}, []);
return null;
}
function PostHogUserIdentify() {
const { data: user, isSuccess, isError } = useAtomValue(currentUserAtom); const { data: user, isSuccess, isError } = useAtomValue(currentUserAtom);
const previousUserIdRef = useRef<string | null>(null); const previousUserIdRef = useRef<string | null>(null);
@ -47,3 +58,27 @@ export function PostHogIdentify() {
// This component doesn't render anything // This component doesn't render anything
return null; return null;
} }
function SessionGatedPostHogIdentify() {
const session = useSession();
if (session.status === "loading") {
return null;
}
if (session.status === "unauthenticated") {
return <PostHogReset />;
}
return <PostHogUserIdentify />;
}
export function PostHogIdentify() {
const pathname = usePathname();
if (isPublicRoute(pathname)) {
return <PostHogReset />;
}
return <SessionGatedPostHogIdentify />;
}

View file

@ -7,9 +7,18 @@ const REDIRECT_PATH_KEY = "surfsense_redirect_path";
const BEARER_TOKEN_KEY = "surfsense_bearer_token"; const BEARER_TOKEN_KEY = "surfsense_bearer_token";
const REFRESH_TOKEN_KEY = "surfsense_refresh_token"; const REFRESH_TOKEN_KEY = "surfsense_refresh_token";
// Flag to prevent multiple simultaneous refresh attempts let desktopBearerToken: string | null = null;
let isRefreshing = false; let desktopRefreshToken: string | null = null;
let refreshPromise: Promise<string | null> | null = null;
function isDesktopClient(): boolean {
return typeof window !== "undefined" && !!window.electronAPI;
}
function purgeLegacyStoredTokens(): void {
if (typeof window === "undefined") return;
localStorage.removeItem(BEARER_TOKEN_KEY);
localStorage.removeItem(REFRESH_TOKEN_KEY);
}
/** Path prefixes for routes that do not require auth (no current-user fetch, no redirect on 401) */ /** Path prefixes for routes that do not require auth (no current-user fetch, no redirect on 401) */
const PUBLIC_ROUTE_PREFIXES = [ const PUBLIC_ROUTE_PREFIXES = [
@ -53,8 +62,9 @@ export function handleUnauthorized(): void {
const pathname = window.location.pathname; const pathname = window.location.pathname;
// Always clear tokens // Always clear tokens
localStorage.removeItem(BEARER_TOKEN_KEY); purgeLegacyStoredTokens();
localStorage.removeItem(REFRESH_TOKEN_KEY); desktopBearerToken = null;
desktopRefreshToken = null;
// Only redirect on protected routes; stay on public pages (e.g. /docs) // Only redirect on protected routes; stay on public pages (e.g. /docs)
if (!isPublicRoute(pathname)) { if (!isPublicRoute(pathname)) {
@ -93,8 +103,8 @@ export function getAndClearRedirectPath(): string | null {
* Gets the bearer token from localStorage * Gets the bearer token from localStorage
*/ */
export function getBearerToken(): string | null { export function getBearerToken(): string | null {
if (typeof window === "undefined") return null; if (typeof window === "undefined" || !isDesktopClient()) return null;
return localStorage.getItem(BEARER_TOKEN_KEY); return desktopBearerToken;
} }
/** /**
@ -102,7 +112,8 @@ export function getBearerToken(): string | null {
*/ */
export function setBearerToken(token: string): void { export function setBearerToken(token: string): void {
if (typeof window === "undefined") return; if (typeof window === "undefined") return;
localStorage.setItem(BEARER_TOKEN_KEY, token); purgeLegacyStoredTokens();
desktopBearerToken = isDesktopClient() ? token : null;
syncTokensToElectron(); syncTokensToElectron();
} }
@ -112,14 +123,15 @@ export function setBearerToken(token: string): void {
export function clearBearerToken(): void { export function clearBearerToken(): void {
if (typeof window === "undefined") return; if (typeof window === "undefined") return;
localStorage.removeItem(BEARER_TOKEN_KEY); localStorage.removeItem(BEARER_TOKEN_KEY);
desktopBearerToken = null;
} }
/** /**
* Gets the refresh token from localStorage * Gets the refresh token from localStorage
*/ */
export function getRefreshToken(): string | null { export function getRefreshToken(): string | null {
if (typeof window === "undefined") return null; if (typeof window === "undefined" || !isDesktopClient()) return null;
return localStorage.getItem(REFRESH_TOKEN_KEY); return desktopRefreshToken;
} }
/** /**
@ -127,7 +139,8 @@ export function getRefreshToken(): string | null {
*/ */
export function setRefreshToken(token: string): void { export function setRefreshToken(token: string): void {
if (typeof window === "undefined") return; if (typeof window === "undefined") return;
localStorage.setItem(REFRESH_TOKEN_KEY, token); purgeLegacyStoredTokens();
desktopRefreshToken = isDesktopClient() ? token : null;
syncTokensToElectron(); syncTokensToElectron();
} }
@ -137,6 +150,7 @@ export function setRefreshToken(token: string): void {
export function clearRefreshToken(): void { export function clearRefreshToken(): void {
if (typeof window === "undefined") return; if (typeof window === "undefined") return;
localStorage.removeItem(REFRESH_TOKEN_KEY); localStorage.removeItem(REFRESH_TOKEN_KEY);
desktopRefreshToken = null;
} }
/** /**
@ -153,8 +167,8 @@ export function clearAllTokens(): void {
*/ */
function syncTokensToElectron(): void { function syncTokensToElectron(): void {
if (typeof window === "undefined" || !window.electronAPI?.setAuthTokens) return; if (typeof window === "undefined" || !window.electronAPI?.setAuthTokens) return;
const bearer = localStorage.getItem(BEARER_TOKEN_KEY) || ""; const bearer = desktopBearerToken || "";
const refresh = localStorage.getItem(REFRESH_TOKEN_KEY) || ""; const refresh = desktopRefreshToken || "";
if (bearer) { if (bearer) {
window.electronAPI.setAuthTokens(bearer, refresh); window.electronAPI.setAuthTokens(bearer, refresh);
} }
@ -171,11 +185,18 @@ export async function ensureTokensFromElectron(): Promise<boolean> {
if (getBearerToken()) return true; if (getBearerToken()) return true;
try { try {
if (window.electronAPI.getAccessToken) {
const token = await window.electronAPI.getAccessToken();
if (token) {
desktopBearerToken = token;
return true;
}
}
const tokens = await window.electronAPI.getAuthTokens(); const tokens = await window.electronAPI.getAuthTokens();
if (tokens?.bearer) { if (tokens?.bearer) {
localStorage.setItem(BEARER_TOKEN_KEY, tokens.bearer); desktopBearerToken = tokens.bearer;
if (tokens.refresh) { if (tokens.refresh) {
localStorage.setItem(REFRESH_TOKEN_KEY, tokens.refresh); desktopRefreshToken = tokens.refresh;
} }
return true; return true;
} }
@ -191,16 +212,24 @@ export async function ensureTokensFromElectron(): Promise<boolean> {
*/ */
export async function logout(): Promise<boolean> { export async function logout(): Promise<boolean> {
const refreshToken = getRefreshToken(); const refreshToken = getRefreshToken();
const isDesktop = isDesktopClient();
if (isDesktop && window.electronAPI?.logout) {
await window.electronAPI.logout();
clearAllTokens();
return true;
}
// Call backend to revoke the refresh token // Call backend to revoke the refresh token
if (refreshToken) { if (refreshToken || !isDesktop) {
try { try {
const response = await fetch(buildBackendUrl("/auth/jwt/revoke"), { const response = await fetch(buildBackendUrl("/auth/jwt/revoke"), {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
body: JSON.stringify({ refresh_token: refreshToken }), credentials: "include",
...(refreshToken ? { body: JSON.stringify({ refresh_token: refreshToken }) } : {}),
}); });
if (!response.ok) { if (!response.ok) {
@ -221,7 +250,7 @@ export async function logout(): Promise<boolean> {
* Checks if the user is authenticated (has a token) * Checks if the user is authenticated (has a token)
*/ */
export function isAuthenticated(): boolean { export function isAuthenticated(): boolean {
return !!getBearerToken(); return isDesktopClient() ? !!getBearerToken() : true;
} }
/** /**
@ -259,50 +288,56 @@ export function getAuthHeaders(additionalHeaders?: Record<string, string>): Reco
* Attempts to refresh the access token using the stored refresh token. * Attempts to refresh the access token using the stored refresh token.
* Returns the new access token if successful, null otherwise. * Returns the new access token if successful, null otherwise.
*/ */
export async function refreshAccessToken(): Promise<string | null> { async function doRefreshSession(): Promise<string | null> {
// If already refreshing, wait for that request to complete
if (isRefreshing && refreshPromise) {
return refreshPromise;
}
const currentRefreshToken = getRefreshToken(); const currentRefreshToken = getRefreshToken();
if (!currentRefreshToken) { if (isDesktopClient() && !currentRefreshToken) {
if (window.electronAPI?.refreshAccessToken) {
const token = await window.electronAPI.refreshAccessToken();
if (token) {
desktopBearerToken = token;
}
return token;
}
return null; return null;
} }
isRefreshing = true; try {
refreshPromise = (async () => { const response = await fetch(buildBackendUrl("/auth/jwt/refresh"), {
try { method: "POST",
const response = await fetch(buildBackendUrl("/auth/jwt/refresh"), { headers: {
method: "POST", "Content-Type": "application/json",
headers: { },
"Content-Type": "application/json", credentials: "include",
}, ...(currentRefreshToken ? { body: JSON.stringify({ refresh_token: currentRefreshToken }) } : {}),
body: JSON.stringify({ refresh_token: currentRefreshToken }), });
});
if (!response.ok) { if (!response.ok) {
// Refresh failed, clear tokens clearAllTokens();
clearAllTokens();
return null;
}
const data = await response.json();
if (data.access_token && data.refresh_token) {
setBearerToken(data.access_token);
setRefreshToken(data.refresh_token);
return data.access_token;
}
return null; return null;
} catch {
return null;
} finally {
isRefreshing = false;
refreshPromise = null;
} }
})();
return refreshPromise; const data = await response.json();
if (isDesktopClient() && data.access_token) {
setBearerToken(data.access_token);
if (data.refresh_token) {
setRefreshToken(data.refresh_token);
}
}
return data.access_token ?? null;
} catch {
return null;
}
}
export async function refreshSession(): Promise<string | null> {
if (typeof navigator !== "undefined" && "locks" in navigator) {
return navigator.locks.request("ss-token-refresh", () => doRefreshSession());
}
return doRefreshSession();
}
export async function refreshAccessToken(): Promise<string | null> {
return refreshSession();
} }
/** /**
@ -321,6 +356,7 @@ export async function authenticatedFetch(
const response = await fetch(url, { const response = await fetch(url, {
...fetchOptions, ...fetchOptions,
headers, headers,
credentials: "include",
}); });
// Handle 401 Unauthorized // Handle 401 Unauthorized
@ -337,6 +373,7 @@ export async function authenticatedFetch(
return fetch(url, { return fetch(url, {
...fetchOptions, ...fetchOptions,
headers: retryHeaders, headers: retryHeaders,
credentials: "include",
}); });
} }
} }