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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,9 @@ storybook-static/
.temp
.tmp

# Archived approaches (kept for reference)
app/src/_archived/

# Python
__pycache__/
*.py[cod]
Expand Down
2 changes: 1 addition & 1 deletion app/eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import tseslint from 'typescript-eslint';

export default tseslint.config(
...mantine,
{ ignores: ['**/*.{mjs,cjs,js,d.ts,d.mts}', './.storybook/main.ts'] },
{ ignores: ['**/*.{mjs,cjs,js,d.ts,d.mts}', './.storybook/main.ts', 'src/_archived/**'] },
{
files: ['**/*.story.tsx'],
rules: { 'no-console': 'off' },
Expand Down
17 changes: 15 additions & 2 deletions app/src/Router.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,28 @@
import { createBrowserRouter, RouterProvider } from 'react-router-dom';
import { createBrowserRouter, Navigate, RouterProvider } from 'react-router-dom';
import Layout from './components/Layout';
import HomePage from './pages/Home.page';
import PoliciesPage from './pages/Policies.page';
import PopulationsPage from './pages/Populations.page';
import SimulationsPage from './pages/Simulations.page';
import { CountryGuard } from './routing/guards/CountryGuard';

const router = createBrowserRouter(
[
{
path: '/',
element: <Layout />,
// TODO: Replace with dynamic default country based on user location/preferences
element: <Navigate to="/us" replace />,
},
{
path: '/:countryId',
// CountryGuard wraps Layout directly instead of using Outlet pattern.
// This keeps the structure simple - guard is just a validation wrapper,
// not a route layer. Avoids extra nesting in route config.
element: (
<CountryGuard>
<Layout />
</CountryGuard>
),
children: [
{
index: true,
Expand Down
21 changes: 13 additions & 8 deletions app/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
} from '@tabler/icons-react';
import { useLocation } from 'react-router-dom';
import { Box, Button, Divider, Stack, Text } from '@mantine/core';
import { useCurrentCountry } from '@/hooks/useCurrentCountry';
import { colors, spacing, typography } from '../designTokens';
import SidebarDivider from './sidebar/SidebarDivider';
import SidebarNavItem from './sidebar/SidebarNavItem';
Expand All @@ -22,26 +23,30 @@ interface SidebarProps {

export default function Sidebar({ isOpen = true }: SidebarProps) {
const location = useLocation();
const countryId = useCurrentCountry();

// All internal navigation paths include the country prefix for consistency with v1 app
const navItems = [
{ label: 'Home', icon: IconHome, path: '/' },
{ label: 'Reports', icon: IconFileDescription, path: '/reports' },
{ label: 'Simulations', icon: IconGitBranch, path: '/simulations' },
{ label: 'Configurations', icon: IconSettings2, path: '/configurations' },
{ label: 'Home', icon: IconHome, path: `/${countryId}` },
{ label: 'Reports', icon: IconFileDescription, path: `/${countryId}/reports` },
{ label: 'Simulations', icon: IconGitBranch, path: `/${countryId}/simulations` },
{ label: 'Configurations', icon: IconSettings2, path: `/${countryId}/configurations` },
];

const policyItems = [{ label: 'Populations', icon: IconUsers, path: '/populations' }];
const policyItems = [
{ label: 'Populations', icon: IconUsers, path: `/${countryId}/populations` },
];

const resourceItems = [
{ label: 'GitHub', icon: IconGitBranch, path: 'https://github.com', external: true },
{ label: 'Join Slack', icon: IconExternalLink, path: 'https://slack.com', external: true },
{ label: 'Visit Blog', icon: IconBook, path: 'https://blog.example.com', external: true },
{ label: 'Methodology', icon: IconFileDescription, path: '/methodology' },
{ label: 'Methodology', icon: IconFileDescription, path: `/${countryId}/methodology` },
];

const accountItems = [
{ label: 'Account Settings', icon: IconSettings2, path: '/account' },
{ label: 'Contact Support', icon: IconExternalLink, path: '/support' },
{ label: 'Account Settings', icon: IconSettings2, path: `/${countryId}/account` },
{ label: 'Contact Support', icon: IconExternalLink, path: `/${countryId}/support` },
];

if (!isOpen) {
Expand Down
26 changes: 0 additions & 26 deletions app/src/hooks/useCountryFromURL.ts

This file was deleted.

22 changes: 22 additions & 0 deletions app/src/hooks/useCurrentCountry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { useParams } from 'react-router-dom';
import { countryIds } from '@/libs/countries';

/**
* Returns the current country ID from the URL path parameter.
*
* For routes with :countryId param: Returns the country from URL (already validated by loader)
* For routes without :countryId param: Returns 'us' as fallback with a warning
*
* @returns The country ID from URL or default fallback
*/
export function useCurrentCountry(): (typeof countryIds)[number] {
const { countryId } = useParams<{ countryId: string }>();

if (!countryId) {
console.warn('useCurrentCountry used in non-country route, returning default country');
// TODO: Replace with dynamic default country based on user location/preferences
return 'us';
}

return countryId as (typeof countryIds)[number];
}
12 changes: 4 additions & 8 deletions app/src/hooks/useMetadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,27 +4,23 @@ import { fetchMetadataThunk } from '@/reducers/metadataReducer';
import { AppDispatch, RootState } from '@/store';

/**
* Hook that ensures metadata is fetched for the specified country.
* Hook that ensures metadata is fetched for the current country.
*
* This hook triggers a metadata fetch when:
* - Component mounts and no metadata exists
* - countryId parameter changes (e.g., when URL route changes)
* - Current country in state differs from requested country
*
* Components should use useSelector to read metadata from Redux state
*
* @param countryId - Country code to fetch metadata for (e.g., 'us', 'uk', 'ca')
*/
export function useFetchMetadata(countryId?: string): void {
export function useFetchMetadata(countryId: string): void {
const dispatch = useDispatch<AppDispatch>();
const metadata = useSelector((state: RootState) => state.metadata);

useEffect(() => {
const country = countryId || metadata.currentCountry || 'us';

// Only fetch if we don't already have metadata for this country
if (!metadata.version || country !== metadata.currentCountry) {
dispatch(fetchMetadataThunk(country));
if (!metadata.version || countryId !== metadata.currentCountry) {
dispatch(fetchMetadataThunk(countryId));
}
}, [countryId, metadata.currentCountry, metadata.version, dispatch]);
}
12 changes: 5 additions & 7 deletions app/src/pages/Home.page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,19 +9,17 @@ import { ReportCreationFlow } from '@/flows/reportCreationFlow';
import { ReportViewFlow } from '@/flows/reportViewFlow';
import { SimulationCreationFlow } from '@/flows/simulationCreationFlow';
import { SimulationViewFlow } from '@/flows/simulationViewFlow';
import { useCurrentCountry } from '@/hooks/useCurrentCountry';
import { useFetchMetadata } from '@/hooks/useMetadata';
import { clearFlow, setFlow } from '../reducers/flowReducer';

export default function HomePage() {
const dispatch = useDispatch();

// TODO: Replace with dynamic country from URL route parameter
// When routing is implemented, this will become:
// const { countryId } = useParams();
// This approach ensures metadata is fetched when:
// 1. Component mounts (initial load)
// 2. countryId changes (when user navigates between countries)
const countryId = 'us';
// Get current country from URL for UI formatting
const countryId = useCurrentCountry();

// Ensure metadata is fetched for current country
useFetchMetadata(countryId);

// Note: Below is for testing purposes only
Expand Down
38 changes: 38 additions & 0 deletions app/src/routing/guards/CountryGuard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { Navigate, useLocation, useParams } from 'react-router-dom';
import { countryIds } from '@/libs/countries';

interface CountryGuardProps {
children: React.ReactNode;
}

/**
* Guard component that validates country parameter in the route.
* Wraps the Layout component and redirects if country is invalid.
*/
export function CountryGuard({ children }: CountryGuardProps) {
const { countryId } = useParams<{ countryId: string }>();
const location = useLocation();

// Validation logic
const isValid = countryId && countryIds.includes(countryId as any);

if (!isValid) {
// Extract path after country segment to preserve user's intended destination.
// We can't use useParams for this - it only gives us { countryId }, not the rest.
// Route pattern /:countryId doesn't capture /policies/123 part.
// Using /:countryId/* would capture it but breaks child route matching.
// So we must use string manipulation on location.pathname.
const currentPath = location.pathname;
const pathAfterCountry = countryId
? currentPath.substring(countryId.length + 1) // Skip "/{country}"
: currentPath;
const defaultCountry = 'us';
const redirectPath = `/${defaultCountry}${pathAfterCountry}`;

return <Navigate to={redirectPath} replace />;
}

// Render children (Layout) when validation passes.
// Using {children} instead of <Outlet /> keeps this a simple wrapper component.
return <>{children}</>;
}
41 changes: 41 additions & 0 deletions app/src/tests/fixtures/hooks/useCurrentCountryMocks.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/**
* Test fixtures for useCurrentCountry hook tests
*
* Provides router wrapper and test constants for testing country extraction from URL
*/
import React from 'react';
import { MemoryRouter, Route, Routes } from 'react-router-dom';

// Test constants
export const TEST_COUNTRIES = {
US: 'us',
UK: 'uk',
CA: 'ca',
NG: 'ng',
IL: 'il',
} as const;

export const INVALID_COUNTRY = 'invalid-country';

// Test paths
export const TEST_PATHS = {
UK_POLICIES: '/uk/policies',
US_HOUSEHOLD: '/us/household',
CA_ABOUT: '/ca/about',
INVALID_COUNTRY_POLICIES: `/${INVALID_COUNTRY}/policies`,
ROOT: '/',
} as const;

// Wrapper component that provides router context
export function createRouterWrapper(initialPath: string) {
return function Wrapper({ children }: { children: React.ReactNode }) {
return (
<MemoryRouter initialEntries={[initialPath]}>
<Routes>
<Route path="/:countryId/*" element={children} />
<Route path="/*" element={children} />
</Routes>
</MemoryRouter>
);
};
}
108 changes: 108 additions & 0 deletions app/src/tests/fixtures/routing/guards/countryGuardMocks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
/**
* Test fixtures for CountryGuard component tests
*
* Includes edge cases, security test patterns, and malicious input examples
*/

// Valid countries
export const VALID_COUNTRIES = {
US: 'us',
UK: 'uk',
CA: 'ca',
NG: 'ng',
IL: 'il',
} as const;

// Invalid country examples
export const INVALID_COUNTRIES = {
SIMPLE: 'invalid',
NUMERIC: '123',
EMPTY: '',
UNDEFINED: undefined,
} as const;

// Security test patterns - testing guard's resilience
export const MALICIOUS_INPUTS = {
// SQL injection attempts
SQL_INJECTION: "'; DROP TABLE users; --",
SQL_UNION: "1' UNION SELECT * FROM users--",

// XSS attempts
XSS_SCRIPT: '<script>alert("xss")</script>',
XSS_IMG: '<img src=x onerror=alert("xss")>',
XSS_ENCODED: '%3Cscript%3Ealert(%22xss%22)%3C/script%3E',

// Path traversal attempts
PATH_TRAVERSAL: '../../../etc/passwd',
PATH_WINDOWS: '..\\..\\..\\windows\\system32',

// Special characters and garbage
SPECIAL_CHARS: '@#$%^&*()_+={}[]|\\:";\'<>?,.',
UNICODE: '🇬🇧😀مرحبا你好',
NULL_BYTE: 'us\x00.txt',

// Buffer overflow attempts
LONG_STRING: 'a'.repeat(10000),

// Command injection attempts
COMMAND_INJECTION: '; ls -la',
PIPE_COMMAND: '| cat /etc/passwd',
} as const;

// Test paths with various patterns
export const TEST_PATHS = {
// Valid paths
US_POLICIES: '/us/policies',
UK_HOUSEHOLD: '/uk/household/123',
CA_REPORTS: '/ca/reports/annual/2024',

// Invalid country paths
INVALID_SIMPLE: '/invalid/policies',
GARBAGE_PATH: `/${MALICIOUS_INPUTS.SPECIAL_CHARS}/policies`,
SQL_PATH: `/${MALICIOUS_INPUTS.SQL_INJECTION}/household`,
XSS_PATH: `/${MALICIOUS_INPUTS.XSS_SCRIPT}/reports`,
TRAVERSAL_PATH: `/${MALICIOUS_INPUTS.PATH_TRAVERSAL}/configurations`,
UNICODE_PATH: `/${MALICIOUS_INPUTS.UNICODE}/about`,
LONG_PATH: `/${MALICIOUS_INPUTS.LONG_STRING}/test`,

// Edge cases
ROOT: '/',
DOUBLE_SLASH: '//policies',
TRAILING_SLASH: '/us/policies/',
QUERY_PARAMS: '/invalid/policies?filter=active&sort=date',
HASH_FRAGMENT: '/invalid/policies#section',
} as const;

// Expected redirect paths
export const EXPECTED_REDIRECTS = {
DEFAULT_COUNTRY: 'us',

// Preserve paths after redirect
POLICIES: '/us/policies',
HOUSEHOLD: '/us/household/123',
REPORTS: '/us/reports/annual/2024',
ROOT_REDIRECT: '/us/',

// Complex path preservation
NESTED_PATH: '/us/reports/123/edit',
WITH_QUERY: '/us/policies?filter=active&sort=date',
WITH_HASH: '/us/policies#section',
} as const;

// Helper function to create mock Navigate tracking
export function createNavigateMock() {
const navigateCalls: Array<{ to: string; replace?: boolean }> = [];

const MockNavigate = ({ to, replace }: { to: string; replace?: boolean }) => {
navigateCalls.push({ to, replace });
return null;
};

return {
MockNavigate,
navigateCalls,
getLastCall: () => navigateCalls[navigateCalls.length - 1],
wasCalledWith: (to: string, replace = true) =>
navigateCalls.some((call) => call.to === to && call.replace === replace),
};
}
Loading
Loading