From 56abfc404a187357d69dbe86784d737d1d861526 Mon Sep 17 00:00:00 2001 From: Yehor Kharchenko Date: Fri, 20 Mar 2026 13:17:01 +0100 Subject: [PATCH] add wildcard access for dynamic routes --- contributingGuides/NAVIGATION.md | 20 +++++++++++- src/ROUTES.ts | 2 +- .../Navigation/helpers/getStateFromPath.ts | 4 +-- tests/navigation/getStateFromPathTests.ts | 31 +++++++++++++++++++ 4 files changed, 53 insertions(+), 4 deletions(-) diff --git a/contributingGuides/NAVIGATION.md b/contributingGuides/NAVIGATION.md index 40fbc3dfcd45..6f7098d82139 100644 --- a/contributingGuides/NAVIGATION.md +++ b/contributingGuides/NAVIGATION.md @@ -710,7 +710,7 @@ Do not use dynamic routes when: `DYNAMIC_ROUTES` in `src/ROUTES.ts`: each entry has: - `path`: The URL suffix (e.g. `'verify-account'`). -- `entryScreens`: List of screen names that are allowed to have this suffix appended (access control; see [Entry Screens (Access Control)](#entry-screens-access-control)). +- `entryScreens`: List of screen names that are allowed to have this suffix appended (access control; see [Entry Screens (Access Control)](#entry-screens-access-control)). Use `['*']` to allow all screens. `createDynamicRoute(suffix)` — [`createDynamicRoute.ts`](src/libs/Navigation/helpers/createDynamicRoute.ts). Accepts a `DynamicRouteSuffix` (from `DYNAMIC_ROUTES`), appends it to the current active route and returns the full route. Use the following when navigating to a dynamic route: @@ -731,6 +731,24 @@ When parsing a URL, `src/libs/Navigation/helpers/getStateFromPath.ts` resolves t When adding or extending a dynamic route, list every screen that should be able to open it (e.g. `SCREENS.SETTINGS.WALLET.ROOT` for Verify Account from Wallet). +#### Wildcard access (`'*'`) + +Setting `entryScreens` to `['*']` grants access to the dynamic route from any screen. This bypasses per-screen authorization entirely for that route. + +```ts +KEYBOARD_SHORTCUTS: { + path: 'keyboard-shortcuts', + entryScreens: ['*'], +}, +``` + +> [!CAUTION] +> **Use `'*'` only when the dynamic route genuinely needs to be reachable from every screen.** +> If only a subset of screens should access the route, list them explicitly. +> Overusing `'*'` weakens the access control that `entryScreens` provides +> and makes it harder to reason about which screens can trigger a given flow. +> When in doubt, prefer an explicit list. + ### Current limitations (work in progress) - **Path parameters:** Suffixes must not include path params (e.g. `a/:reportID`). Query parameters are supported - see [Dynamic routes with query parameters](#dynamic-routes-with-query-parameters). diff --git a/src/ROUTES.ts b/src/ROUTES.ts index e4c93c0e366a..cfe76fe43fb8 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -55,7 +55,7 @@ const VERIFY_ACCOUNT = 'verify-account'; type DynamicRouteConfig = { path: string; - entryScreens: Screen[]; + entryScreens: ReadonlyArray; getRoute?: (...args: never[]) => string; queryParams?: readonly string[]; }; diff --git a/src/libs/Navigation/helpers/getStateFromPath.ts b/src/libs/Navigation/helpers/getStateFromPath.ts index 2c4d7a11dcce..ceb2d50c1247 100644 --- a/src/libs/Navigation/helpers/getStateFromPath.ts +++ b/src/libs/Navigation/helpers/getStateFromPath.ts @@ -33,11 +33,11 @@ function getStateFromPath(path: Route): PartialState { // Get the currently focused route from the base path to check permissions const focusedRoute = findFocusedRoute(getStateFromPath(pathWithoutDynamicSuffix) ?? {}); - const entryScreens: Screen[] = DYNAMIC_ROUTES[dynamicRoute as DynamicRouteKey]?.entryScreens ?? []; + const entryScreens: ReadonlyArray = DYNAMIC_ROUTES[dynamicRoute as DynamicRouteKey]?.entryScreens ?? []; // Check if the focused route is allowed to access this dynamic route if (focusedRoute?.name) { - if (entryScreens.includes(focusedRoute.name as Screen)) { + if (entryScreens.some((s) => s === '*' || s === focusedRoute.name)) { // Generate navigation state for the dynamic route const dynamicRouteState = getStateForDynamicRoute(normalizedPath, dynamicRoute as DynamicRouteKey, focusedRoute?.params as Record | undefined); return dynamicRouteState; diff --git a/tests/navigation/getStateFromPathTests.ts b/tests/navigation/getStateFromPathTests.ts index 39548f069a16..3c5498899f35 100644 --- a/tests/navigation/getStateFromPathTests.ts +++ b/tests/navigation/getStateFromPathTests.ts @@ -41,6 +41,10 @@ jest.mock('@src/ROUTES', () => ({ path: 'suffix-b-from-multi', entryScreens: ['DynamicMultiSegScreen'], }, + WILDCARD_SUFFIX: { + path: 'wildcard-suffix', + entryScreens: ['*'], + }, }, })); @@ -59,6 +63,7 @@ describe('getStateFromPath', () => { const dynamicSuffixBState = {routes: [{name: 'DynamicSuffixBScreen'}]}; const dynamicMultiSegState = {routes: [{name: 'DynamicMultiSegScreen'}]}; const dynamicMultiSegLayerState = {routes: [{name: 'DynamicMultiSegLayerScreen'}]}; + const dynamicWildcardState = {routes: [{name: 'DynamicWildcardScreen'}]}; const focusedRouteParams = {baseParam: '123'}; beforeEach(() => { @@ -77,6 +82,9 @@ describe('getStateFromPath', () => { if (dynamicRouteKey === 'MULTI_SEG_LAYER') { return dynamicMultiSegLayerState; } + if (dynamicRouteKey === 'WILDCARD_SUFFIX') { + return dynamicWildcardState; + } return {routes: [{name: 'UnknownDynamic'}]}; }); mockFindFocusedRoute.mockImplementation((state: unknown) => { @@ -170,4 +178,27 @@ describe('getStateFromPath', () => { expect(mockGetStateForDynamicRoute).toHaveBeenCalledWith(fullPath, 'MULTI_SEG_LAYER', focusedRouteParams); }); }); + + describe('wildcard entryScreens', () => { + it('should authorize any focused screen when entryScreens contains wildcard', () => { + const fullPath = '/base/wildcard-suffix'; + + const result = getStateFromPath(fullPath as unknown as Route); + + expect(result).toBe(dynamicWildcardState); + expect(mockGetStateForDynamicRoute).toHaveBeenCalledWith(fullPath, 'WILDCARD_SUFFIX', focusedRouteParams); + expect(mockLogWarn).not.toHaveBeenCalled(); + }); + + it('should authorize wildcard in a layered scenario where the inner screen is not explicitly listed', () => { + const fullPath = '/base/suffix-a/wildcard-suffix'; + + const result = getStateFromPath(fullPath as unknown as Route); + + expect(result).toBe(dynamicWildcardState); + expect(mockGetStateForDynamicRoute).toHaveBeenCalledWith('/base/suffix-a', 'SUFFIX_A', focusedRouteParams); + expect(mockGetStateForDynamicRoute).toHaveBeenCalledWith(fullPath, 'WILDCARD_SUFFIX', focusedRouteParams); + expect(mockLogWarn).not.toHaveBeenCalled(); + }); + }); });