session fixes
All checks were successful
CI/CD Pipeline / Build and Push App Docker Image (push) Successful in 2m52s
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 11s
CI/CD Pipeline / Deploy to Kubernetes (push) Successful in 44s

This commit is contained in:
yohlo
2026-02-09 23:53:54 -06:00
parent 9ed054e5d0
commit 236fcda671
6 changed files with 162 additions and 47 deletions

View File

@@ -11,6 +11,7 @@ import { type QueryClient } from "@tanstack/react-query";
import { ensureSuperTokensFrontend } from "@/lib/supertokens/client"; import { ensureSuperTokensFrontend } from "@/lib/supertokens/client";
import { AuthContextType } from "@/contexts/auth-context"; import { AuthContextType } from "@/contexts/auth-context";
import Providers from "@/features/core/components/providers"; import Providers from "@/features/core/components/providers";
import { SessionMonitor } from "@/components/session-monitor";
import { ColorSchemeScript, mantineHtmlProps } from "@mantine/core"; import { ColorSchemeScript, mantineHtmlProps } from "@mantine/core";
import { HeaderConfig } from "@/features/core/types/header-config"; import { HeaderConfig } from "@/features/core/types/header-config";
import { playerQueries } from "@/features/players/queries"; import { playerQueries } from "@/features/players/queries";
@@ -126,6 +127,7 @@ function RootComponent() {
return ( return (
<RootDocument> <RootDocument>
<Providers> <Providers>
<SessionMonitor />
<Outlet /> <Outlet />
</Providers> </Providers>
</RootDocument> </RootDocument>

View File

@@ -2,7 +2,8 @@ import { createFileRoute } from '@tanstack/react-router'
import { useEffect, useRef } from 'react' import { useEffect, useRef } from 'react'
import FullScreenLoader from '@/components/full-screen-loader' import FullScreenLoader from '@/components/full-screen-loader'
import { attemptRefreshingSession } from 'supertokens-web-js/recipe/session' import { attemptRefreshingSession } from 'supertokens-web-js/recipe/session'
import { resetRefreshFlag } from '@/lib/supertokens/client' import { resetRefreshFlag, getOrCreateRefreshPromise } from '@/lib/supertokens/client'
import { logger } from '@/lib/supertokens'
export const Route = createFileRoute('/refresh-session')({ export const Route = createFileRoute('/refresh-session')({
component: RouteComponent, component: RouteComponent,
@@ -17,23 +18,31 @@ function RouteComponent() {
const handleRefresh = async () => { const handleRefresh = async () => {
try { try {
resetRefreshFlag(); logger.info("Refresh session route: starting refresh");
const refreshed = await attemptRefreshingSession()
const refreshed = await getOrCreateRefreshPromise(async () => {
return await attemptRefreshingSession();
});
if (refreshed) { if (refreshed) {
const urlParams = new URLSearchParams(window.location.search) logger.info("Refresh session route: refresh successful");
const redirect = urlParams.get('redirect') const urlParams = new URLSearchParams(window.location.search);
const redirect = urlParams.get('redirect');
if (redirect && !redirect.includes('_serverFn') && !redirect.includes('/api/')) { if (redirect && !redirect.includes('_serverFn') && !redirect.includes('/api/')) {
window.location.href = decodeURIComponent(redirect) logger.info("Refresh session route: redirecting to", redirect);
window.location.href = decodeURIComponent(redirect);
} else { } else {
logger.info("Refresh session route: redirecting to home");
window.location.href = '/'; window.location.href = '/';
} }
} else { } else {
window.location.href = '/login' logger.warn("Refresh session route: refresh failed, redirecting to login");
window.location.href = '/login';
} }
} catch (error) { } catch (error) {
window.location.href = '/login' logger.error("Refresh session route: error during refresh", error);
window.location.href = '/login';
} }
} }

View File

@@ -0,0 +1,60 @@
import { useEffect, useRef } from 'react';
import { doesSessionExist } from 'supertokens-web-js/recipe/session';
import { getOrCreateRefreshPromise } from '@/lib/supertokens/client';
import { attemptRefreshingSession } from 'supertokens-web-js/recipe/session';
import { logger } from '@/lib/supertokens';
export function SessionMonitor() {
const lastRefreshTimeRef = useRef<number>(0);
const REFRESH_COOLDOWN = 30 * 1000;
useEffect(() => {
if (typeof window === 'undefined') return;
const handleVisibilityChange = async () => {
if (document.visibilityState !== 'visible') return;
const publicRoutes = ['/login', '/logout', '/refresh-session'];
if (publicRoutes.some(route => window.location.pathname === route)) {
return;
}
const now = Date.now();
if (now - lastRefreshTimeRef.current < REFRESH_COOLDOWN) {
logger.info('Session monitor: skipping refresh (cooldown)');
return;
}
try {
const sessionExists = await doesSessionExist();
if (!sessionExists) {
logger.info('Session monitor: no session exists, skipping refresh');
return;
}
logger.info('Session monitor: tab became visible, refreshing session');
const refreshed = await getOrCreateRefreshPromise(async () => {
return await attemptRefreshingSession();
});
if (refreshed) {
lastRefreshTimeRef.current = Date.now();
logger.info('Session monitor: session refreshed successfully');
} else {
logger.warn('Session monitor: refresh returned false');
}
} catch (error) {
logger.error('Session monitor: error refreshing session', error);
}
};
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => {
document.removeEventListener('visibilitychange', handleVisibilityChange);
};
}, []);
return null;
}

View File

@@ -1,5 +1,8 @@
import { useServerSuspenseQuery } from "@/lib/tanstack-query/hooks"; import { useServerSuspenseQuery } from "@/lib/tanstack-query/hooks";
import { listPlayers, getPlayer, getUnassociatedPlayers, fetchMe, getPlayerStats, getAllPlayerStats, getPlayerMatches, getUnenrolledPlayers, getPlayersActivity } from "./server"; import { listPlayers, getPlayer, getUnassociatedPlayers, fetchMe, getPlayerStats, getAllPlayerStats, getPlayerMatches, getUnenrolledPlayers, getPlayersActivity } from "./server";
import { logger } from '@/lib/supertokens';
let queryRefreshRedirect: Promise<void> | null = null;
export const playerKeys = { export const playerKeys = {
auth: ['auth'], auth: ['auth'],
@@ -54,24 +57,45 @@ export const playerQueries = {
export const useMe = () => { export const useMe = () => {
const { queryKey, queryFn } = playerQueries.auth(); const { queryKey, queryFn } = playerQueries.auth();
return useServerSuspenseQuery({ return useServerSuspenseQuery({
queryKey, queryKey,
queryFn, queryFn,
options: { options: {
staleTime: 0, staleTime: 30 * 1000,
refetchOnMount: true, refetchOnMount: false,
refetchOnWindowFocus: true,
retry: (failureCount, error: any) => { retry: (failureCount, error: any) => {
if (error?.response?.status === 401) { if (error?.response?.status === 401) {
const errorData = error?.response?.data; const errorData = error?.response?.data;
if (errorData?.error === "SESSION_REFRESH_REQUIRED") { if (errorData?.error === 'SESSION_REFRESH_REQUIRED') {
const currentUrl = window.location.pathname + window.location.search; logger.warn("Query detected SESSION_REFRESH_REQUIRED");
window.location.href = `/refresh-session?redirect=${encodeURIComponent(currentUrl)}`;
if (!queryRefreshRedirect) {
const currentUrl = window.location.pathname + window.location.search;
logger.info("Query initiating refresh redirect to:", currentUrl);
queryRefreshRedirect = new Promise<void>((resolve) => {
setTimeout(() => {
window.location.href = `/refresh-session?redirect=${encodeURIComponent(currentUrl)}`;
resolve();
}, 100);
});
queryRefreshRedirect.finally(() => {
setTimeout(() => {
queryRefreshRedirect = null;
}, 1000);
});
} else {
logger.info("Query: refresh redirect already in progress");
}
return false; return false;
} }
} }
return failureCount < 3; return failureCount < 3;
} },
} },
}); });
}; };

View File

@@ -4,30 +4,34 @@ import Passwordless from "supertokens-web-js/recipe/passwordless";
import { appInfo } from "./config"; import { appInfo } from "./config";
import { logger } from "./"; import { logger } from "./";
let refreshAttemptCount = 0; let refreshPromise: Promise<boolean> | null = null;
export const resetRefreshFlag = () => { export const resetRefreshFlag = () => {
refreshAttemptCount = 0; refreshPromise = null;
}; };
const setupFetchInterceptor = () => { export const getOrCreateRefreshPromise = (refreshFn: () => Promise<boolean>): Promise<boolean> => {
if (typeof window === 'undefined') return; if (refreshPromise) {
logger.info("Reusing existing refresh promise");
return refreshPromise;
}
const originalFetch = window.fetch; logger.info("Creating new refresh promise");
//@ts-ignore refreshPromise = refreshFn()
window.fetch = async (resource: RequestInfo | URL, options?: RequestInit) => { .then((result) => {
const url = typeof resource === 'string' ? resource : logger.info("Refresh completed successfully:", result);
resource instanceof URL ? resource.toString() : resource.url; setTimeout(() => {
refreshPromise = null;
}, 500);
return result;
})
.catch((error) => {
logger.error("Refresh failed:", error);
refreshPromise = null;
throw error;
});
if (url.includes('/api/auth/session/refresh')) { return refreshPromise;
refreshAttemptCount++;
if (refreshAttemptCount > 1) {
throw new Error('Duplicate refresh attempt blocked');
}
}
return originalFetch.call(window, resource, options);
};
}; };
export const frontendConfig = () => { export const frontendConfig = () => {
@@ -53,7 +57,6 @@ export function ensureSuperTokensFrontend() {
if (typeof window === "undefined") return; if (typeof window === "undefined") return;
if (!initialized) { if (!initialized) {
setupFetchInterceptor();
SuperTokens.init(frontendConfig()); SuperTokens.init(frontendConfig());
initialized = true; initialized = true;
logger.info("SuperTokens initialized"); logger.info("SuperTokens initialized");

View File

@@ -1,8 +1,10 @@
import { useMutation, UseMutationOptions } from "@tanstack/react-query"; import { useMutation, UseMutationOptions } from "@tanstack/react-query";
import { ServerResult } from "../types"; import { ServerResult } from "../types";
import toast from '@/lib/sonner' import toast from '@/lib/sonner'
import { logger } from '@/lib/supertokens'
let isMutationRefreshingSession = false;
let sessionRefreshRedirect: Promise<void> | null = null;
export function useServerMutation<TData, TVariables = unknown>( export function useServerMutation<TData, TVariables = unknown>(
options: Omit<UseMutationOptions<TData, Error, TVariables>, 'mutationFn'> & { options: Omit<UseMutationOptions<TData, Error, TVariables>, 'mutationFn'> & {
@@ -39,24 +41,39 @@ export function useServerMutation<TData, TVariables = unknown>(
} catch (error: any) { } catch (error: any) {
if (error?.response?.status === 401) { if (error?.response?.status === 401) {
try { try {
const errorData = typeof error.response.data === 'string' const errorData = typeof error.response.data === 'string'
? JSON.parse(error.response.data) ? JSON.parse(error.response.data)
: error.response.data; : error.response.data;
if (errorData?.error === "SESSION_REFRESH_REQUIRED") { if (errorData?.error === "SESSION_REFRESH_REQUIRED") {
if (!isMutationRefreshingSession) { logger.warn("Mutation detected SESSION_REFRESH_REQUIRED");
isMutationRefreshingSession = true;
if (!sessionRefreshRedirect) {
const currentUrl = window.location.pathname + window.location.search; const currentUrl = window.location.pathname + window.location.search;
setTimeout(() => { logger.info("Mutation initiating refresh redirect to:", currentUrl);
isMutationRefreshingSession = false;
}, 1000); sessionRefreshRedirect = new Promise<void>((resolve) => {
window.location.href = `/refresh-session?redirect=${encodeURIComponent(currentUrl)}`; setTimeout(() => {
window.location.href = `/refresh-session?redirect=${encodeURIComponent(currentUrl)}`;
resolve();
}, 100);
});
sessionRefreshRedirect.finally(() => {
setTimeout(() => {
sessionRefreshRedirect = null;
}, 1000);
});
} else {
logger.info("Mutation: refresh redirect already in progress, waiting...");
await sessionRefreshRedirect;
} }
throw new Error("SESSION_REFRESH_REQUIRED"); throw new Error("SESSION_REFRESH_REQUIRED");
} }
} catch (parseError) {} } catch (parseError) {}
} }
throw error; throw error;
} }
}, },