diff --git a/src/app/routeTree.gen.ts b/src/app/routeTree.gen.ts index 043e7ca..e10dfeb 100644 --- a/src/app/routeTree.gen.ts +++ b/src/app/routeTree.gen.ts @@ -11,6 +11,7 @@ import { createServerRootRoute } from '@tanstack/react-start/server' import { Route as rootRouteImport } from './routes/__root' +import { Route as RefreshSessionRouteImport } from './routes/refresh-session' import { Route as LogoutRouteImport } from './routes/logout' import { Route as LoginRouteImport } from './routes/login' import { Route as AuthedRouteImport } from './routes/_authed' @@ -33,6 +34,11 @@ import { ServerRoute as ApiFilesCollectionRecordIdFileServerRouteImport } from ' const rootServerRouteImport = createServerRootRoute() +const RefreshSessionRoute = RefreshSessionRouteImport.update({ + id: '/refresh-session', + path: '/refresh-session', + getParentRoute: () => rootRouteImport, +} as any) const LogoutRoute = LogoutRouteImport.update({ id: '/logout', path: '/logout', @@ -137,6 +143,7 @@ const ApiFilesCollectionRecordIdFileServerRoute = export interface FileRoutesByFullPath { '/login': typeof LoginRoute '/logout': typeof LogoutRoute + '/refresh-session': typeof RefreshSessionRoute '/admin': typeof AuthedAdminRouteWithChildren '/settings': typeof AuthedSettingsRoute '/': typeof AuthedIndexRoute @@ -153,6 +160,7 @@ export interface FileRoutesByFullPath { export interface FileRoutesByTo { '/login': typeof LoginRoute '/logout': typeof LogoutRoute + '/refresh-session': typeof RefreshSessionRoute '/settings': typeof AuthedSettingsRoute '/': typeof AuthedIndexRoute '/admin/preview': typeof AuthedAdminPreviewRoute @@ -170,6 +178,7 @@ export interface FileRoutesById { '/_authed': typeof AuthedRouteWithChildren '/login': typeof LoginRoute '/logout': typeof LogoutRoute + '/refresh-session': typeof RefreshSessionRoute '/_authed/admin': typeof AuthedAdminRouteWithChildren '/_authed/settings': typeof AuthedSettingsRoute '/_authed/': typeof AuthedIndexRoute @@ -188,6 +197,7 @@ export interface FileRouteTypes { fullPaths: | '/login' | '/logout' + | '/refresh-session' | '/admin' | '/settings' | '/' @@ -204,6 +214,7 @@ export interface FileRouteTypes { to: | '/login' | '/logout' + | '/refresh-session' | '/settings' | '/' | '/admin/preview' @@ -220,6 +231,7 @@ export interface FileRouteTypes { | '/_authed' | '/login' | '/logout' + | '/refresh-session' | '/_authed/admin' | '/_authed/settings' | '/_authed/' @@ -238,6 +250,7 @@ export interface RootRouteChildren { AuthedRoute: typeof AuthedRouteWithChildren LoginRoute: typeof LoginRoute LogoutRoute: typeof LogoutRoute + RefreshSessionRoute: typeof RefreshSessionRoute } export interface FileServerRoutesByFullPath { '/api/auth/$': typeof ApiAuthSplatServerRoute @@ -288,6 +301,13 @@ export interface RootServerRouteChildren { declare module '@tanstack/react-router' { interface FileRoutesByPath { + '/refresh-session': { + id: '/refresh-session' + path: '/refresh-session' + fullPath: '/refresh-session' + preLoaderRoute: typeof RefreshSessionRouteImport + parentRoute: typeof rootRouteImport + } '/logout': { id: '/logout' path: '/logout' @@ -475,6 +495,7 @@ const rootRouteChildren: RootRouteChildren = { AuthedRoute: AuthedRouteWithChildren, LoginRoute: LoginRoute, LogoutRoute: LogoutRoute, + RefreshSessionRoute: RefreshSessionRoute, } export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) diff --git a/src/app/routes/__root.tsx b/src/app/routes/__root.tsx index 0207b7d..feda413 100644 --- a/src/app/routes/__root.tsx +++ b/src/app/routes/__root.tsx @@ -19,6 +19,7 @@ import { HeaderConfig } from "@/features/core/types/header-config"; import { playerQueries } from "@/features/players/queries"; import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure"; +import FullScreenLoader from "@/components/full-screen-loader"; export const Route = createRootRouteWithContext<{ queryClient: QueryClient; @@ -70,7 +71,12 @@ export const Route = createRootRouteWithContext<{ }, component: RootComponent, notFoundComponent: () => , - beforeLoad: async ({ context }) => { + beforeLoad: async ({ context, location }) => { + // Skip auth check for refresh-session route to avoid infinite loops + if (location.pathname === '/refresh-session') { + return {}; + } + // https://github.com/TanStack/router/discussions/3531 const auth = await ensureServerQueryData( context.queryClient, @@ -78,7 +84,7 @@ export const Route = createRootRouteWithContext<{ ); return { auth }; }, - pendingComponent: () =>

Loading...

, + pendingComponent: () => , }); function RootComponent() { diff --git a/src/app/routes/refresh-session.tsx b/src/app/routes/refresh-session.tsx new file mode 100644 index 0000000..615318c --- /dev/null +++ b/src/app/routes/refresh-session.tsx @@ -0,0 +1,39 @@ +import { createFileRoute } from '@tanstack/react-router' +import { useEffect } from 'react' +import FullScreenLoader from '@/components/full-screen-loader' +import { attemptRefreshingSession } from 'supertokens-web-js/recipe/session' + +export const Route = createFileRoute('/refresh-session')({ + component: RouteComponent, +}) + +// https://supertokens.com/docs/additional-verification/session-verification/ssr?uiType=custom +function RouteComponent() { + useEffect(() => { + const handleRefresh = async () => { + try { + const refreshed = await attemptRefreshingSession() + + if (refreshed) { + const urlParams = new URLSearchParams(window.location.search) + const redirect = urlParams.get('redirect') + + if (redirect) { + window.location.href = decodeURIComponent(redirect) + } else { + window.location.href = '/' + } + } else { + window.location.href = '/login' + } + } catch (error) { + window.location.href = '/login' + } + } + + const timeout = setTimeout(handleRefresh, 100) + return () => clearTimeout(timeout) + }, []) + + return +} \ No newline at end of file diff --git a/src/components/full-screen-loader.tsx b/src/components/full-screen-loader.tsx new file mode 100644 index 0000000..af88ba5 --- /dev/null +++ b/src/components/full-screen-loader.tsx @@ -0,0 +1,13 @@ +import { Center, Container, Loader, Stack } from "@mantine/core"; + +const FullScreenLoader = () => ( + +
+ + + +
+
+); + +export default FullScreenLoader; diff --git a/src/features/players/server.ts b/src/features/players/server.ts index 92543d3..ec2f2ee 100644 --- a/src/features/players/server.ts +++ b/src/features/players/server.ts @@ -1,4 +1,4 @@ -import { setUserMetadata, superTokensFunctionMiddleware, verifySuperTokensSession } from "@/utils/supertokens"; +import { setUserMetadata, superTokensFunctionMiddleware, getSessionContext } from "@/utils/supertokens"; import { createServerFn } from "@tanstack/react-start"; import { Player, playerInputSchema, playerUpdateSchema } from "@/features/players/types"; import { pbAdmin } from "@/lib/pocketbase/client"; @@ -8,20 +8,25 @@ import { getWebRequest } from "@tanstack/react-start/server"; import { toServerResult } from "@/lib/tanstack-query/utils/to-server-result"; export const fetchMe = createServerFn() - .handler(async ({ response }) => + .handler(async () => toServerResult(async () => { const request = getWebRequest(); - const { context } = await verifySuperTokensSession(request, response); - - if (!context || !context.userAuthId) return { user: undefined, roles: [], metadata: {} }; - - await pbAdmin.authPromise; - const result = await pbAdmin.getPlayerByAuthId(context.userAuthId); - return { - user: result || undefined, - roles: context.roles, - metadata: context.metadata - }; + + try { + const context = await getSessionContext(request); + + await pbAdmin.authPromise; + const result = await pbAdmin.getPlayerByAuthId(context.userAuthId); + return { + user: result || undefined, + roles: context.roles, + metadata: context.metadata + }; + } catch (error: any) { + // If getSessionContext throws (unauthenticated or redirect), return empty state + logger.info('fetchMe: No authenticated user or redirect needed'); + return { user: undefined, roles: [], metadata: {} }; + } }) ); diff --git a/src/lib/supertokens/recipes/start-session.ts b/src/lib/supertokens/recipes/start-session.ts index cb3f7fc..9ba9500 100644 --- a/src/lib/supertokens/recipes/start-session.ts +++ b/src/lib/supertokens/recipes/start-session.ts @@ -6,11 +6,7 @@ export async function getSessionForStart(request: Request, options?: { sessionRe ensureSuperTokensBackend(); try { - console.log("=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-="); - console.log("Getting session for request:", request); const session = await getSessionForSSR(request); - console.log("Session from getSessionForSSR:", session); - console.log("=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-="); if (session.hasToken) { if (session.accessTokenPayload?.sub === undefined || session.accessTokenPayload?.sessionHandle === undefined) { diff --git a/src/utils/supertokens.ts b/src/utils/supertokens.ts index f3589f8..13d2509 100644 --- a/src/utils/supertokens.ts +++ b/src/utils/supertokens.ts @@ -4,7 +4,7 @@ import { ServerFnResponseType, } from "@tanstack/react-start"; import { getWebRequest } from "@tanstack/react-start/server"; -import { getSessionForSSR } from "supertokens-node/custom"; +import { redirect as redirect } from "@tanstack/react-router"; import UserRoles from "supertokens-node/recipe/userroles"; import UserMetadata from "supertokens-node/recipe/usermetadata"; import { getSessionForStart } from "@/lib/supertokens/recipes/start-session"; @@ -21,9 +21,32 @@ export const verifySuperTokensSession = async ( let session = await getSessionForStart(request, { sessionRequired: false }); if (session?.needsRefresh) { - logger.info("Session refreshing..."); - session = await getSessionForStart(request, { sessionRequired: false }); + logger.info("Session needs refresh"); + + try { + // attempt refresh on backend + if (response) { + const refreshedSession = await refreshSession(request, response); + if (refreshedSession) { + session = await getSessionForStart(request, { sessionRequired: false }); + } + } + + if (session?.needsRefresh) { + // tryRefresh on frontend + return { context: { session: { tryRefresh: true } } }; + } + } catch (error: any) { + logger.error("Session refresh error", error); + + if (error.type === 'UNAUTHORISED' || error.type === 'TOKEN_THEFT_DETECTED') { + return { context: { userAuthId: null, roles: [] } }; + } + + return { context: { session: { tryRefresh: true } } }; + } } + const userAuthId = session?.userId; if (!userAuthId || !session) { @@ -47,13 +70,20 @@ export const verifySuperTokensSession = async ( }; }; -export const superTokensRequestMiddleware = createMiddleware({ - type: "request", -}).server(async ({ next, request }) => { +export const getSessionContext = async (request: Request): Promise => { const session = await verifySuperTokensSession(request); + if (session.context.session?.tryRefresh) { + const url = new URL(request.url); + const from = encodeURIComponent(url.pathname + url.search); + throw redirect({ + to: "/refresh-session", + search: { redirect: from } + }); + } + if (!session.context.userAuthId) { - logger.error("Unauthenticated user in API call.", session.context); + logger.error("Unauthenticated user", session.context); throw new Error("Unauthenticated"); } @@ -63,6 +93,13 @@ export const superTokensRequestMiddleware = createMiddleware({ metadata: session.context.metadata, }; + return context; +}; + +export const superTokensRequestMiddleware = createMiddleware({ + type: "request", +}).server(async ({ next, request }) => { + const context = await getSessionContext(request); return next({ context }); }); @@ -70,37 +107,15 @@ export const superTokensFunctionMiddleware = createMiddleware({ type: "function", }).server(async ({ next, response }) => { const request = getWebRequest(); - const session = await verifySuperTokensSession(request, response); - - if (!session.context.userAuthId) { - logger.error("Unauthenticated user in server function.", session.context); - throw new Error("Unauthenticated"); - } - - const context = { - userAuthId: session.context.userAuthId, - roles: session.context.roles, - metadata: session.context.metadata, - }; + const context = await getSessionContext(request); return next({ context }); }); export const superTokensAdminFunctionMiddleware = createMiddleware({ type: "function", -}).server(async ({ next, response }) => { +}).server(async ({ next }) => { const request = getWebRequest(); - const session = await verifySuperTokensSession(request, response); - - if (!session.context.userAuthId) { - logger.error("Unauthenticated user in admin function.", session.context); - throw new Error("Unauthenticated"); - } - - const context = { - userAuthId: session.context.userAuthId, - roles: session.context.roles, - metadata: session.context.metadata, - }; + const context = await getSessionContext(request); if (context.roles?.includes("Admin")) { return next({ context });