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 new file mode 100644 index 000000000..44cfa60fa --- /dev/null +++ b/apps/web/src/hooks/__tests__/useNavItems.test.ts @@ -0,0 +1,71 @@ +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 } }, + setupState: { isExperimentalFeaturesEnabled: false, isMailEnabled: false }, + store: { currentGroup: { id: 'group-1' }, 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: mocks.setupState }) +})); + +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; + mocks.setupState.isMailEnabled = false; +}); + +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'); + }); + + 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 58099e846..aaa21ca9a 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'; @@ -86,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' }), @@ -176,7 +178,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 +200,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/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/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/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 }); 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,