From 3400c0b3a3b260ff5b63a109fbd324b29b98cb41 Mon Sep 17 00:00:00 2001 From: thomasbeaudry Date: Fri, 7 Aug 2026 17:19:53 -0400 Subject: [PATCH 1/2] feat(web): honour GATEWAY_ENABLED across every remote-assignment surface Assignments are served through the gateway, and the API only loads AssignmentsModule and GatewayModule when GATEWAY_ENABLED. The frontend only partly reflected that: the sidebar item read the flag back from GET /v1/setup, the subject tab read it from the env config, and the two routes plus the tutorial step were gated on nothing at all. All four now read config.setup.isGatewayEnabled, which is the same env variable the backend switches on. The routes redirect in beforeLoad rather than rendering a page whose endpoints are not mounted. Co-Authored-By: Claude Opus 5 --- .../src/hooks/__tests__/useNavItems.test.ts | 56 +++++++++++++++++++ apps/web/src/hooks/useNavItems.ts | 4 +- .../web/src/providers/WalkthroughProvider.tsx | 34 ++++++----- .../_app/datahub/$subjectId/assignments.tsx | 8 ++- .../routes/_app/session/remote-assignment.tsx | 8 ++- testing/src/specs/remote-assignment.spec.ts | 19 +++++++ 6 files changed, 110 insertions(+), 19 deletions(-) create mode 100644 apps/web/src/hooks/__tests__/useNavItems.test.ts diff --git a/apps/web/src/hooks/__tests__/useNavItems.test.ts b/apps/web/src/hooks/__tests__/useNavItems.test.ts new file mode 100644 index 000000000..8419d5959 --- /dev/null +++ b/apps/web/src/hooks/__tests__/useNavItems.test.ts @@ -0,0 +1,56 @@ +import { cleanup, renderHook } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { useNavItems } from '../useNavItems'; + +import '@/services/i18n'; + +const mocks = vi.hoisted(() => { + const can = vi.fn((_action: string, _subject: string) => true); + return { + can, + config: { setup: { isGatewayEnabled: true } }, + store: { currentGroup: null, currentSession: null, currentUser: { ability: { can } } } + }; +}); + +vi.mock('@/config', () => ({ config: mocks.config })); + +vi.mock('@/store', () => ({ + useAppStore: vi.fn((selector) => selector(mocks.store)) +})); + +vi.mock('@/hooks/useSetupStateQuery', () => ({ + useSetupStateQuery: () => ({ data: { isExperimentalFeaturesEnabled: false, isMailEnabled: false } }) +})); + +const navUrls = () => + renderHook(() => useNavItems()) + .result.current.flat() + .map((item) => item.url); + +beforeEach(() => { + // There are no vitest setup files in this repo, so RTL never auto-unmounts between tests. + cleanup(); + vi.clearAllMocks(); + mocks.can.mockReturnValue(true); + mocks.config.setup.isGatewayEnabled = true; +}); + +describe('useNavItems', () => { + it('should offer remote assignment when the gateway is deployed', () => { + expect(navUrls()).toContain('/session/remote-assignment'); + }); + + // Assignments are served through the gateway, and the API only loads AssignmentsModule when + // GATEWAY_ENABLED, so an instance without it must not advertise a page that cannot work. + it('should omit remote assignment when the gateway is not deployed', () => { + mocks.config.setup.isGatewayEnabled = false; + expect(navUrls()).not.toContain('/session/remote-assignment'); + }); + + it('should omit remote assignment when the user cannot create one', () => { + mocks.can.mockImplementation((action, subject) => !(action === 'create' && subject === 'Assignment')); + expect(navUrls()).not.toContain('/session/remote-assignment'); + }); +}); diff --git a/apps/web/src/hooks/useNavItems.ts b/apps/web/src/hooks/useNavItems.ts index 58099e846..f652c8aa0 100644 --- a/apps/web/src/hooks/useNavItems.ts +++ b/apps/web/src/hooks/useNavItems.ts @@ -19,6 +19,7 @@ import { UsersIcon } from 'lucide-react'; +import { config } from '@/config'; import { useAppStore } from '@/store'; import { useSetupStateQuery } from './useSetupStateQuery'; @@ -176,7 +177,7 @@ export function useNavItems() { }); } // Remote assignment requires the gateway to be enabled, since assignments are served through it - if (ability?.can('create', 'Assignment') && setupStateQuery.data.isGatewayEnabled) { + if (ability?.can('create', 'Assignment') && config.setup.isGatewayEnabled) { sessionItems.push({ disabled: currentSession === null, icon: SendIcon, @@ -198,7 +199,6 @@ export function useNavItems() { currentUser, resolvedLanguage, setupStateQuery.data.isExperimentalFeaturesEnabled, - setupStateQuery.data.isGatewayEnabled, setupStateQuery.data.isMailEnabled ]); diff --git a/apps/web/src/providers/WalkthroughProvider.tsx b/apps/web/src/providers/WalkthroughProvider.tsx index b78ecf9ba..c0514dbb1 100644 --- a/apps/web/src/providers/WalkthroughProvider.tsx +++ b/apps/web/src/providers/WalkthroughProvider.tsx @@ -348,21 +348,25 @@ const Walkthrough = () => { fr: 'Graphique' }) }, - { - content: t({ - en: 'Here, you can create and view assignments, which are instruments for a subject to complete at home.', - fr: 'Ici, vous pouvez créer et visualiser des assignations, qui sont des instruments que le client doit compléter à la maison.' - }), - navigateOptions: { - to: '/datahub/123/assignments' - }, - position: 'bottom-left', - target: 'a[data-nav-url="/datahub/123/assignments"]', - title: t({ - en: 'Assignments', - fr: 'Assignations' - }) - } + ...(config.setup.isGatewayEnabled + ? [ + { + content: t({ + en: 'Here, you can create and view assignments, which are instruments for a subject to complete at home.', + fr: 'Ici, vous pouvez créer et visualiser des assignations, qui sont des instruments que le client doit compléter à la maison.' + }), + navigateOptions: { + to: '/datahub/123/assignments' + }, + position: 'bottom-left', + target: 'a[data-nav-url="/datahub/123/assignments"]', + title: t({ + en: 'Assignments', + fr: 'Assignations' + }) + } satisfies WalkthroughStep + ] + : []) ]; }, [resolvedLanguage]); diff --git a/apps/web/src/routes/_app/datahub/$subjectId/assignments.tsx b/apps/web/src/routes/_app/datahub/$subjectId/assignments.tsx index b9cc01548..e9225fbb8 100644 --- a/apps/web/src/routes/_app/datahub/$subjectId/assignments.tsx +++ b/apps/web/src/routes/_app/datahub/$subjectId/assignments.tsx @@ -9,10 +9,11 @@ import { useTranslation } from '@douglasneuroinformatics/libui/hooks'; import { CopyButton } from '@opendatacapture/react-core'; import type { Assignment, AssignmentStatus } from '@opendatacapture/schemas/assignment'; import type { UnilingualInstrumentInfo } from '@opendatacapture/schemas/instrument'; -import { createFileRoute } from '@tanstack/react-router'; +import { createFileRoute, redirect } from '@tanstack/react-router'; import { AssignmentEmailForm } from '@/components/AssignmentEmailForm'; import { QRCode } from '@/components/QRCode'; +import { config } from '@/config'; import { useAssignmentsQuery } from '@/hooks/useAssignmentsQuery'; import { useInstrument } from '@/hooks/useInstrument'; import { useInstrumentInfoQuery } from '@/hooks/useInstrumentInfoQuery'; @@ -180,5 +181,10 @@ const RouteComponent = () => { }; export const Route = createFileRoute('/_app/datahub/$subjectId/assignments')({ + beforeLoad: ({ params }) => { + if (!config.setup.isGatewayEnabled) { + throw redirect({ params, to: '/datahub/$subjectId/table' }); + } + }, component: RouteComponent }); diff --git a/apps/web/src/routes/_app/session/remote-assignment.tsx b/apps/web/src/routes/_app/session/remote-assignment.tsx index 9092ec273..60dfcbe06 100644 --- a/apps/web/src/routes/_app/session/remote-assignment.tsx +++ b/apps/web/src/routes/_app/session/remote-assignment.tsx @@ -5,7 +5,7 @@ import { useTranslation } from '@douglasneuroinformatics/libui/hooks'; import { CopyButton } from '@opendatacapture/react-core'; import type { Assignment, CreateAssignmentData } from '@opendatacapture/schemas/assignment'; import type { TranslatedInstrumentInfo } from '@opendatacapture/schemas/instrument'; -import { createFileRoute, useNavigate } from '@tanstack/react-router'; +import { createFileRoute, redirect, useNavigate } from '@tanstack/react-router'; import { z } from 'zod/v4'; import { AssignmentEmailForm } from '@/components/AssignmentEmailForm'; @@ -13,6 +13,7 @@ import { InstrumentShowcase } from '@/components/InstrumentShowcase'; import { PageHeader } from '@/components/PageHeader'; import { QRCode } from '@/components/QRCode'; import { WithFallback } from '@/components/WithFallback'; +import { config } from '@/config'; import { useCreateAssignment } from '@/hooks/useCreateAssignment'; import { useInstrumentInfoQuery } from '@/hooks/useInstrumentInfoQuery'; import { useSetupStateQuery } from '@/hooks/useSetupStateQuery'; @@ -211,5 +212,10 @@ const RouteComponent = () => { }; export const Route = createFileRoute('/_app/session/remote-assignment')({ + beforeLoad: () => { + if (!config.setup.isGatewayEnabled) { + throw redirect({ to: '/dashboard' }); + } + }, component: RouteComponent }); diff --git a/testing/src/specs/remote-assignment.spec.ts b/testing/src/specs/remote-assignment.spec.ts index c8579f1c7..787d6339b 100644 --- a/testing/src/specs/remote-assignment.spec.ts +++ b/testing/src/specs/remote-assignment.spec.ts @@ -101,6 +101,25 @@ test.describe('remote assignment', () => { expect(secondUrl).not.toBe(firstUrl); }); + // Entering by URL is the path a bookmarked link takes, and it runs the GATEWAY_ENABLED guard in + // `beforeLoad` on a cold document load rather than on a client-side transition from the tab. + // `getPageModel` asserts it landed on the URL it asked for, so a guard that wrongly bounced the + // request to the table tab fails here. + test('should allow deep-linking to the assignments tab while the gateway is deployed', async ({ + getPageModel, + page, + uniqueId + }) => { + await startSession(getPageModel, `Deep${uniqueId}`, `Subject${uniqueId}`, 'Male'); + + await page.locator('[data-testid^="nav-button-/datahub/"]').click(); + await page.waitForURL('**/datahub/**/table'); + const subjectId = page.url().split('/datahub/')[1]!.split('/')[0]!; + + const assignmentsPage = await getPageModel('/datahub/$subjectId/assignments', { subjectId }); + await expect(assignmentsPage.assignmentRows).toHaveCount(0); + }); + test("should let a group manager cancel an outstanding assignment from the subject's assignments tab", async ({ getPageModel, page, From 872755c0a67ebae79176287745db8e6034d47115 Mon Sep 17 00:00:00 2001 From: thomasbeaudry Date: Tue, 11 Aug 2026 23:57:03 -0400 Subject: [PATCH 2/2] feat(web): gate the remaining remote-assignment surfaces on GATEWAY_ENABLED Addresses review on #1506. /group/email-templates is a fifth remote-assignment surface: its page is the assignment-email template manager, and the endpoint that sends that mail lives in the gateway-gated AssignmentsModule. Its nav item and route now read config.setup.isGatewayEnabled like the other four. The Default Assignment Validity setting configures the expiry of a remote assignment, so with the gateway off it sets something that can never apply. Its section is hidden rather than left inert. The three beforeLoad guards now have unit tests, so the redirect that protects a bookmarked link is covered rather than only the sidebar. The GATEWAY_ENABLED comment in .env.template said only that it activates the gateway, which does not tell an operator that remote assignments depend on it. Co-Authored-By: Claude Opus 5 --- .env.template | 2 +- .../__tests__/gateway-route-guards.test.ts | 58 +++++++++++ .../src/hooks/__tests__/useNavItems.test.ts | 19 +++- apps/web/src/hooks/useNavItems.ts | 3 +- apps/web/src/routes/_app/admin/settings.tsx | 96 ++++++++++--------- .../src/routes/_app/group/email-templates.tsx | 8 +- 6 files changed, 138 insertions(+), 48 deletions(-) create mode 100644 apps/web/src/__tests__/gateway-route-guards.test.ts diff --git a/.env.template b/.env.template index a2bf763df..0cf391dc6 100644 --- a/.env.template +++ b/.env.template @@ -47,7 +47,7 @@ CONTACT_EMAIL=support@example.org DOCS_URL=https://opendatacapture.org/docs # A link to the repository containing the source code for the platform GITHUB_REPO_URL=https://github.com/DouglasNeuroInformatics/OpenDataCapture -# Whether or not the gateway should be activated +# Controls gateway activation; must be set to true to send remote assignments GATEWAY_ENABLED=true # A link to the license governing distribution of the platform and all derivative work LICENSE_URL=https://www.apache.org/licenses/LICENSE-2.0 diff --git a/apps/web/src/__tests__/gateway-route-guards.test.ts b/apps/web/src/__tests__/gateway-route-guards.test.ts new file mode 100644 index 000000000..85f8835e1 --- /dev/null +++ b/apps/web/src/__tests__/gateway-route-guards.test.ts @@ -0,0 +1,58 @@ +import { isRedirect } from '@tanstack/react-router'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + config: { setup: { apiBaseUrl: '', isGatewayEnabled: true } } +})); + +vi.mock('@/config', () => ({ config: mocks.config })); + +/** + * Every route whose page exists only to serve remote assignments, which the API mounts behind + * GATEWAY_ENABLED. Each entry is the module holding the route and where its guard sends a user + * whose instance was deployed without the gateway. + */ +const GATEWAY_ROUTES = [ + { + importRoute: async () => (await import('@/routes/_app/session/remote-assignment')).Route, + params: {}, + redirectsTo: '/dashboard' + }, + { + importRoute: async () => (await import('@/routes/_app/datahub/$subjectId/assignments')).Route, + params: { subjectId: '123' }, + redirectsTo: '/datahub/$subjectId/table' + }, + { + importRoute: async () => (await import('@/routes/_app/group/email-templates')).Route, + params: {}, + redirectsTo: '/dashboard' + } +] as const; + +const runGuard = (route: { options: { beforeLoad?: unknown } }, params: object) => { + const beforeLoad = route.options.beforeLoad as (opts: { params: object }) => void; + try { + beforeLoad({ params }); + } catch (err) { + return err; + } + return null; +}; + +beforeEach(() => { + mocks.config.setup.isGatewayEnabled = true; +}); + +describe.each(GATEWAY_ROUTES)('$redirectsTo guard', ({ importRoute, params, redirectsTo }) => { + it('should redirect away when the gateway is not deployed, so a bookmarked link cannot reach a page whose endpoints are not mounted', async () => { + mocks.config.setup.isGatewayEnabled = false; + const thrown = runGuard(await importRoute(), params); + expect(isRedirect(thrown)).toBe(true); + expect((thrown as { options: { to: string } }).options.to).toBe(redirectsTo); + }); + + it('should allow the route when the gateway is deployed', async () => { + expect(runGuard(await importRoute(), params)).toBeNull(); + }); +}); diff --git a/apps/web/src/hooks/__tests__/useNavItems.test.ts b/apps/web/src/hooks/__tests__/useNavItems.test.ts index 8419d5959..44cfa60fa 100644 --- a/apps/web/src/hooks/__tests__/useNavItems.test.ts +++ b/apps/web/src/hooks/__tests__/useNavItems.test.ts @@ -10,7 +10,8 @@ const mocks = vi.hoisted(() => { return { can, config: { setup: { isGatewayEnabled: true } }, - store: { currentGroup: null, currentSession: null, currentUser: { ability: { can } } } + setupState: { isExperimentalFeaturesEnabled: false, isMailEnabled: false }, + store: { currentGroup: { id: 'group-1' }, currentSession: null, currentUser: { ability: { can } } } }; }); @@ -21,7 +22,7 @@ vi.mock('@/store', () => ({ })); vi.mock('@/hooks/useSetupStateQuery', () => ({ - useSetupStateQuery: () => ({ data: { isExperimentalFeaturesEnabled: false, isMailEnabled: false } }) + useSetupStateQuery: () => ({ data: mocks.setupState }) })); const navUrls = () => @@ -35,6 +36,7 @@ beforeEach(() => { vi.clearAllMocks(); mocks.can.mockReturnValue(true); mocks.config.setup.isGatewayEnabled = true; + mocks.setupState.isMailEnabled = false; }); describe('useNavItems', () => { @@ -53,4 +55,17 @@ describe('useNavItems', () => { mocks.can.mockImplementation((action, subject) => !(action === 'create' && subject === 'Assignment')); expect(navUrls()).not.toContain('/session/remote-assignment'); }); + + it('should offer email templates when mail is configured and the gateway is deployed', () => { + mocks.setupState.isMailEnabled = true; + expect(navUrls()).toContain('/group/email-templates'); + }); + + // The templates are only ever used to email a remote assignment link, and the endpoint that sends + // that mail lives in the gateway-gated AssignmentsModule. + it('should omit email templates when the gateway is not deployed, even with mail configured', () => { + mocks.setupState.isMailEnabled = true; + mocks.config.setup.isGatewayEnabled = false; + expect(navUrls()).not.toContain('/group/email-templates'); + }); }); diff --git a/apps/web/src/hooks/useNavItems.ts b/apps/web/src/hooks/useNavItems.ts index f652c8aa0..aaa21ca9a 100644 --- a/apps/web/src/hooks/useNavItems.ts +++ b/apps/web/src/hooks/useNavItems.ts @@ -87,7 +87,8 @@ export function useNavItems() { label: t('layout.navLinks.manageGroup'), url: '/group/manage' }); - if (setupStateQuery.data.isMailEnabled) { + // These templates exist only to email a remote assignment link, which the gateway serves + if (setupStateQuery.data.isMailEnabled && config.setup.isGatewayEnabled) { globalItems.push({ icon: MailIcon, label: t({ en: 'Email Templates', fr: 'Modèles de courriel' }), diff --git a/apps/web/src/routes/_app/admin/settings.tsx b/apps/web/src/routes/_app/admin/settings.tsx index ca09a56cf..aa708c901 100644 --- a/apps/web/src/routes/_app/admin/settings.tsx +++ b/apps/web/src/routes/_app/admin/settings.tsx @@ -18,6 +18,7 @@ import { CircleHelpIcon } from 'lucide-react'; import { PageHeader } from '@/components/PageHeader'; import { SaveStatus } from '@/components/SaveStatus'; +import { config } from '@/config'; import { useSetupStateQuery } from '@/hooks/useSetupStateQuery'; import { useUpdateSetupStateMutation } from '@/hooks/useUpdateSetupStateMutation'; import { useAppStore } from '@/store'; @@ -187,49 +188,58 @@ const RouteComponent = () => { /> - - -
-
-

- {t({ en: 'Default Assignment Validity (Days)', fr: 'Validité par défaut des tâches (jours)' })} -

- - - - - - {t({ - en: 'The number of days a new remote assignment stays valid by default. This only sets the initial expiry date when creating an assignment, which can still be changed for each one.', - fr: "Le nombre de jours pendant lesquels une nouvelle tâche à distance reste valide par défaut. Ceci ne définit que la date d'expiration initiale lors de la création d'une tâche, qui peut toujours être modifiée pour chacune." - })} - - -
- { - setDurationDays(event.target.value); - clearTimeout(durationDebounceRef.current); - durationDebounceRef.current = setTimeout(saveDurationIfChanged, DURATION_AUTOSAVE_DELAY); - }} - onKeyDown={(event) => { - if (event.key === 'Enter') { - event.currentTarget.blur(); - } - }} - /> -
-
+ {/* The only thing this section configures is the expiry of a remote assignment, which + an instance deployed without the gateway can never create */} + {config.setup.isGatewayEnabled && ( + + + +
+
+

+ {t({ en: 'Default Assignment Validity (Days)', fr: 'Validité par défaut des tâches (jours)' })} +

+ + + + + + {t({ + en: 'The number of days a new remote assignment stays valid by default. This only sets the initial expiry date when creating an assignment, which can still be changed for each one.', + fr: "Le nombre de jours pendant lesquels une nouvelle tâche à distance reste valide par défaut. Ceci ne définit que la date d'expiration initiale lors de la création d'une tâche, qui peut toujours être modifiée pour chacune." + })} + + +
+ { + setDurationDays(event.target.value); + clearTimeout(durationDebounceRef.current); + durationDebounceRef.current = setTimeout(saveDurationIfChanged, DURATION_AUTOSAVE_DELAY); + }} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.currentTarget.blur(); + } + }} + /> +
+
+
+ )}
diff --git a/apps/web/src/routes/_app/group/email-templates.tsx b/apps/web/src/routes/_app/group/email-templates.tsx index bd15d9666..a7b819f36 100644 --- a/apps/web/src/routes/_app/group/email-templates.tsx +++ b/apps/web/src/routes/_app/group/email-templates.tsx @@ -2,10 +2,11 @@ import React from 'react'; import { Heading } from '@douglasneuroinformatics/libui/components'; import { useTranslation } from '@douglasneuroinformatics/libui/hooks'; -import { createFileRoute } from '@tanstack/react-router'; +import { createFileRoute, redirect } from '@tanstack/react-router'; import { GroupEmailTemplates } from '@/components/GroupEmailTemplates'; import { PageHeader } from '@/components/PageHeader'; +import { config } from '@/config'; const RouteComponent = () => { const { t } = useTranslation(); @@ -23,5 +24,10 @@ const RouteComponent = () => { }; export const Route = createFileRoute('/_app/group/email-templates')({ + beforeLoad: () => { + if (!config.setup.isGatewayEnabled) { + throw redirect({ to: '/dashboard' }); + } + }, component: RouteComponent });