From 5510bcf5957e92f8a448716be09c3ac6154b7f0e Mon Sep 17 00:00:00 2001 From: Nat Budin Date: Sun, 28 Jun 2026 11:00:40 -0700 Subject: [PATCH 1/6] Fix 404 on /admin_events/:eventId/edit for single-event sites MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Single-event conventions redirect /admin_events to /:eventId/edit, but that path had no matching route — only the multi-event paths existed under the siteMode !== SingleEvent guard. Co-Authored-By: Claude Sonnet 4.6 --- app/javascript/AppRouter.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/javascript/AppRouter.tsx b/app/javascript/AppRouter.tsx index d1185fb39f..8d5efe17a8 100644 --- a/app/javascript/AppRouter.tsx +++ b/app/javascript/AppRouter.tsx @@ -529,6 +529,10 @@ const commonInConventionRoutes: RouteObject[] = [ loader: eventAdminEventsLoader, id: NamedRoute.EventAdmin, children: [ + { + element: siteMode === SiteMode.SingleEvent} />, + children: [{ path: ':eventId/edit', lazy: () => import('./EventAdmin/EventAdminEditEvent') }], + }, { element: siteMode !== SiteMode.SingleEvent} />, children: [ From 19b457ccbbdec8dce2e73455ea264edc08ff3a8d Mon Sep 17 00:00:00 2001 From: Nat Budin Date: Sun, 28 Jun 2026 11:01:04 -0700 Subject: [PATCH 2/6] Refactor AppRoot to lazy module with v8_middleware and appRootMiddleware - Extract RouteErrorBoundary to its own module for reuse - Convert AppRoot from a static element to a lazy-loaded route module (exports Component, loader, ErrorBoundary instead of default export) - Enable v8_middleware in createBrowserRouter; add appRootDataContext to the per-navigation router context so middleware can share data with loaders - Add appRootMiddleware on the AppRoot route: runs the AppRootQuery once per navigation and stores the result in appRootDataContext, so the query is never duplicated across middleware and loaders - AppRoot loader now reads from appRootDataContext rather than issuing its own query; retains the profile-setup mutation and redirect logic - Replace EditEventGuard (render-time side-effect guard) with editEventMiddleware: reads siteMode from appRootDataContext and redirects single-event sites to /admin_events before any rendering occurs; restore LoginRequiredRouteGuard as the route element - Fix EventPageGuard: move navigate() call into useEffect to avoid calling a side effect during render Co-Authored-By: Claude Sonnet 4.6 --- app/javascript/AppContexts.ts | 2 + app/javascript/AppRoot.tsx | 80 ++++++++++++++++- app/javascript/AppRouter.tsx | 122 ++++++-------------------- app/javascript/RouteErrorBoundary.tsx | 14 +++ app/javascript/packs/application.tsx | 5 ++ 5 files changed, 125 insertions(+), 98 deletions(-) create mode 100644 app/javascript/RouteErrorBoundary.tsx diff --git a/app/javascript/AppContexts.ts b/app/javascript/AppContexts.ts index 18a69669a5..dc2bd319bf 100644 --- a/app/javascript/AppContexts.ts +++ b/app/javascript/AppContexts.ts @@ -2,6 +2,7 @@ import { ApolloClient } from '@apollo/client'; import { Session, createContext } from 'react-router'; import { SessionData, SessionFlashData } from 'sessions'; import AuthenticityTokensManager from 'AuthenticityTokensContext'; +import { AppRootQueryData } from 'appRootQueries.generated'; // Bootstrap configuration served from `GET /client_configuration`. Fetched // once at module load before any GraphQL traffic, so the SPA has enough to @@ -25,3 +26,4 @@ export const apolloClientContext = createContext(); export const clientConfigurationContext = createContext(); export const fetchContext = createContext(); export const sessionContext = createContext | undefined>(); +export const appRootDataContext = createContext(); diff --git a/app/javascript/AppRoot.tsx b/app/javascript/AppRoot.tsx index c2a2e3028a..16fdd2d352 100644 --- a/app/javascript/AppRoot.tsx +++ b/app/javascript/AppRoot.tsx @@ -1,5 +1,13 @@ import { Suspense, useMemo, useState, useEffect, useRef } from 'react'; -import { useLocation, useLoaderData, Outlet, useNavigation } from 'react-router'; +import { + useLocation, + useLoaderData, + Outlet, + useNavigation, + LoaderFunction, + redirect, + RouterContextProvider, +} from 'react-router'; import { Settings } from 'luxon'; import { PageLoadingIndicator } from '@neinteractiveliterature/litform'; @@ -12,6 +20,72 @@ import { LazyStripeContext } from './LazyStripe'; import { Stripe } from '@stripe/stripe-js'; import { reloadOnAppEntrypointHeadersMismatch } from './checkAppEntrypointHeadersMatch'; import errorReporting from 'ErrorReporting'; +import { apolloClientContext, appRootDataContext } from 'AppContexts'; +import { SetupMyProfileDocument } from 'Authentication/mutations.generated'; +import RouteErrorBoundary from 'RouteErrorBoundary'; + +export const ErrorBoundary = RouteErrorBoundary; + +export const loader: LoaderFunction = async ({ context, request }) => { + const client = context.get(apolloClientContext); + const data = context.get(appRootDataContext); + if (!data) return data; + + const { pathname } = new URL(request.url); + + if (data.currentUser && data.convention) { + const myProfile = data.convention.my_profile; + let freshlyCreated = false; + + if (!myProfile) { + try { + const convention = data.convention; + await client.mutate({ + mutation: SetupMyProfileDocument, + update(cache, result) { + const newProfile = result.data?.setupMyProfile?.my_profile; + if (!newProfile) return; + // After creating the profile, wire up the Convention.my_profile reference + // in the normalized cache so cache-first queries (e.g. MyProfileForm.loader) + // see the new profile instead of the stale null entry. Without this, + // MyProfileQuery returns null from cache even though the profile now exists, + // causing MyProfileForm.loader to return a 404 and show a broken page. + const conventionRef = cache.identify({ __typename: 'Convention', id: convention.id }); + const profileRef = cache.identify({ __typename: 'UserConProfile', id: newProfile.id }); + if (conventionRef && profileRef) { + cache.modify({ + id: conventionRef, + fields: { my_profile: () => ({ __ref: profileRef }) }, + }); + } + }, + }); + freshlyCreated = true; + } catch { + return data; + } + } + + const clickwrapRequired = (data.convention.clickwrap_agreement || '').trim() !== ''; + const acceptedClickwrap = freshlyCreated ? false : (myProfile?.accepted_clickwrap_agreement ?? false); + const needsUpdate = freshlyCreated || (myProfile?.needs_update ?? false); + + const clickwrapNeeded = + clickwrapRequired && + !acceptedClickwrap && + pathname !== '/clickwrap_agreement' && + pathname !== '/' && + !pathname.startsWith('/pages'); + + if (clickwrapNeeded) return redirect('/clickwrap_agreement'); + + if (needsUpdate && pathname !== '/my_profile/setup' && pathname !== '/clickwrap_agreement') { + return redirect('/my_profile/setup'); + } + } + + return data; +}; export function buildAppRootContextValue( data: AppRootQueryData | null | undefined, @@ -125,7 +199,7 @@ function AppRoot(): React.JSX.Element { {/* Disabling ScrollRestoration for now because it's breaking hash links within the same page (reproducible with Mac Chrome) */} {/* */} - }> + }> @@ -134,4 +208,4 @@ function AppRoot(): React.JSX.Element { ); } -export default AppRoot; +export const Component = AppRoot; diff --git a/app/javascript/AppRouter.tsx b/app/javascript/AppRouter.tsx index 8d5efe17a8..4fba905834 100644 --- a/app/javascript/AppRouter.tsx +++ b/app/javascript/AppRouter.tsx @@ -1,4 +1,4 @@ -import { useContext } from 'react'; +import { useContext, useEffect } from 'react'; import { RouteObject, replace, @@ -6,16 +6,16 @@ import { LoaderFunction, redirect, useNavigate, - useRouteError, RouterContextProvider, + MiddlewareFunction, } from 'react-router'; -import { ErrorDisplay } from '@neinteractiveliterature/litform'; import FourOhFourPage from './FourOhFourPage'; import { SignupAutomationMode, SignupMode, SiteMode, TicketMode } from './graphqlTypes.generated'; import AppRootContext, { AppRootContextValue } from './AppRootContext'; import useAuthorizationRequired, { AbilityName } from './Authentication/useAuthorizationRequired'; import { EventAdminEventsQueryDocument } from './EventAdmin/queries.generated'; +import { AppRootQueryDocument } from './appRootQueries.generated'; import buildEventCategoryUrl from './EventAdmin/buildEventCategoryUrl'; import { adminSingleTicketTypeLoader, @@ -32,9 +32,6 @@ import { teamMembersLoader } from './EventsApp/TeamMemberAdmin/loader'; import { cmsAdminBaseQueryLoader } from './CmsAdmin/loaders'; import { cmsPagesAdminLoader } from './CmsAdmin/CmsPagesAdmin/loaders'; import { cmsPartialsAdminLoader } from './CmsAdmin/CmsPartialsAdmin/loaders'; -import AppRoot from './AppRoot'; -import { AppRootQueryDocument } from './appRootQueries.generated'; -import { SetupMyProfileDocument } from './Authentication/mutations.generated'; import { liquidDocsLoader } from './LiquidDocs/loader'; import { cmsLayoutsAdminLoader } from './CmsAdmin/CmsLayoutsAdmin/loaders'; import { cmsGraphqlQueriesAdminLoader } from './CmsAdmin/CmsGraphqlQueriesAdmin/loaders'; @@ -42,7 +39,8 @@ import { cmsContentGroupsAdminLoader } from './CmsAdmin/CmsContentGroupsAdmin/lo import { departmentAdminLoader } from './DepartmentAdmin/loaders'; import { eventCategoryAdminLoader } from './EventCategoryAdmin/loaders'; import { eventAdminEventsLoader } from './EventAdmin/loaders'; -import { apolloClientContext } from 'AppContexts'; +import { apolloClientContext, appRootDataContext } from 'AppContexts'; +import RouteErrorBoundary from 'RouteErrorBoundary'; export enum NamedRoute { AdminEditEventProposal = 'AdminEditEventProposal', @@ -163,37 +161,33 @@ function EventPageGuard() { const { siteMode } = useContext(AppRootContext); const navigate = useNavigate(); + useEffect(() => { + if (siteMode === SiteMode.SingleEvent) { + navigate('/', { replace: true }); + } + }, []); + if (siteMode === SiteMode.SingleEvent) { - navigate('/', { replace: true }); return <>; } else { return ; } } -function EditEventGuard() { - const { siteMode } = useContext(AppRootContext); - const navigate = useNavigate(); - - if (siteMode === SiteMode.SingleEvent) { - navigate('/admin_events', { replace: true }); - return <>; - } else { - return ; +const appRootMiddleware: MiddlewareFunction = async ({ context }) => { + const client = context.get(apolloClientContext); + const { data } = await client.query({ query: AppRootQueryDocument }); + if (data) { + context.set(appRootDataContext, data); } -} - -function RouteErrorBoundary() { - const error = useRouteError(); +}; - if (error instanceof Error) { - return ; - } else if (typeof error === 'object' && error) { - return ; - } else { - return ; +const editEventMiddleware: MiddlewareFunction = ({ context }) => { + const appRootData = context.get(appRootDataContext); + if (appRootData?.convention?.site_mode === SiteMode.SingleEvent) { + return redirect('/admin_events'); } -} +}; const eventsRoutes: RouteObject[] = [ { @@ -252,7 +246,8 @@ const eventsRoutes: RouteObject[] = [ }, { path: 'edit', - element: , + middleware: [editEventMiddleware], + element: , children: [ { index: true, @@ -1097,73 +1092,10 @@ export const appLayoutRoutes: RouteObject[] = [ { path: '*', element: }, ]; -const appRootLoader: LoaderFunction = async ({ context, request }) => { - const client = context.get(apolloClientContext); - const { data } = await client.query({ query: AppRootQueryDocument }); - - if (!data) return data; - - const { pathname } = new URL(request.url); - - if (data.currentUser && data.convention) { - const myProfile = data.convention.my_profile; - let freshlyCreated = false; - - if (!myProfile) { - try { - const convention = data.convention; - await client.mutate({ - mutation: SetupMyProfileDocument, - update(cache, result) { - const newProfile = result.data?.setupMyProfile?.my_profile; - if (!newProfile) return; - // After creating the profile, wire up the Convention.my_profile reference - // in the normalized cache so cache-first queries (e.g. MyProfileForm.loader) - // see the new profile instead of the stale null entry. Without this, - // MyProfileQuery returns null from cache even though the profile now exists, - // causing MyProfileForm.loader to return a 404 and show a broken page. - const conventionRef = cache.identify({ __typename: 'Convention', id: convention.id }); - const profileRef = cache.identify({ __typename: 'UserConProfile', id: newProfile.id }); - if (conventionRef && profileRef) { - cache.modify({ - id: conventionRef, - fields: { my_profile: () => ({ __ref: profileRef }) }, - }); - } - }, - }); - freshlyCreated = true; - } catch { - return data; - } - } - - const clickwrapRequired = (data.convention.clickwrap_agreement || '').trim() !== ''; - const acceptedClickwrap = freshlyCreated ? false : (myProfile?.accepted_clickwrap_agreement ?? false); - const needsUpdate = freshlyCreated || (myProfile?.needs_update ?? false); - - const clickwrapNeeded = - clickwrapRequired && - !acceptedClickwrap && - pathname !== '/clickwrap_agreement' && - pathname !== '/' && - !pathname.startsWith('/pages'); - - if (clickwrapNeeded) return redirect('/clickwrap_agreement'); - - if (needsUpdate && pathname !== '/my_profile/setup' && pathname !== '/clickwrap_agreement') { - return redirect('/my_profile/setup'); - } - } - - return data; -}; - export const appRootRoutes: RouteObject[] = [ { - element: , - errorElement: , - loader: appRootLoader, + lazy: () => import('./AppRoot'), + middleware: [appRootMiddleware], children: [ { path: '/admin_forms/:id/edit', @@ -1219,7 +1151,7 @@ export const appRootRoutes: RouteObject[] = [ }, { lazy: () => import('./AppRootLayout'), - children: [{ errorElement: , children: appLayoutRoutes }], + children: [{ ErrorBoundary: RouteErrorBoundary, children: appLayoutRoutes }], }, ], }, diff --git a/app/javascript/RouteErrorBoundary.tsx b/app/javascript/RouteErrorBoundary.tsx new file mode 100644 index 0000000000..98f092e95c --- /dev/null +++ b/app/javascript/RouteErrorBoundary.tsx @@ -0,0 +1,14 @@ +import { ErrorDisplay } from '@neinteractiveliterature/litform'; +import { useRouteError } from 'react-router'; + +export default function RouteErrorBoundary() { + const error = useRouteError(); + + if (error instanceof Error) { + return ; + } else if (typeof error === 'object' && error) { + return ; + } else { + return ; + } +} diff --git a/app/javascript/packs/application.tsx b/app/javascript/packs/application.tsx index 8525ff279b..90e60b0a5e 100644 --- a/app/javascript/packs/application.tsx +++ b/app/javascript/packs/application.tsx @@ -12,6 +12,7 @@ import { fetchContext, sessionContext, ClientConfiguration, + appRootDataContext, } from 'AppContexts'; import { appRootRoutes } from 'AppRouter'; import { AuthenticationManager, AuthenticationManagerContext } from '../Authentication/authenticationManager'; @@ -132,6 +133,9 @@ function DataModeApplicationEntry() { }, ]), { + future: { + v8_middleware: true, + }, getContext: () => { const context = new RouterContextProvider(); context.set(apolloClientContext, bootstrap.client); @@ -139,6 +143,7 @@ function DataModeApplicationEntry() { context.set(authenticityTokensManagerContext, bootstrap.authenticityTokensManager); context.set(clientConfigurationContext, bootstrap.clientConfiguration); context.set(sessionContext, undefined); + context.set(appRootDataContext, undefined); return context; }, }, From 50666d5174e14dc08b984443f3b816ceca7caa9f Mon Sep 17 00:00:00 2001 From: Nat Budin Date: Sun, 28 Jun 2026 11:10:34 -0700 Subject: [PATCH 3/6] Replace EventPageGuard with eventPageMiddleware Converts the render-time EventPageGuard component to a middleware function, consistent with editEventMiddleware. Removes useEffect/useNavigate imports that were only needed for the guard's render-side navigation call. Co-Authored-By: Claude Sonnet 4.6 --- app/javascript/AppRouter.tsx | 25 +++++++------------------ 1 file changed, 7 insertions(+), 18 deletions(-) diff --git a/app/javascript/AppRouter.tsx b/app/javascript/AppRouter.tsx index 4fba905834..c75fe947c5 100644 --- a/app/javascript/AppRouter.tsx +++ b/app/javascript/AppRouter.tsx @@ -1,11 +1,10 @@ -import { useContext, useEffect } from 'react'; +import { useContext } from 'react'; import { RouteObject, replace, Outlet, LoaderFunction, redirect, - useNavigate, RouterContextProvider, MiddlewareFunction, } from 'react-router'; @@ -157,22 +156,12 @@ const eventAdminRootRedirect: LoaderFunction = async ({ c return redirect(buildEventCategoryUrl(firstEventCategory)); }; -function EventPageGuard() { - const { siteMode } = useContext(AppRootContext); - const navigate = useNavigate(); - - useEffect(() => { - if (siteMode === SiteMode.SingleEvent) { - navigate('/', { replace: true }); - } - }, []); - - if (siteMode === SiteMode.SingleEvent) { - return <>; - } else { - return ; +const eventPageMiddleware: MiddlewareFunction = ({ context }) => { + const appRootData = context.get(appRootDataContext); + if (appRootData?.convention?.site_mode === SiteMode.SingleEvent) { + return redirect('/'); } -} +}; const appRootMiddleware: MiddlewareFunction = async ({ context }) => { const client = context.get(apolloClientContext); @@ -344,7 +333,7 @@ const eventsRoutes: RouteObject[] = [ }, { path: '', - element: , + middleware: [eventPageMiddleware], children: [{ index: true, id: NamedRoute.EventPage, lazy: () => import('./EventsApp/EventPage') }], }, ], From b215dfa1c71e5d70349e5f72576b4e32b5652e06 Mon Sep 17 00:00:00 2001 From: Nat Budin Date: Sun, 28 Jun 2026 11:47:29 -0700 Subject: [PATCH 4/6] Convert AppRootContextRouteGuard to middleware Replaces the render-time AppRootContextRouteGuard component with a makeAppRootContextMiddleware factory that reads appRootDataContext and returns a 404 Response before loaders run. RouteErrorBoundary now renders FourOhFourPage for 404 responses so the layout is preserved. Co-Authored-By: Claude Sonnet 4.6 --- app/javascript/AppRouter.tsx | 85 +++++++++++++-------------- app/javascript/RouteErrorBoundary.tsx | 7 ++- 2 files changed, 47 insertions(+), 45 deletions(-) diff --git a/app/javascript/AppRouter.tsx b/app/javascript/AppRouter.tsx index c75fe947c5..e15b4f541e 100644 --- a/app/javascript/AppRouter.tsx +++ b/app/javascript/AppRouter.tsx @@ -1,4 +1,3 @@ -import { useContext } from 'react'; import { RouteObject, replace, @@ -11,10 +10,9 @@ import { import FourOhFourPage from './FourOhFourPage'; import { SignupAutomationMode, SignupMode, SiteMode, TicketMode } from './graphqlTypes.generated'; -import AppRootContext, { AppRootContextValue } from './AppRootContext'; import useAuthorizationRequired, { AbilityName } from './Authentication/useAuthorizationRequired'; import { EventAdminEventsQueryDocument } from './EventAdmin/queries.generated'; -import { AppRootQueryDocument } from './appRootQueries.generated'; +import { AppRootQueryData, AppRootQueryDocument } from './appRootQueries.generated'; import buildEventCategoryUrl from './EventAdmin/buildEventCategoryUrl'; import { adminSingleTicketTypeLoader, @@ -97,18 +95,13 @@ export enum NamedRoute { export type RouteName = keyof typeof NamedRoute & string; -export type AppRootContextRouteGuardProps = { - guard: (context: AppRootContextValue) => boolean; -}; - -export function AppRootContextRouteGuard({ guard }: AppRootContextRouteGuardProps) { - const context = useContext(AppRootContext); - - if (guard(context)) { - return ; - } else { - return ; - } +function makeAppRootContextMiddleware(guard: (data: AppRootQueryData) => boolean): MiddlewareFunction { + return ({ context }) => { + const appRootData = context.get(appRootDataContext); + if (appRootData != null && !guard(appRootData)) { + return new Response(null, { status: 404 }); + } + }; } function LoginRequiredRouteGuard() { @@ -180,7 +173,7 @@ const editEventMiddleware: MiddlewareFunction = ({ context }) => { const eventsRoutes: RouteObject[] = [ { - element: siteMode !== SiteMode.SingleEvent} />, + middleware: [makeAppRootContextMiddleware(({ convention }) => convention?.site_mode !== SiteMode.SingleEvent)], children: [ { path: 'schedule', @@ -202,11 +195,11 @@ const eventsRoutes: RouteObject[] = [ ], }, { - element: ( - signupAutomationMode === SignupAutomationMode.RankedChoice} - /> - ), + middleware: [ + makeAppRootContextMiddleware( + ({ convention }) => convention?.signup_automation_mode === SignupAutomationMode.RankedChoice, + ), + ], children: [{ path: 'my-signup-queue', lazy: () => import('./EventsApp/MySignupQueue') }], }, { @@ -215,7 +208,9 @@ const eventsRoutes: RouteObject[] = [ children: [ { path: 'attach_image', lazy: () => import('./EventsApp/attach_image') }, { - element: ticketMode === TicketMode.TicketPerEvent} />, + middleware: [ + makeAppRootContextMiddleware(({ convention }) => convention?.ticket_mode === TicketMode.TicketPerEvent), + ], children: [ { path: 'ticket_types', @@ -455,7 +450,7 @@ const commonRoutes: RouteObject[] = [ ], }, { - element: conventionName == null} />, + middleware: [makeAppRootContextMiddleware(({ convention }) => convention?.name == null)], children: [{ path: '/root_site', lazy: () => import('./RootSiteAdmin/EditRootSite') }], }, ], @@ -514,11 +509,11 @@ const commonInConventionRoutes: RouteObject[] = [ id: NamedRoute.EventAdmin, children: [ { - element: siteMode === SiteMode.SingleEvent} />, + middleware: [makeAppRootContextMiddleware(({ convention }) => convention?.site_mode === SiteMode.SingleEvent)], children: [{ path: ':eventId/edit', lazy: () => import('./EventAdmin/EventAdminEditEvent') }], }, { - element: siteMode !== SiteMode.SingleEvent} />, + middleware: [makeAppRootContextMiddleware(({ convention }) => convention?.site_mode !== SiteMode.SingleEvent)], children: [ { path: 'dropped_events', lazy: () => import('./EventAdmin/DroppedEventAdmin') }, { @@ -703,18 +698,18 @@ const commonInConventionRoutes: RouteObject[] = [ children: [{ path: ':id', lazy: () => import('./RoomsAdmin/$id/route') }], }, { - element: signupMode === SignupMode.Moderated} />, + middleware: [makeAppRootContextMiddleware(({ convention }) => convention?.signup_mode === SignupMode.Moderated)], children: [ { path: '/signup_moderation', lazy: () => import('./SignupModeration'), children: [ { - element: ( - signupAutomationMode === SignupAutomationMode.RankedChoice} - /> - ), + middleware: [ + makeAppRootContextMiddleware( + ({ convention }) => convention?.signup_automation_mode === SignupAutomationMode.RankedChoice, + ), + ], children: [ { path: 'ranked_choice_queue', @@ -797,7 +792,9 @@ const commonInConventionRoutes: RouteObject[] = [ ], }, { - element: ticketMode === TicketMode.RequiredForSignup} />, + middleware: [ + makeAppRootContextMiddleware(({ convention }) => convention?.ticket_mode === TicketMode.RequiredForSignup), + ], children: [ { path: '/ticket_types', @@ -1051,27 +1048,27 @@ export const appLayoutRoutes: RouteObject[] = [ element: , children: [ { - element: ( - conventionName != null && siteMode !== SiteMode.SingleEvent} - /> - ), + middleware: [ + makeAppRootContextMiddleware( + ({ convention }) => convention?.name != null && convention?.site_mode !== SiteMode.SingleEvent, + ), + ], children: conventionModeRoutes, }, { - element: ( - conventionName != null && siteMode === SiteMode.SingleEvent} - /> - ), + middleware: [ + makeAppRootContextMiddleware( + ({ convention }) => convention?.name != null && convention?.site_mode === SiteMode.SingleEvent, + ), + ], children: singleEventModeRoutes, }, { - element: conventionName != null} />, + middleware: [makeAppRootContextMiddleware(({ convention }) => convention?.name != null)], children: commonInConventionRoutes, }, { - element: conventionName == null} />, + middleware: [makeAppRootContextMiddleware(({ convention }) => convention?.name == null)], children: rootSiteRoutes, }, ...commonRoutes, diff --git a/app/javascript/RouteErrorBoundary.tsx b/app/javascript/RouteErrorBoundary.tsx index 98f092e95c..41db275615 100644 --- a/app/javascript/RouteErrorBoundary.tsx +++ b/app/javascript/RouteErrorBoundary.tsx @@ -1,9 +1,14 @@ +import { isRouteErrorResponse, useRouteError } from 'react-router'; import { ErrorDisplay } from '@neinteractiveliterature/litform'; -import { useRouteError } from 'react-router'; +import FourOhFourPage from './FourOhFourPage'; export default function RouteErrorBoundary() { const error = useRouteError(); + if (isRouteErrorResponse(error) && error.status === 404) { + return ; + } + if (error instanceof Error) { return ; } else if (typeof error === 'object' && error) { From aa03d2255085e3358815704c46b3479786439023 Mon Sep 17 00:00:00 2001 From: Nat Budin Date: Sun, 28 Jun 2026 11:51:01 -0700 Subject: [PATCH 5/6] Convert AuthorizationRequiredRouteGuard to middleware Replaces the render-time AuthorizationRequiredRouteGuard component with makeAuthorizationMiddleware, which returns 401 for unauthenticated users and 403 for users lacking the required abilities. RouteErrorBoundary now handles 401 by rendering a LoginRequired component (preserving the async OAuth spinner behavior) and 403 by rendering AuthorizationError. Co-Authored-By: Claude Sonnet 4.6 --- app/javascript/AppRouter.tsx | 39 ++++++++++++++------------- app/javascript/RouteErrorBoundary.tsx | 13 +++++++-- 2 files changed, 31 insertions(+), 21 deletions(-) diff --git a/app/javascript/AppRouter.tsx b/app/javascript/AppRouter.tsx index e15b4f541e..efd046320e 100644 --- a/app/javascript/AppRouter.tsx +++ b/app/javascript/AppRouter.tsx @@ -10,7 +10,7 @@ import { import FourOhFourPage from './FourOhFourPage'; import { SignupAutomationMode, SignupMode, SiteMode, TicketMode } from './graphqlTypes.generated'; -import useAuthorizationRequired, { AbilityName } from './Authentication/useAuthorizationRequired'; +import { AbilityName } from './Authentication/useAuthorizationRequired'; import { EventAdminEventsQueryDocument } from './EventAdmin/queries.generated'; import { AppRootQueryData, AppRootQueryDocument } from './appRootQueries.generated'; import buildEventCategoryUrl from './EventAdmin/buildEventCategoryUrl'; @@ -113,16 +113,17 @@ function LoginRequiredRouteGuard() { return ; } -type AuthorizationRequiredRouteGuardProps = { - abilities: AbilityName[]; -}; - -function AuthorizationRequiredRouteGuard({ abilities }: AuthorizationRequiredRouteGuardProps) { - const authorizationWarning = useAuthorizationRequired(...abilities); - - if (authorizationWarning) return authorizationWarning; - - return ; +function makeAuthorizationMiddleware(...abilities: AbilityName[]): MiddlewareFunction { + return ({ context }) => { + const appRootData = context.get(appRootDataContext); + if (appRootData == null) return; + if (!appRootData.currentUser) { + return new Response(null, { status: 401 }); + } + if (!abilities.every((ability) => appRootData.currentAbility[ability])) { + return new Response(null, { status: 403 }); + } + }; } const eventAdminRootRedirect: LoaderFunction = async ({ context }) => { @@ -486,7 +487,7 @@ const commonRoutes: RouteObject[] = [ const commonInConventionRoutes: RouteObject[] = [ { path: '/admin_departments', - element: , + middleware: [makeAuthorizationMiddleware('can_update_departments')], id: NamedRoute.DepartmentAdmin, loader: departmentAdminLoader, children: [ @@ -574,7 +575,7 @@ const commonInConventionRoutes: RouteObject[] = [ }, { path: '/admin_notifications', - element: , + middleware: [makeAuthorizationMiddleware('can_update_notification_templates')], children: [ { path: ':eventKey', @@ -657,7 +658,7 @@ const commonInConventionRoutes: RouteObject[] = [ { path: '/events', children: eventsRoutes }, { path: '/mailing_lists', - element: , + middleware: [makeAuthorizationMiddleware('can_read_any_mailing_list')], children: [ { path: 'ticketed_attendees', lazy: () => import('./MailingLists/TicketedAttendees') }, { path: 'event_proposers', lazy: () => import('./MailingLists/EventProposers') }, @@ -682,7 +683,7 @@ const commonInConventionRoutes: RouteObject[] = [ { path: '/products/:id', lazy: () => import('./Store/ProductPage') }, { path: '/reports', - element: , + middleware: [makeAuthorizationMiddleware('can_read_reports')], children: [ { path: 'new_and_returning_attendees', lazy: () => import('./Reports/NewAndReturningAttendees') }, { path: 'attendance_by_payment_amount', lazy: () => import('./Reports/AttendanceByPaymentAmount') }, @@ -798,7 +799,7 @@ const commonInConventionRoutes: RouteObject[] = [ children: [ { path: '/ticket_types', - element: , + middleware: [makeAuthorizationMiddleware('can_manage_ticket_types')], children: [ { path: 'new', loader: adminTicketTypesLoader, lazy: () => import('./TicketTypeAdmin/NewTicketType') }, { @@ -833,7 +834,7 @@ const commonInConventionRoutes: RouteObject[] = [ }, { path: '/user_con_profiles', - element: , + middleware: [makeAuthorizationMiddleware('can_read_user_con_profiles')], children: [ { path: ':id', @@ -898,7 +899,7 @@ const conventionModeRoutes: RouteObject[] = [ ], }, { - element: , + middleware: [makeAuthorizationMiddleware('can_update_event_categories')], children: [ { path: '/event_categories', @@ -1022,7 +1023,7 @@ const rootSiteRoutes: RouteObject[] = [ ], }, { - element: , + middleware: [makeAuthorizationMiddleware('can_read_users')], children: [ { path: '/users', diff --git a/app/javascript/RouteErrorBoundary.tsx b/app/javascript/RouteErrorBoundary.tsx index 41db275615..a0676f5cc0 100644 --- a/app/javascript/RouteErrorBoundary.tsx +++ b/app/javascript/RouteErrorBoundary.tsx @@ -1,12 +1,21 @@ import { isRouteErrorResponse, useRouteError } from 'react-router'; import { ErrorDisplay } from '@neinteractiveliterature/litform'; import FourOhFourPage from './FourOhFourPage'; +import { AuthorizationError } from './Authentication/useAuthorizationRequired'; +import useLoginRequired from './Authentication/useLoginRequired'; + +function LoginRequired() { + const loginRequired = useLoginRequired(); + return loginRequired || null; +} export default function RouteErrorBoundary() { const error = useRouteError(); - if (isRouteErrorResponse(error) && error.status === 404) { - return ; + if (isRouteErrorResponse(error)) { + if (error.status === 401) return ; + if (error.status === 403) return ; + if (error.status === 404) return ; } if (error instanceof Error) { From bf17d5d3436c18a4a563243458fd854e4735f386 Mon Sep 17 00:00:00 2001 From: Nat Budin Date: Sun, 28 Jun 2026 11:53:07 -0700 Subject: [PATCH 6/6] Convert LoginRequiredRouteGuard to middleware Replaces the render-time LoginRequiredRouteGuard component with a loginRequiredMiddleware constant that returns 401 when no current user is present. RouteErrorBoundary already handles 401 with LoginRequired, so the OAuth spinner behavior is preserved. Co-Authored-By: Claude Sonnet 4.6 --- app/javascript/AppRouter.tsx | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/app/javascript/AppRouter.tsx b/app/javascript/AppRouter.tsx index efd046320e..729d6792f2 100644 --- a/app/javascript/AppRouter.tsx +++ b/app/javascript/AppRouter.tsx @@ -21,7 +21,6 @@ import { eventTicketTypesLoader, } from './TicketTypeAdmin/loaders'; import { organizationsLoader, singleOrganizationLoader } from './OrganizationAdmin/loaders'; -import useLoginRequired from './Authentication/useLoginRequired'; import { eventProposalWithOwnerLoader } from './EventProposals/loaders'; import { conventionDayLoader } from './EventsApp/conventionDayUrls'; import { signupAdminEventLoader, singleSignupLoader } from './EventsApp/SignupAdmin/loaders'; @@ -104,14 +103,13 @@ function makeAppRootContextMiddleware(guard: (data: AppRootQueryData) => boolean }; } -function LoginRequiredRouteGuard() { - const loginRequired = useLoginRequired(); - if (loginRequired) { - return loginRequired; +const loginRequiredMiddleware: MiddlewareFunction = ({ context }) => { + const appRootData = context.get(appRootDataContext); + if (appRootData == null) return; + if (!appRootData.currentUser) { + return new Response(null, { status: 401 }); } - - return ; -} +}; function makeAuthorizationMiddleware(...abilities: AbilityName[]): MiddlewareFunction { return ({ context }) => { @@ -231,8 +229,7 @@ const eventsRoutes: RouteObject[] = [ }, { path: 'edit', - middleware: [editEventMiddleware], - element: , + middleware: [editEventMiddleware, loginRequiredMiddleware], children: [ { index: true, @@ -671,7 +668,7 @@ const commonInConventionRoutes: RouteObject[] = [ }, { path: '/my_profile', - element: , + middleware: [loginRequiredMiddleware], children: [ { path: 'edit_bio', loader: () => replace('./edit') }, { path: 'edit', lazy: () => import('./MyProfile/MyProfileForm') },