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: 2 additions & 0 deletions src/components/ErrorLoadingConfig.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ export function ErrorLoadingConfig({
'unused-component',
'disabled-sidebar',
'default-contact-email',
'default-firebase-config',
'default-supabase-config',
];

// Format category labels by capitalizing each word and replacing hyphens with spaces
Expand Down
18 changes: 18 additions & 0 deletions src/components/interface/AppHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,12 @@ import { hideNotification, showNotification } from '../../utils/notifications';
import { getMutedInstruction } from '../../utils/recordingWarnings';
import classes from './AppHeader.module.css';
import { useDeviceRules } from '../../utils/useDeviceRules';
import {
DEFAULT_FIREBASE_WARNING_MESSAGE,
DEFAULT_SUPABASE_WARNING_MESSAGE,
shouldWarnForDefaultFirebaseConfig,
shouldWarnForDefaultSupabaseConfig,
} from '../../utils/defaultStorageConfig';

export function AppHeader({ developmentModeEnabled, dataCollectionEnabled }: { developmentModeEnabled: boolean; dataCollectionEnabled: boolean }) {
const studyConfig = useStoreSelector((state) => state.config);
Expand Down Expand Up @@ -110,6 +116,8 @@ export function AppHeader({ developmentModeEnabled, dataCollectionEnabled }: { d
} = useDeviceRules(studyConfig.studyRules);
const hasUnmetDeviceRequirement = developmentModeEnabled
&& (!isBrowserAllowed || !isDeviceAllowed || !isInputAllowed || !isDisplayAllowed);
const showDefaultFirebaseWarning = shouldWarnForDefaultFirebaseConfig();
const showDefaultSupabaseWarning = shouldWarnForDefaultSupabaseConfig();
const isScreenRecordingPermission = currentComponent === '$screen-recording.components.screenRecordingPermission';
const showAudioStatus = currentComponentHasAudioRecording
|| isAudioRecording
Expand Down Expand Up @@ -246,6 +254,16 @@ export function AppHeader({ developmentModeEnabled, dataCollectionEnabled }: { d
</Group>
)}
{storageEngineFailedToConnect && <Tooltip multiline withArrow arrowSize={6} w={300} label="Failed to connect to the storage engine. Study data will not be saved. Check your connection or restart the app."><Badge size="lg" color="red">Storage Disconnected</Badge></Tooltip>}
{showDefaultFirebaseWarning && (
<Tooltip multiline withArrow arrowSize={6} w={360} label={DEFAULT_FIREBASE_WARNING_MESSAGE}>
Comment thread
jaykim1213 marked this conversation as resolved.
<Badge size="lg" color="orange">Default Firebase</Badge>
</Tooltip>
)}
{showDefaultSupabaseWarning && (
<Tooltip multiline withArrow arrowSize={6} w={360} label={DEFAULT_SUPABASE_WARNING_MESSAGE}>
<Badge size="lg" color="orange">Default Supabase</Badge>
</Tooltip>
)}
{!storageEngineFailedToConnect && !dataCollectionEnabled && <Tooltip multiline withArrow arrowSize={6} w={300} label="This is a demo version of the study, we’re not collecting any data."><Badge size="lg" color="orange">Demo Mode</Badge></Tooltip>}
{hasUnmetDeviceRequirement && developmentModeEnabled && <Tooltip multiline withArrow arrowSize={6} w={420} label="Your device does not meet this study's requirements. You are still able to explore this study while in debug mode."><Badge size="lg" color="red">Device Requirement Not Met</Badge></Tooltip>}
{studyConfig?.uiConfig.helpTextPath !== undefined && (
Expand Down
33 changes: 30 additions & 3 deletions src/components/interface/tests/AppHeader.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,12 @@ vi.mock('../../../utils/recordingWarnings', () => ({

describe('AppHeader interactive', () => {
beforeEach(() => { mockStorageEngineFailedToConnect = false; });
afterEach(() => { cleanup(); vi.useRealTimers(); });
afterEach(() => {
cleanup();
vi.useRealTimers();
vi.unstubAllEnvs();
vi.unstubAllGlobals();
});

test('covers storageEngineFailedToConnect effect: setTimeout setup and callback', async () => {
mockStorageEngineFailedToConnect = true;
Expand All @@ -185,6 +190,9 @@ describe('AppHeader interactive', () => {

describe('AppHeader', () => {
beforeEach(() => {
vi.unstubAllEnvs();
vi.unstubAllGlobals();
mockStorageEngineFailedToConnect = false;
mockedCurrentComponent = 'componentA';
mockedRecordingContext = {
isScreenRecording: false,
Expand Down Expand Up @@ -223,8 +231,7 @@ describe('AppHeader', () => {
const html = renderToStaticMarkup(
<AppHeader developmentModeEnabled={false} dataCollectionEnabled={false} />,
);
// With no storageEngine connection, shows disconnected state
expect(html).toContain('Storage Disconnected');
expect(html).toContain('Demo Mode');
// Dev mode controls should not appear
expect(html).not.toContain('Study Browser');
});
Expand Down Expand Up @@ -296,4 +303,24 @@ describe('AppHeader', () => {

expect(html).toContain('Recording permission denied');
});

test('shows default Firebase warning badge on direct study pages', () => {
vi.stubEnv('VITE_STORAGE_ENGINE', 'firebase');
vi.stubEnv('VITE_FIREBASE_CONFIG', JSON.stringify({ projectId: 'revisit-utah' }));
vi.stubGlobal('window', { location: { hostname: 'study.example.com' } });

const html = renderToStaticMarkup(<AppHeader developmentModeEnabled={false} dataCollectionEnabled />);

expect(html).toContain('Default Firebase');
});

test('shows default Supabase warning badge on direct study pages', () => {
vi.stubEnv('VITE_STORAGE_ENGINE', 'supabase');
vi.stubEnv('VITE_SUPABASE_URL', 'https://supabase.revisit.dev');
vi.stubGlobal('window', { location: { hostname: 'study.example.com' } });

const html = renderToStaticMarkup(<AppHeader developmentModeEnabled={false} dataCollectionEnabled />);

expect(html).toContain('Default Supabase');
});
});
46 changes: 46 additions & 0 deletions src/components/tests/ErrorLoadingConfig.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -105,4 +105,50 @@ describe('ErrorLoadingConfig', () => {
expect(textOnly).toContain('Default Contact Email');
expect(textOnly).toContain('contact@revisit.dev');
});

test('renders default-firebase-config warning category and message', () => {
const issues: ParsedConfig<StudyConfig>['errors'] = [
{
category: 'default-firebase-config',
instancePath: 'environment/VITE_FIREBASE_CONFIG',
message: 'This study is connected to ReVISit\'s default Firebase project. Participant data may not be saved to a backend controlled by the study designer.',
params: {
action: 'Set VITE_FIREBASE_CONFIG to a Firebase project controlled by the study designer or choose another storage engine',
},
},
];

const html = renderToStaticMarkup(
<MantineProvider>
<ErrorLoadingConfig issues={issues} type="warning" />
</MantineProvider>,
);
const textOnly = html.replace(/<[^>]*>/g, ' ');

expect(textOnly).toContain('Default Firebase Config');
expect(textOnly).toContain('default Firebase project');
});

test('renders default-supabase-config warning category and message', () => {
const issues: ParsedConfig<StudyConfig>['errors'] = [
{
category: 'default-supabase-config',
instancePath: 'environment/VITE_SUPABASE_URL',
message: 'This study is connected to ReVISit\'s default Supabase project. Participant data may not be saved to a backend controlled by the study designer.',
params: {
action: 'Set VITE_SUPABASE_URL to a Supabase project controlled by the study designer or choose another storage engine',
},
},
];

const html = renderToStaticMarkup(
<MantineProvider>
<ErrorLoadingConfig issues={issues} type="warning" />
</MantineProvider>,
);
const textOnly = html.replace(/<[^>]*>/g, ' ');

expect(textOnly).toContain('Default Supabase Config');
expect(textOnly).toContain('default Supabase project');
});
});
38 changes: 30 additions & 8 deletions src/parser/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,17 @@ import {
import { getSequenceFlatMapWithInterruptions } from '../utils/getSequenceFlatMap';
import { expandLibrarySequences, loadLibrariesParseNamespace, verifyLibraryUsage } from './libraryParser';
import { isDynamicBlock, isInheritedComponent } from './utils';
import {
DEFAULT_CONTACT_EMAIL,
DEFAULT_FIREBASE_WARNING_ACTION,
DEFAULT_FIREBASE_WARNING_MESSAGE,
DEFAULT_SUPABASE_WARNING_ACTION,
DEFAULT_SUPABASE_WARNING_MESSAGE,
getCurrentHostname,
shouldSuppressDefaultDeploymentWarnings,
shouldWarnForDefaultFirebaseConfig,
shouldWarnForDefaultSupabaseConfig,
} from '../utils/defaultStorageConfig';

const ajv1 = new Ajv({ allowUnionTypes: true });
ajv1.addSchema(globalSchema);
Expand Down Expand Up @@ -165,21 +176,32 @@ function verifyStudyConfig(studyConfig: StudyConfig, importedLibrariesData: Reco
});
}

// Warn if the default contact email is left in the config and the study is not hosted on a known ReVISit domain
const DEFAULT_CONTACT_EMAIL = 'contact@revisit.dev';
const REVISIT_DOMAINS = ['revisit.dev', 'vdl.sci.utah.edu'];
const LOCAL_DEVELOPMENT_HOSTNAMES = new Set(['localhost', '127.0.0.1', '0.0.0.0', '::1', '[::1]']);
const hostname = typeof window !== 'undefined' ? window.location.hostname : '';
const isRevisitDomain = REVISIT_DOMAINS.some((domain) => hostname === domain || hostname.endsWith(`.${domain}`));
const isLocalDevelopment = LOCAL_DEVELOPMENT_HOSTNAMES.has(hostname);
if (studyConfig.uiConfig.contactEmail === DEFAULT_CONTACT_EMAIL && !isRevisitDomain && !isLocalDevelopment) {
// Warn if deployment defaults are left in place outside known ReVISit/local hosts.
const hostname = getCurrentHostname();
if (studyConfig.uiConfig.contactEmail === DEFAULT_CONTACT_EMAIL && !shouldSuppressDefaultDeploymentWarnings(hostname)) {
warnings.push({
message: `The contact email is set to the default value \`${DEFAULT_CONTACT_EMAIL}\`. Please update it to your own email address.`,
instancePath: '/uiConfig/contactEmail',
params: { action: 'Update the contactEmail field in uiConfig to your own email address' },
category: 'default-contact-email',
});
}
if (shouldWarnForDefaultFirebaseConfig({ hostname })) {
warnings.push({
message: DEFAULT_FIREBASE_WARNING_MESSAGE,
instancePath: 'environment/VITE_FIREBASE_CONFIG',
params: { action: DEFAULT_FIREBASE_WARNING_ACTION },
category: 'default-firebase-config',
});
}
if (shouldWarnForDefaultSupabaseConfig({ hostname })) {
warnings.push({
message: DEFAULT_SUPABASE_WARNING_MESSAGE,
instancePath: 'environment/VITE_SUPABASE_URL',
params: { action: DEFAULT_SUPABASE_WARNING_ACTION },
category: 'default-supabase-config',
});
}

// Verify components are well defined
Object.entries(studyConfig.components)
Expand Down
120 changes: 119 additions & 1 deletion src/parser/tests/parser.spec.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
import {
describe, expect, test, vi,
afterEach, describe, expect, test, vi,
} from 'vitest';
import { parseStudyConfig } from '../parser';
import { isDynamicBlock } from '../utils';

global.fetch = vi.fn();

afterEach(() => {
vi.unstubAllEnvs();
vi.unstubAllGlobals();
});

function mockFetchText(body: string) {
return { text: () => Promise.resolve(body) } as Response;
}
Expand Down Expand Up @@ -1478,4 +1483,117 @@ describe('Parser Warnings', () => {

expect(contactEmailWarning).toBeUndefined();
});

test('adds default-firebase-config warning for the default Firebase project on a custom host', async () => {
vi.stubEnv('VITE_STORAGE_ENGINE', 'firebase');
vi.stubEnv('VITE_FIREBASE_CONFIG', JSON.stringify({ projectId: 'revisit-utah' }));
vi.stubGlobal('window', { location: { hostname: 'study.example.com' } });

const result = await parseStudyConfig(JSON.stringify(buildContactEmailStudyConfig('researcher@university.edu')));

const defaultFirebaseWarning = result.warnings.find(
(warning) => warning.category === 'default-firebase-config',
);

expect(defaultFirebaseWarning).toBeDefined();
expect(defaultFirebaseWarning?.instancePath).toBe('environment/VITE_FIREBASE_CONFIG');
expect(defaultFirebaseWarning?.message).toContain('default Firebase project');
expect(defaultFirebaseWarning?.message).toContain('backend controlled by the study designer');
});

test('adds default-firebase-config warning when authDomain identifies the default Firebase project', async () => {
vi.stubEnv('VITE_STORAGE_ENGINE', 'firebase');
vi.stubEnv('VITE_FIREBASE_CONFIG', JSON.stringify({ authDomain: 'revisit-utah.firebaseapp.com' }));
vi.stubGlobal('window', { location: { hostname: 'study.example.com' } });

const result = await parseStudyConfig(JSON.stringify(buildContactEmailStudyConfig('researcher@university.edu')));

expect(result.warnings.some((warning) => warning.category === 'default-firebase-config')).toBe(true);
});

test('adds default-firebase-config warning when storageBucket identifies the default Firebase project', async () => {
vi.stubEnv('VITE_STORAGE_ENGINE', 'firebase');
vi.stubEnv('VITE_FIREBASE_CONFIG', JSON.stringify({ storageBucket: 'revisit-utah.appspot.com' }));
vi.stubGlobal('window', { location: { hostname: 'study.example.com' } });

const result = await parseStudyConfig(JSON.stringify(buildContactEmailStudyConfig('researcher@university.edu')));

expect(result.warnings.some((warning) => warning.category === 'default-firebase-config')).toBe(true);
});

test('does not add default-firebase-config warning for a custom Firebase project', async () => {
vi.stubEnv('VITE_STORAGE_ENGINE', 'firebase');
vi.stubEnv('VITE_FIREBASE_CONFIG', JSON.stringify({ projectId: 'research-owned-project' }));
vi.stubGlobal('window', { location: { hostname: 'study.example.com' } });

const result = await parseStudyConfig(JSON.stringify(buildContactEmailStudyConfig('researcher@university.edu')));

expect(result.warnings.some((warning) => warning.category === 'default-firebase-config')).toBe(false);
});

test.each([
['supabase'],
['localStorage'],
])('does not add default-firebase-config warning when storage engine is %s', async (storageEngine) => {
vi.stubEnv('VITE_STORAGE_ENGINE', storageEngine);
vi.stubEnv('VITE_FIREBASE_CONFIG', JSON.stringify({ projectId: 'revisit-utah' }));
vi.stubGlobal('window', { location: { hostname: 'study.example.com' } });

const result = await parseStudyConfig(JSON.stringify(buildContactEmailStudyConfig('researcher@university.edu')));

expect(result.warnings.some((warning) => warning.category === 'default-firebase-config')).toBe(false);
});

test.each([
['localhost'],
['revisit.dev'],
['study.revisit.dev'],
['vdl.sci.utah.edu'],
['study.vdl.sci.utah.edu'],
])('does not add default-firebase-config warning on %s', async (hostname) => {
vi.stubEnv('VITE_STORAGE_ENGINE', 'firebase');
vi.stubEnv('VITE_FIREBASE_CONFIG', JSON.stringify({ projectId: 'revisit-utah' }));
vi.stubGlobal('window', { location: { hostname } });

const result = await parseStudyConfig(JSON.stringify(buildContactEmailStudyConfig('researcher@university.edu')));

expect(result.warnings.some((warning) => warning.category === 'default-firebase-config')).toBe(false);
});

test('adds default-supabase-config warning when Supabase URL is a revisit.dev domain on a custom host', async () => {
vi.stubEnv('VITE_STORAGE_ENGINE', 'supabase');
vi.stubEnv('VITE_SUPABASE_URL', 'https://supabase.revisit.dev');
vi.stubGlobal('window', { location: { hostname: 'study.example.com' } });

const result = await parseStudyConfig(JSON.stringify(buildContactEmailStudyConfig('researcher@university.edu')));

const defaultSupabaseWarning = result.warnings.find(
(warning) => warning.category === 'default-supabase-config',
);

expect(defaultSupabaseWarning).toBeDefined();
expect(defaultSupabaseWarning?.instancePath).toBe('environment/VITE_SUPABASE_URL');
expect(defaultSupabaseWarning?.message).toContain('default Supabase project');
expect(defaultSupabaseWarning?.message).toContain('backend controlled by the study designer');
});

test('does not add default-supabase-config warning for a custom Supabase URL', async () => {
vi.stubEnv('VITE_STORAGE_ENGINE', 'supabase');
vi.stubEnv('VITE_SUPABASE_URL', 'https://research-project.supabase.co');
vi.stubGlobal('window', { location: { hostname: 'study.example.com' } });

const result = await parseStudyConfig(JSON.stringify(buildContactEmailStudyConfig('researcher@university.edu')));

expect(result.warnings.some((warning) => warning.category === 'default-supabase-config')).toBe(false);
});

test('does not add default-supabase-config warning on local or ReVISit-controlled hosts', async () => {
vi.stubEnv('VITE_STORAGE_ENGINE', 'supabase');
vi.stubEnv('VITE_SUPABASE_URL', 'https://supabase.revisit.dev');
vi.stubGlobal('window', { location: { hostname: 'revisit.dev' } });

const result = await parseStudyConfig(JSON.stringify(buildContactEmailStudyConfig('researcher@university.edu')));

expect(result.warnings.some((warning) => warning.category === 'default-supabase-config')).toBe(false);
});
});
2 changes: 1 addition & 1 deletion src/parser/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1917,7 +1917,7 @@ export interface LibraryConfig {
baseComponents?: BaseComponents;
}

export type ErrorWarningCategory = 'invalid-config' | 'invalid-library-config' | 'undefined-library' | 'undefined-base-component' | 'undefined-component' | 'sequence-validation' | 'skip-validation' | 'unused-component' | 'disabled-sidebar' | 'default-contact-email';
export type ErrorWarningCategory = 'invalid-config' | 'invalid-library-config' | 'undefined-library' | 'undefined-base-component' | 'undefined-component' | 'sequence-validation' | 'skip-validation' | 'unused-component' | 'disabled-sidebar' | 'default-contact-email' | 'default-firebase-config' | 'default-supabase-config';

/**
* @ignore
Expand Down
Loading
Loading