Compare commits

2 Commits

Author SHA1 Message Date
07388e30da refresh progress 2025-08-31 10:23:18 -05:00
b7d14be590 add exclude option to navlink active indicator 2025-08-31 10:02:17 -05:00
9 changed files with 192 additions and 62 deletions

View File

@@ -11,6 +11,7 @@
import { createServerRootRoute } from '@tanstack/react-start/server' import { createServerRootRoute } from '@tanstack/react-start/server'
import { Route as rootRouteImport } from './routes/__root' 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 LogoutRouteImport } from './routes/logout'
import { Route as LoginRouteImport } from './routes/login' import { Route as LoginRouteImport } from './routes/login'
import { Route as AuthedRouteImport } from './routes/_authed' import { Route as AuthedRouteImport } from './routes/_authed'
@@ -33,6 +34,11 @@ import { ServerRoute as ApiFilesCollectionRecordIdFileServerRouteImport } from '
const rootServerRouteImport = createServerRootRoute() const rootServerRouteImport = createServerRootRoute()
const RefreshSessionRoute = RefreshSessionRouteImport.update({
id: '/refresh-session',
path: '/refresh-session',
getParentRoute: () => rootRouteImport,
} as any)
const LogoutRoute = LogoutRouteImport.update({ const LogoutRoute = LogoutRouteImport.update({
id: '/logout', id: '/logout',
path: '/logout', path: '/logout',
@@ -137,6 +143,7 @@ const ApiFilesCollectionRecordIdFileServerRoute =
export interface FileRoutesByFullPath { export interface FileRoutesByFullPath {
'/login': typeof LoginRoute '/login': typeof LoginRoute
'/logout': typeof LogoutRoute '/logout': typeof LogoutRoute
'/refresh-session': typeof RefreshSessionRoute
'/admin': typeof AuthedAdminRouteWithChildren '/admin': typeof AuthedAdminRouteWithChildren
'/settings': typeof AuthedSettingsRoute '/settings': typeof AuthedSettingsRoute
'/': typeof AuthedIndexRoute '/': typeof AuthedIndexRoute
@@ -153,6 +160,7 @@ export interface FileRoutesByFullPath {
export interface FileRoutesByTo { export interface FileRoutesByTo {
'/login': typeof LoginRoute '/login': typeof LoginRoute
'/logout': typeof LogoutRoute '/logout': typeof LogoutRoute
'/refresh-session': typeof RefreshSessionRoute
'/settings': typeof AuthedSettingsRoute '/settings': typeof AuthedSettingsRoute
'/': typeof AuthedIndexRoute '/': typeof AuthedIndexRoute
'/admin/preview': typeof AuthedAdminPreviewRoute '/admin/preview': typeof AuthedAdminPreviewRoute
@@ -170,6 +178,7 @@ export interface FileRoutesById {
'/_authed': typeof AuthedRouteWithChildren '/_authed': typeof AuthedRouteWithChildren
'/login': typeof LoginRoute '/login': typeof LoginRoute
'/logout': typeof LogoutRoute '/logout': typeof LogoutRoute
'/refresh-session': typeof RefreshSessionRoute
'/_authed/admin': typeof AuthedAdminRouteWithChildren '/_authed/admin': typeof AuthedAdminRouteWithChildren
'/_authed/settings': typeof AuthedSettingsRoute '/_authed/settings': typeof AuthedSettingsRoute
'/_authed/': typeof AuthedIndexRoute '/_authed/': typeof AuthedIndexRoute
@@ -188,6 +197,7 @@ export interface FileRouteTypes {
fullPaths: fullPaths:
| '/login' | '/login'
| '/logout' | '/logout'
| '/refresh-session'
| '/admin' | '/admin'
| '/settings' | '/settings'
| '/' | '/'
@@ -204,6 +214,7 @@ export interface FileRouteTypes {
to: to:
| '/login' | '/login'
| '/logout' | '/logout'
| '/refresh-session'
| '/settings' | '/settings'
| '/' | '/'
| '/admin/preview' | '/admin/preview'
@@ -220,6 +231,7 @@ export interface FileRouteTypes {
| '/_authed' | '/_authed'
| '/login' | '/login'
| '/logout' | '/logout'
| '/refresh-session'
| '/_authed/admin' | '/_authed/admin'
| '/_authed/settings' | '/_authed/settings'
| '/_authed/' | '/_authed/'
@@ -238,6 +250,7 @@ export interface RootRouteChildren {
AuthedRoute: typeof AuthedRouteWithChildren AuthedRoute: typeof AuthedRouteWithChildren
LoginRoute: typeof LoginRoute LoginRoute: typeof LoginRoute
LogoutRoute: typeof LogoutRoute LogoutRoute: typeof LogoutRoute
RefreshSessionRoute: typeof RefreshSessionRoute
} }
export interface FileServerRoutesByFullPath { export interface FileServerRoutesByFullPath {
'/api/auth/$': typeof ApiAuthSplatServerRoute '/api/auth/$': typeof ApiAuthSplatServerRoute
@@ -288,6 +301,13 @@ export interface RootServerRouteChildren {
declare module '@tanstack/react-router' { declare module '@tanstack/react-router' {
interface FileRoutesByPath { interface FileRoutesByPath {
'/refresh-session': {
id: '/refresh-session'
path: '/refresh-session'
fullPath: '/refresh-session'
preLoaderRoute: typeof RefreshSessionRouteImport
parentRoute: typeof rootRouteImport
}
'/logout': { '/logout': {
id: '/logout' id: '/logout'
path: '/logout' path: '/logout'
@@ -475,6 +495,7 @@ const rootRouteChildren: RootRouteChildren = {
AuthedRoute: AuthedRouteWithChildren, AuthedRoute: AuthedRouteWithChildren,
LoginRoute: LoginRoute, LoginRoute: LoginRoute,
LogoutRoute: LogoutRoute, LogoutRoute: LogoutRoute,
RefreshSessionRoute: RefreshSessionRoute,
} }
export const routeTree = rootRouteImport export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren) ._addFileChildren(rootRouteChildren)

View File

@@ -19,6 +19,7 @@ import { HeaderConfig } from "@/features/core/types/header-config";
import { playerQueries } from "@/features/players/queries"; import { playerQueries } from "@/features/players/queries";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure"; import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure";
import FullScreenLoader from "@/components/full-screen-loader";
export const Route = createRootRouteWithContext<{ export const Route = createRootRouteWithContext<{
queryClient: QueryClient; queryClient: QueryClient;
@@ -70,7 +71,12 @@ export const Route = createRootRouteWithContext<{
}, },
component: RootComponent, component: RootComponent,
notFoundComponent: () => <Navigate to="/" />, notFoundComponent: () => <Navigate to="/" />,
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 // https://github.com/TanStack/router/discussions/3531
const auth = await ensureServerQueryData( const auth = await ensureServerQueryData(
context.queryClient, context.queryClient,
@@ -78,7 +84,7 @@ export const Route = createRootRouteWithContext<{
); );
return { auth }; return { auth };
}, },
pendingComponent: () => <p>Loading...</p>, pendingComponent: () => <Providers><FullScreenLoader /></Providers>,
}); });
function RootComponent() { function RootComponent() {

View File

@@ -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 <FullScreenLoader />
}

View File

@@ -0,0 +1,13 @@
import { Center, Container, Loader, Stack } from "@mantine/core";
const FullScreenLoader = () => (
<Container h="100dvh" w="100dvw">
<Center h="100%">
<Stack align="center" gap="md">
<Loader size="xl" />
</Stack>
</Center>
</Container>
);
export default FullScreenLoader;

View File

@@ -1,6 +1,6 @@
import { Flex, Box, Text } from "@mantine/core"; import { Flex, Box, Text } from "@mantine/core";
import { Link, useRouterState } from "@tanstack/react-router"; import { Link, useRouterState } from "@tanstack/react-router";
import styles from './styles.module.css'; import styles from "./styles.module.css";
import { Icon } from "@phosphor-icons/react"; import { Icon } from "@phosphor-icons/react";
import { useMemo } from "react"; import { useMemo } from "react";
@@ -9,21 +9,55 @@ interface NavLinkProps {
label: string; label: string;
Icon: Icon; Icon: Icon;
include?: string[]; include?: string[];
exclude?: string[];
} }
export const NavLink = ({ href, label, Icon, include }: NavLinkProps) => { export const NavLink = ({
href,
label,
Icon,
include,
exclude,
}: NavLinkProps) => {
const router = useRouterState(); const router = useRouterState();
const isActive = useMemo(() => (router.location.pathname === href || (router.location.pathname.includes(href) && href !== '/')) || include?.includes(router.location.pathname), [router.location.pathname, href]); const isActive = useMemo(
() =>
(!exclude?.some((e) => router.location.pathname.includes(e)) &&
(router.location.pathname === href ||
(router.location.pathname.includes(href) && href !== "/"))) ||
include?.includes(router.location.pathname),
[router.location.pathname, href]
);
return ( return (
<Box component={Link} to={href} <Box
component={Link}
to={href}
className={styles.navLinkBox} className={styles.navLinkBox}
p={{ base: 0, sm: 8 }} p={{ base: 0, sm: 8 }}
> >
<Flex direction={{ base: 'column', md: 'row' }} align='center' gap={{ base: 0, md: 'xs' }}> <Flex
<Icon weight={isActive ? 'fill' : 'regular'} size={28} style={{ color: isActive ? 'var(--mantine-primary-color-filled)' : undefined }} /> direction={{ base: "column", md: "row" }}
<Text visibleFrom='md' ta='center' size='md' fw={isActive ? 800 : 500} c={isActive ? 'var(--mantine-primary-color-filled)' : undefined}>{label}</Text> align="center"
gap={{ base: 0, md: "xs" }}
>
<Icon
weight={isActive ? "fill" : "regular"}
size={28}
style={{
color: isActive ? "var(--mantine-primary-color-filled)" : undefined,
}}
/>
<Text
visibleFrom="md"
ta="center"
size="md"
fw={isActive ? 800 : 500}
c={isActive ? "var(--mantine-primary-color-filled)" : undefined}
>
{label}
</Text>
</Flex> </Flex>
</Box> </Box>
) );
} };

View File

@@ -17,7 +17,8 @@ export const useLinks = (userId: string | undefined, roles: string[]) =>
{ {
label: 'Tournaments', label: 'Tournaments',
href: '/tournaments', href: '/tournaments',
Icon: TrophyIcon Icon: TrophyIcon,
exclude: ['/admin/tournaments']
}, },
{ {
label: 'Profile', label: 'Profile',

View File

@@ -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 { createServerFn } from "@tanstack/react-start";
import { Player, playerInputSchema, playerUpdateSchema } from "@/features/players/types"; import { Player, playerInputSchema, playerUpdateSchema } from "@/features/players/types";
import { pbAdmin } from "@/lib/pocketbase/client"; 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"; import { toServerResult } from "@/lib/tanstack-query/utils/to-server-result";
export const fetchMe = createServerFn() export const fetchMe = createServerFn()
.handler(async ({ response }) => .handler(async () =>
toServerResult(async () => { toServerResult(async () => {
const request = getWebRequest(); const request = getWebRequest();
const { context } = await verifySuperTokensSession(request, response);
try {
if (!context || !context.userAuthId) return { user: undefined, roles: [], metadata: {} }; const context = await getSessionContext(request);
await pbAdmin.authPromise; await pbAdmin.authPromise;
const result = await pbAdmin.getPlayerByAuthId(context.userAuthId); const result = await pbAdmin.getPlayerByAuthId(context.userAuthId);
return { return {
user: result || undefined, user: result || undefined,
roles: context.roles, roles: context.roles,
metadata: context.metadata 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: {} };
}
}) })
); );

View File

@@ -6,11 +6,7 @@ export async function getSessionForStart(request: Request, options?: { sessionRe
ensureSuperTokensBackend(); ensureSuperTokensBackend();
try { try {
console.log("=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=");
console.log("Getting session for request:", request);
const session = await getSessionForSSR(request); const session = await getSessionForSSR(request);
console.log("Session from getSessionForSSR:", session);
console.log("=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=");
if (session.hasToken) { if (session.hasToken) {
if (session.accessTokenPayload?.sub === undefined || session.accessTokenPayload?.sessionHandle === undefined) { if (session.accessTokenPayload?.sub === undefined || session.accessTokenPayload?.sessionHandle === undefined) {

View File

@@ -4,7 +4,7 @@ import {
ServerFnResponseType, ServerFnResponseType,
} from "@tanstack/react-start"; } from "@tanstack/react-start";
import { getWebRequest } from "@tanstack/react-start/server"; 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 UserRoles from "supertokens-node/recipe/userroles";
import UserMetadata from "supertokens-node/recipe/usermetadata"; import UserMetadata from "supertokens-node/recipe/usermetadata";
import { getSessionForStart } from "@/lib/supertokens/recipes/start-session"; import { getSessionForStart } from "@/lib/supertokens/recipes/start-session";
@@ -21,9 +21,32 @@ export const verifySuperTokensSession = async (
let session = await getSessionForStart(request, { sessionRequired: false }); let session = await getSessionForStart(request, { sessionRequired: false });
if (session?.needsRefresh) { if (session?.needsRefresh) {
logger.info("Session refreshing..."); logger.info("Session needs refresh");
session = await getSessionForStart(request, { sessionRequired: false });
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; const userAuthId = session?.userId;
if (!userAuthId || !session) { if (!userAuthId || !session) {
@@ -47,13 +70,20 @@ export const verifySuperTokensSession = async (
}; };
}; };
export const superTokensRequestMiddleware = createMiddleware({ export const getSessionContext = async (request: Request): Promise<any> => {
type: "request",
}).server(async ({ next, request }) => {
const session = await verifySuperTokensSession(request); 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) { if (!session.context.userAuthId) {
logger.error("Unauthenticated user in API call.", session.context); logger.error("Unauthenticated user", session.context);
throw new Error("Unauthenticated"); throw new Error("Unauthenticated");
} }
@@ -63,6 +93,13 @@ export const superTokensRequestMiddleware = createMiddleware({
metadata: session.context.metadata, metadata: session.context.metadata,
}; };
return context;
};
export const superTokensRequestMiddleware = createMiddleware({
type: "request",
}).server(async ({ next, request }) => {
const context = await getSessionContext(request);
return next({ context }); return next({ context });
}); });
@@ -70,37 +107,15 @@ export const superTokensFunctionMiddleware = createMiddleware({
type: "function", type: "function",
}).server(async ({ next, response }) => { }).server(async ({ next, response }) => {
const request = getWebRequest(); const request = getWebRequest();
const session = await verifySuperTokensSession(request, response); const context = await getSessionContext(request);
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,
};
return next({ context }); return next({ context });
}); });
export const superTokensAdminFunctionMiddleware = createMiddleware({ export const superTokensAdminFunctionMiddleware = createMiddleware({
type: "function", type: "function",
}).server(async ({ next, response }) => { }).server(async ({ next }) => {
const request = getWebRequest(); const request = getWebRequest();
const session = await verifySuperTokensSession(request, response); const context = await getSessionContext(request);
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,
};
if (context.roles?.includes("Admin")) { if (context.roles?.includes("Admin")) {
return next({ context }); return next({ context });