diff --git a/surfsense_web/components/TokenHandler.tsx b/surfsense_web/components/TokenHandler.tsx index 97e937526..64e99487e 100644 --- a/surfsense_web/components/TokenHandler.tsx +++ b/surfsense_web/components/TokenHandler.tsx @@ -3,17 +3,18 @@ import { useEffect } from "react"; import { useGlobalLoadingEffect } from "@/hooks/use-global-loading"; 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"; interface TokenHandlerProps { 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 - * After storing the token, it redirects the user back to the page they were on before + * Client component that finalizes a cookie session after OAuth/local login. + * 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. * * @param redirectPath - Default path to redirect after storing token (default: '/dashboard') @@ -21,7 +22,7 @@ interface TokenHandlerProps { */ const TokenHandler = ({ redirectPath = "/dashboard", - tokenParamName = "token", + tokenParamName: _tokenParamName = "token", }: TokenHandlerProps) => { // Always show loading for this component - spinner animation won't reset useGlobalLoadingEffect(true); @@ -30,51 +31,47 @@ const TokenHandler = ({ if (typeof window === "undefined") return; const run = async () => { - const params = new URLSearchParams(window.location.search); - const token = params.get(tokenParamName); - const refreshToken = params.get("refresh_token"); - - if (token) { - try { - const alreadyTracked = sessionStorage.getItem("login_success_tracked"); - 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; + try { + const sessionResponse = await fetch(buildBackendUrl("/auth/session"), { + credentials: "include", + }); + if (!sessionResponse.ok) { + window.location.href = "/login"; + return; } + + 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(); - }, [tokenParamName, redirectPath]); + }, [redirectPath]); // Return null - the global provider handles the loading UI return null; diff --git a/surfsense_web/components/homepage/auth-redirect.tsx b/surfsense_web/components/homepage/auth-redirect.tsx index 6697ab744..43073cd7d 100644 --- a/surfsense_web/components/homepage/auth-redirect.tsx +++ b/surfsense_web/components/homepage/auth-redirect.tsx @@ -2,16 +2,17 @@ import { useRouter } from "next/navigation"; import { useEffect } from "react"; -import { getBearerToken } from "@/lib/auth-utils"; +import { useSession } from "@/hooks/use-session"; export function AuthRedirect() { const router = useRouter(); + const session = useSession(); useEffect(() => { - if (getBearerToken()) { + if (session.status === "authenticated") { router.replace("/dashboard"); } - }, [router]); + }, [router, session.status]); return null; } diff --git a/surfsense_web/components/providers/PostHogIdentify.tsx b/surfsense_web/components/providers/PostHogIdentify.tsx index 57a7766b8..f85a5052a 100644 --- a/surfsense_web/components/providers/PostHogIdentify.tsx +++ b/surfsense_web/components/providers/PostHogIdentify.tsx @@ -1,8 +1,11 @@ "use client"; import { useAtomValue } from "jotai"; +import { usePathname } from "next/navigation"; import { useEffect, useRef } from "react"; 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"; /** @@ -12,7 +15,15 @@ import { identifyUser, resetUser } from "@/lib/posthog/events"; * * 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 previousUserIdRef = useRef(null); @@ -47,3 +58,27 @@ export function PostHogIdentify() { // This component doesn't render anything return null; } + +function SessionGatedPostHogIdentify() { + const session = useSession(); + + if (session.status === "loading") { + return null; + } + + if (session.status === "unauthenticated") { + return ; + } + + return ; +} + +export function PostHogIdentify() { + const pathname = usePathname(); + + if (isPublicRoute(pathname)) { + return ; + } + + return ; +} diff --git a/surfsense_web/lib/auth-utils.ts b/surfsense_web/lib/auth-utils.ts index 8ad10308b..33bdb79fd 100644 --- a/surfsense_web/lib/auth-utils.ts +++ b/surfsense_web/lib/auth-utils.ts @@ -7,9 +7,18 @@ const REDIRECT_PATH_KEY = "surfsense_redirect_path"; const BEARER_TOKEN_KEY = "surfsense_bearer_token"; const REFRESH_TOKEN_KEY = "surfsense_refresh_token"; -// Flag to prevent multiple simultaneous refresh attempts -let isRefreshing = false; -let refreshPromise: Promise | null = null; +let desktopBearerToken: string | null = null; +let desktopRefreshToken: string | 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) */ const PUBLIC_ROUTE_PREFIXES = [ @@ -53,8 +62,9 @@ export function handleUnauthorized(): void { const pathname = window.location.pathname; // Always clear tokens - localStorage.removeItem(BEARER_TOKEN_KEY); - localStorage.removeItem(REFRESH_TOKEN_KEY); + purgeLegacyStoredTokens(); + desktopBearerToken = null; + desktopRefreshToken = null; // Only redirect on protected routes; stay on public pages (e.g. /docs) if (!isPublicRoute(pathname)) { @@ -93,8 +103,8 @@ export function getAndClearRedirectPath(): string | null { * Gets the bearer token from localStorage */ export function getBearerToken(): string | null { - if (typeof window === "undefined") return null; - return localStorage.getItem(BEARER_TOKEN_KEY); + if (typeof window === "undefined" || !isDesktopClient()) return null; + return desktopBearerToken; } /** @@ -102,7 +112,8 @@ export function getBearerToken(): string | null { */ export function setBearerToken(token: string): void { if (typeof window === "undefined") return; - localStorage.setItem(BEARER_TOKEN_KEY, token); + purgeLegacyStoredTokens(); + desktopBearerToken = isDesktopClient() ? token : null; syncTokensToElectron(); } @@ -112,14 +123,15 @@ export function setBearerToken(token: string): void { export function clearBearerToken(): void { if (typeof window === "undefined") return; localStorage.removeItem(BEARER_TOKEN_KEY); + desktopBearerToken = null; } /** * Gets the refresh token from localStorage */ export function getRefreshToken(): string | null { - if (typeof window === "undefined") return null; - return localStorage.getItem(REFRESH_TOKEN_KEY); + if (typeof window === "undefined" || !isDesktopClient()) return null; + return desktopRefreshToken; } /** @@ -127,7 +139,8 @@ export function getRefreshToken(): string | null { */ export function setRefreshToken(token: string): void { if (typeof window === "undefined") return; - localStorage.setItem(REFRESH_TOKEN_KEY, token); + purgeLegacyStoredTokens(); + desktopRefreshToken = isDesktopClient() ? token : null; syncTokensToElectron(); } @@ -137,6 +150,7 @@ export function setRefreshToken(token: string): void { export function clearRefreshToken(): void { if (typeof window === "undefined") return; localStorage.removeItem(REFRESH_TOKEN_KEY); + desktopRefreshToken = null; } /** @@ -153,8 +167,8 @@ export function clearAllTokens(): void { */ function syncTokensToElectron(): void { if (typeof window === "undefined" || !window.electronAPI?.setAuthTokens) return; - const bearer = localStorage.getItem(BEARER_TOKEN_KEY) || ""; - const refresh = localStorage.getItem(REFRESH_TOKEN_KEY) || ""; + const bearer = desktopBearerToken || ""; + const refresh = desktopRefreshToken || ""; if (bearer) { window.electronAPI.setAuthTokens(bearer, refresh); } @@ -171,11 +185,18 @@ export async function ensureTokensFromElectron(): Promise { if (getBearerToken()) return true; try { + if (window.electronAPI.getAccessToken) { + const token = await window.electronAPI.getAccessToken(); + if (token) { + desktopBearerToken = token; + return true; + } + } const tokens = await window.electronAPI.getAuthTokens(); if (tokens?.bearer) { - localStorage.setItem(BEARER_TOKEN_KEY, tokens.bearer); + desktopBearerToken = tokens.bearer; if (tokens.refresh) { - localStorage.setItem(REFRESH_TOKEN_KEY, tokens.refresh); + desktopRefreshToken = tokens.refresh; } return true; } @@ -191,16 +212,24 @@ export async function ensureTokensFromElectron(): Promise { */ export async function logout(): Promise { 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 - if (refreshToken) { + if (refreshToken || !isDesktop) { try { const response = await fetch(buildBackendUrl("/auth/jwt/revoke"), { method: "POST", headers: { "Content-Type": "application/json", }, - body: JSON.stringify({ refresh_token: refreshToken }), + credentials: "include", + ...(refreshToken ? { body: JSON.stringify({ refresh_token: refreshToken }) } : {}), }); if (!response.ok) { @@ -221,7 +250,7 @@ export async function logout(): Promise { * Checks if the user is authenticated (has a token) */ export function isAuthenticated(): boolean { - return !!getBearerToken(); + return isDesktopClient() ? !!getBearerToken() : true; } /** @@ -259,50 +288,56 @@ export function getAuthHeaders(additionalHeaders?: Record): Reco * Attempts to refresh the access token using the stored refresh token. * Returns the new access token if successful, null otherwise. */ -export async function refreshAccessToken(): Promise { - // If already refreshing, wait for that request to complete - if (isRefreshing && refreshPromise) { - return refreshPromise; - } - +async function doRefreshSession(): Promise { 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; } - isRefreshing = true; - refreshPromise = (async () => { - try { - const response = await fetch(buildBackendUrl("/auth/jwt/refresh"), { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ refresh_token: currentRefreshToken }), - }); + try { + const response = await fetch(buildBackendUrl("/auth/jwt/refresh"), { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + credentials: "include", + ...(currentRefreshToken ? { body: JSON.stringify({ refresh_token: currentRefreshToken }) } : {}), + }); - if (!response.ok) { - // Refresh failed, clear tokens - 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; - } + if (!response.ok) { + clearAllTokens(); 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 { + if (typeof navigator !== "undefined" && "locks" in navigator) { + return navigator.locks.request("ss-token-refresh", () => doRefreshSession()); + } + return doRefreshSession(); +} + +export async function refreshAccessToken(): Promise { + return refreshSession(); } /** @@ -321,6 +356,7 @@ export async function authenticatedFetch( const response = await fetch(url, { ...fetchOptions, headers, + credentials: "include", }); // Handle 401 Unauthorized @@ -337,6 +373,7 @@ export async function authenticatedFetch( return fetch(url, { ...fetchOptions, headers: retryHeaders, + credentials: "include", }); } }