session fixes
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
@@ -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';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
60
src/components/session-monitor.tsx
Normal file
60
src/components/session-monitor.tsx
Normal 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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user