Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .env.template
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
58 changes: 58 additions & 0 deletions apps/web/src/__tests__/gateway-route-guards.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
71 changes: 71 additions & 0 deletions apps/web/src/hooks/__tests__/useNavItems.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
7 changes: 4 additions & 3 deletions apps/web/src/hooks/useNavItems.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
UsersIcon
} from 'lucide-react';

import { config } from '@/config';
import { useAppStore } from '@/store';

import { useSetupStateQuery } from './useSetupStateQuery';
Expand Down Expand Up @@ -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' }),
Expand Down Expand Up @@ -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,
Expand All @@ -198,7 +200,6 @@ export function useNavItems() {
currentUser,
resolvedLanguage,
setupStateQuery.data.isExperimentalFeaturesEnabled,
setupStateQuery.data.isGatewayEnabled,
setupStateQuery.data.isMailEnabled
]);

Expand Down
34 changes: 19 additions & 15 deletions apps/web/src/providers/WalkthroughProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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]);

Expand Down
96 changes: 53 additions & 43 deletions apps/web/src/routes/_app/admin/settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -187,49 +188,58 @@ const RouteComponent = () => {
/>
</div>
</SettingSection>
<Separator />
<SettingSection title={t({ en: 'Settings', fr: 'Paramètres' })}>
<div className="flex items-center gap-10">
<div className="flex items-center gap-2">
<p className="text-sm font-medium">
{t({ en: 'Default Assignment Validity (Days)', fr: 'Validité par défaut des tâches (jours)' })}
</p>
<HoverCard>
<HoverCard.Trigger asChild>
<button className="text-muted-foreground hover:text-foreground transition-colors" type="button">
<CircleHelpIcon className="h-4 w-4" />
</button>
</HoverCard.Trigger>
<HoverCard.Content className="w-72 text-sm">
{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."
})}
</HoverCard.Content>
</HoverCard>
</div>
<Input
className="w-[90px] shrink-0"
data-testid="default-assignment-duration-input"
inputMode="numeric"
max={MAX_ASSIGNMENT_DURATION_DAYS}
min={1}
type="number"
value={durationDays}
onBlur={flushDurationOnBlur}
onChange={(event) => {
setDurationDays(event.target.value);
clearTimeout(durationDebounceRef.current);
durationDebounceRef.current = setTimeout(saveDurationIfChanged, DURATION_AUTOSAVE_DELAY);
}}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.currentTarget.blur();
}
}}
/>
</div>
</SettingSection>
{/* 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 && (
<React.Fragment>
<Separator />
<SettingSection title={t({ en: 'Settings', fr: 'Paramètres' })}>
<div className="flex items-center gap-10">
<div className="flex items-center gap-2">
<p className="text-sm font-medium">
{t({ en: 'Default Assignment Validity (Days)', fr: 'Validité par défaut des tâches (jours)' })}
</p>
<HoverCard>
<HoverCard.Trigger asChild>
<button
className="text-muted-foreground hover:text-foreground transition-colors"
type="button"
>
<CircleHelpIcon className="h-4 w-4" />
</button>
</HoverCard.Trigger>
<HoverCard.Content className="w-72 text-sm">
{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."
})}
</HoverCard.Content>
</HoverCard>
</div>
<Input
className="w-[90px] shrink-0"
data-testid="default-assignment-duration-input"
inputMode="numeric"
max={MAX_ASSIGNMENT_DURATION_DAYS}
min={1}
type="number"
value={durationDays}
onBlur={flushDurationOnBlur}
onChange={(event) => {
setDurationDays(event.target.value);
clearTimeout(durationDebounceRef.current);
durationDebounceRef.current = setTimeout(saveDurationIfChanged, DURATION_AUTOSAVE_DELAY);
}}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.currentTarget.blur();
}
}}
/>
</div>
</SettingSection>
</React.Fragment>
)}
<Separator />
<SettingSection title={t({ en: 'Languages', es: 'Idiomas', fr: 'Langues' })}>
<div className="flex items-center gap-2">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
});
8 changes: 7 additions & 1 deletion apps/web/src/routes/_app/group/email-templates.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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
});
Loading
Loading