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 d1185fb39f..729d6792f2 100644 --- a/app/javascript/AppRouter.tsx +++ b/app/javascript/AppRouter.tsx @@ -1,21 +1,18 @@ -import { useContext } from 'react'; import { RouteObject, replace, Outlet, 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 { AbilityName } from './Authentication/useAuthorizationRequired'; import { EventAdminEventsQueryDocument } from './EventAdmin/queries.generated'; +import { AppRootQueryData, AppRootQueryDocument } from './appRootQueries.generated'; import buildEventCategoryUrl from './EventAdmin/buildEventCategoryUrl'; import { adminSingleTicketTypeLoader, @@ -24,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'; @@ -32,9 +28,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 +35,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', @@ -100,39 +94,34 @@ 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() { - 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 ; -} - -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 }) => { @@ -159,45 +148,31 @@ const eventAdminRootRedirect: LoaderFunction = async ({ c return redirect(buildEventCategoryUrl(firstEventCategory)); }; -function EventPageGuard() { - const { siteMode } = useContext(AppRootContext); - const navigate = useNavigate(); - - if (siteMode === SiteMode.SingleEvent) { - navigate('/', { replace: true }); - return <>; - } else { - return ; +const eventPageMiddleware: MiddlewareFunction = ({ context }) => { + const appRootData = context.get(appRootDataContext); + if (appRootData?.convention?.site_mode === SiteMode.SingleEvent) { + return redirect('/'); } -} - -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[] = [ { - element: siteMode !== SiteMode.SingleEvent} />, + middleware: [makeAppRootContextMiddleware(({ convention }) => convention?.site_mode !== SiteMode.SingleEvent)], children: [ { path: 'schedule', @@ -219,11 +194,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') }], }, { @@ -232,7 +207,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', @@ -252,7 +229,7 @@ const eventsRoutes: RouteObject[] = [ }, { path: 'edit', - element: , + middleware: [editEventMiddleware, loginRequiredMiddleware], children: [ { index: true, @@ -349,7 +326,7 @@ const eventsRoutes: RouteObject[] = [ }, { path: '', - element: , + middleware: [eventPageMiddleware], children: [{ index: true, id: NamedRoute.EventPage, lazy: () => import('./EventsApp/EventPage') }], }, ], @@ -471,7 +448,7 @@ const commonRoutes: RouteObject[] = [ ], }, { - element: conventionName == null} />, + middleware: [makeAppRootContextMiddleware(({ convention }) => convention?.name == null)], children: [{ path: '/root_site', lazy: () => import('./RootSiteAdmin/EditRootSite') }], }, ], @@ -507,7 +484,7 @@ const commonRoutes: RouteObject[] = [ const commonInConventionRoutes: RouteObject[] = [ { path: '/admin_departments', - element: , + middleware: [makeAuthorizationMiddleware('can_update_departments')], id: NamedRoute.DepartmentAdmin, loader: departmentAdminLoader, children: [ @@ -530,7 +507,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') }], + }, + { + middleware: [makeAppRootContextMiddleware(({ convention }) => convention?.site_mode !== SiteMode.SingleEvent)], children: [ { path: 'dropped_events', lazy: () => import('./EventAdmin/DroppedEventAdmin') }, { @@ -591,7 +572,7 @@ const commonInConventionRoutes: RouteObject[] = [ }, { path: '/admin_notifications', - element: , + middleware: [makeAuthorizationMiddleware('can_update_notification_templates')], children: [ { path: ':eventKey', @@ -674,7 +655,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') }, @@ -687,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') }, @@ -699,7 +680,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') }, @@ -715,18 +696,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', @@ -809,11 +790,13 @@ const commonInConventionRoutes: RouteObject[] = [ ], }, { - element: ticketMode === TicketMode.RequiredForSignup} />, + middleware: [ + makeAppRootContextMiddleware(({ convention }) => convention?.ticket_mode === TicketMode.RequiredForSignup), + ], children: [ { path: '/ticket_types', - element: , + middleware: [makeAuthorizationMiddleware('can_manage_ticket_types')], children: [ { path: 'new', loader: adminTicketTypesLoader, lazy: () => import('./TicketTypeAdmin/NewTicketType') }, { @@ -848,7 +831,7 @@ const commonInConventionRoutes: RouteObject[] = [ }, { path: '/user_con_profiles', - element: , + middleware: [makeAuthorizationMiddleware('can_read_user_con_profiles')], children: [ { path: ':id', @@ -913,7 +896,7 @@ const conventionModeRoutes: RouteObject[] = [ ], }, { - element: , + middleware: [makeAuthorizationMiddleware('can_update_event_categories')], children: [ { path: '/event_categories', @@ -1037,7 +1020,7 @@ const rootSiteRoutes: RouteObject[] = [ ], }, { - element: , + middleware: [makeAuthorizationMiddleware('can_read_users')], children: [ { path: '/users', @@ -1063,27 +1046,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, @@ -1093,73 +1076,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', @@ -1215,7 +1135,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..a0676f5cc0 --- /dev/null +++ b/app/javascript/RouteErrorBoundary.tsx @@ -0,0 +1,28 @@ +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)) { + if (error.status === 401) return ; + if (error.status === 403) return ; + if (error.status === 404) return ; + } + + 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; }, },