diff --git a/packages/react-router/package.json b/packages/react-router/package.json index 2660455..0e72dd7 100644 --- a/packages/react-router/package.json +++ b/packages/react-router/package.json @@ -19,6 +19,11 @@ "import": "./dist/index.js", "require": "./dist/index.cjs", "types": "./dist/index.d.ts" + }, + "./testing": { + "import": "./dist/testing.js", + "require": "./dist/testing.cjs", + "types": "./dist/testing.d.ts" } }, "files": [ diff --git a/packages/react-router/src/createAccessRouter.tsx b/packages/react-router/src/createAccessRouter.tsx index 9f16cf8..981499f 100644 --- a/packages/react-router/src/createAccessRouter.tsx +++ b/packages/react-router/src/createAccessRouter.tsx @@ -60,6 +60,44 @@ function buildRedirect( return defaultPath } +function getGuardRedirect( + ctx: RouterGuardContext, + currentPath: string +): string | null { + if (ctx.protection.access === 'guest-only') { + const user = ctx.guard.options.getUser() + const isAuth = ctx.guard.options.isAuthenticated(user) + + return isAuth ? ctx.defaultPath : null + } + + const result = ctx.guard.check(ctx.protection as AccessConfig) + + if (!result.allowed) { + return buildRedirect( + result.reason, + currentPath, + ctx.loginPath, + ctx.forbiddenPath, + ctx.defaultPath, + ctx.callbackUrlParam, + ctx.shouldAddCallbackUrl + ) + } + + return null +} + +function getFirstGuardRedirect( + contexts: Array>, + currentPath: string +): string | null { + return contexts.reduce( + (resolvedRedirect, ctx) => resolvedRedirect ?? getGuardRedirect(ctx, currentPath), + null + ) +} + function GuardedElement({ access, roles, @@ -76,30 +114,21 @@ function GuardedElement({ }: GuardedElementProps) { const location = useLocation() const currentPath = `${location.pathname}${location.search}${location.hash}` - - if (access === 'guest-only') { - const user = guard.options.getUser() - const isAuth = guard.options.isAuthenticated(user) - if (isAuth) return - if (RouteComponent) return - if (routeElement !== undefined && routeElement !== null) return routeElement - return - } - - const result = guard.check({ access, roles, permissions, meta }) - - if (!result.allowed) { - const redirectTo = buildRedirect( - result.reason, - currentPath, + const redirectTo = getGuardRedirect( + { + guard, + protection: { access, roles, permissions, meta }, + hasStaticUi: Boolean(RouteComponent) || (routeElement !== undefined && routeElement !== null), loginPath, forbiddenPath, defaultPath, callbackUrlParam, - shouldAddCallbackUrl - ) - return - } + shouldAddCallbackUrl, + }, + currentPath + ) + + if (redirectTo) return if (RouteComponent) return if (routeElement !== undefined && routeElement !== null) return routeElement @@ -127,33 +156,19 @@ function wrapGuardedElement(ctx: RouterGuardContext, element?: Rea function wrapDataFunction( handler: ((args: TArgs) => TResult) | boolean | undefined, - ctx: RouterGuardContext + guardChain: Array> ) { - if (handler === undefined || typeof handler === 'boolean') return handler + if (handler === undefined || typeof handler === 'boolean' || guardChain.length === 0) { + return handler + } return ((args: TArgs) => { const url = new URL(args.request.url) const currentPath = `${url.pathname}${url.search}` - if (ctx.protection.access === 'guest-only') { - const user = ctx.guard.options.getUser() - const isAuth = ctx.guard.options.isAuthenticated(user) - if (isAuth) return redirect(ctx.defaultPath) as TResult - return handler(args) - } + const redirectTo = getFirstGuardRedirect(guardChain, currentPath) - const result = ctx.guard.check(ctx.protection as AccessConfig) - - if (!result.allowed) { - const redirectTo = buildRedirect( - result.reason, - currentPath, - ctx.loginPath, - ctx.forbiddenPath, - ctx.defaultPath, - ctx.callbackUrlParam, - ctx.shouldAddCallbackUrl - ) + if (redirectTo) { return redirect(redirectTo) as TResult } @@ -170,7 +185,8 @@ async function resolveLazyObject(lazyObject: LazyRouteLoader) { function wrapLazyRoute( lazy: ProtectedRouteObject['lazy'], - ctx: RouterGuardContext + guardChain: Array>, + ctx?: RouterGuardContext ): RouteObject['lazy'] | undefined { if (!lazy) return undefined @@ -189,14 +205,21 @@ function wrapLazyRoute( | undefined if (!resolvedRoute) { + if (!ctx) return {} return ctx.hasStaticUi ? {} : { element: wrapGuardedElement(ctx) } } const { element, Component, loader, action, ...routeProps } = resolvedRoute const wrappedRoute: Record = { ...routeProps, - loader: wrapDataFunction(loader, ctx), - action: wrapDataFunction(action, ctx), + loader: wrapDataFunction(loader, guardChain), + action: wrapDataFunction(action, guardChain), + } + + if (!ctx) { + wrappedRoute.element = element + wrappedRoute.Component = Component + return wrappedRoute } if (!ctx.hasStaticUi) { @@ -223,7 +246,10 @@ export function createAccessRouter( const guard = createGuard(guardOptions) - const transform = (inputRoutes: Array>): Array => + const transform = ( + inputRoutes: Array>, + inheritedGuardChain: Array> = [] + ): Array => inputRoutes.map((route) => { const { access, roles, permissions, meta, @@ -237,38 +263,52 @@ export function createAccessRouter( Boolean(permissions?.length) || meta !== undefined + const ownGuardContext = hasGuardConfig + ? { + guard, + protection: { access, roles, permissions, meta }, + hasStaticUi: (element !== undefined && element !== null) || Component != null, + loginPath, + forbiddenPath, + defaultPath, + callbackUrlParam, + shouldAddCallbackUrl, + } + : undefined + + const guardChain = ownGuardContext + ? [...inheritedGuardChain, ownGuardContext] + : inheritedGuardChain + if (!hasGuardConfig) { return { ...routeProps, - element, Component, loader, action, lazy, - children: children ? transform(children) : undefined, + element, + Component, + loader: wrapDataFunction(loader, guardChain), + action: wrapDataFunction(action, guardChain), + lazy: wrapLazyRoute(lazy, guardChain), + children: children ? transform(children, guardChain) : undefined, } as RouteObject } - const ctx: RouterGuardContext = { - guard, - protection: { access, roles, permissions, meta }, - hasStaticUi: (element !== undefined && element !== null) || Component != null, - loginPath, - forbiddenPath, - defaultPath, - callbackUrlParam, - shouldAddCallbackUrl, + if (!ownGuardContext) { + throw new Error('Guard context must exist for guarded routes') } const guardedElement = - ctx.hasStaticUi || !lazy - ? wrapGuardedElement(ctx, element, Component ?? null) + ownGuardContext.hasStaticUi || !lazy + ? wrapGuardedElement(ownGuardContext, element, Component ?? null) : undefined return { ...routeProps, element: guardedElement, Component: undefined, - loader: wrapDataFunction(loader, ctx), - action: wrapDataFunction(action, ctx), - lazy: wrapLazyRoute(lazy, ctx), - children: children ? transform(children) : undefined, + loader: wrapDataFunction(loader, guardChain), + action: wrapDataFunction(action, guardChain), + lazy: wrapLazyRoute(lazy, guardChain, ownGuardContext), + children: children ? transform(children, guardChain) : undefined, } as RouteObject }) diff --git a/packages/react-router/src/testing.ts b/packages/react-router/src/testing.ts new file mode 100644 index 0000000..84076a8 --- /dev/null +++ b/packages/react-router/src/testing.ts @@ -0,0 +1,2 @@ +export type { MockAccessProviderProps } from '@react-protected/react/testing' +export { MockAccessProvider } from '@react-protected/react/testing' diff --git a/packages/react-router/tests/build.test.ts b/packages/react-router/tests/build.test.ts index b426eb5..c112aa2 100644 --- a/packages/react-router/tests/build.test.ts +++ b/packages/react-router/tests/build.test.ts @@ -21,6 +21,9 @@ describe('package build', () => { access(join(distDir, 'index.js')), access(join(distDir, 'index.cjs')), access(join(distDir, 'index.d.ts')), + access(join(distDir, 'testing.js')), + access(join(distDir, 'testing.cjs')), + access(join(distDir, 'testing.d.ts')), ]) }) }) diff --git a/packages/react-router/tests/create-guarded-router.test.tsx b/packages/react-router/tests/create-guarded-router.test.tsx index 96022b6..8e97905 100644 --- a/packages/react-router/tests/create-guarded-router.test.tsx +++ b/packages/react-router/tests/create-guarded-router.test.tsx @@ -119,6 +119,38 @@ describe('createAccessRouter', () => { router.dispose() }) + it('does not execute nested child loader when parent access is denied', async () => { + let childLoaderCalls = 0 + + const router = await createAccessMemoryRouter( + [ + { + path: '/private', + access: 'authenticated', + element: null, + children: [ + { + index: true, + loader: () => { + childLoaderCalls += 1 + return null + }, + element:
private child
, + }, + ], + }, + { path: '/login', element:
login page
}, + ], + { getUser: () => null }, + ['/private'] + ) + + render() + expect(await screen.findByText('login page')).toBeTruthy() + expect(childLoaderCalls).toBe(0) + router.dispose() + }) + it('does not execute action when access is denied, redirects to loginPath', async () => { let actionCalls = 0 let capturedRoutes: Array | undefined @@ -170,6 +202,66 @@ describe('createAccessRouter', () => { expect(result.headers.get('Location')).toBe('/login') }) + it('does not execute nested child action when parent access is denied', async () => { + let childActionCalls = 0 + let capturedRoutes: Array | undefined + + vi.resetModules() + globalThis.Request = NativeRequest + + vi.doMock('react-router-dom', async () => { + const actual = await vi.importActual('react-router-dom') + return { + ...actual, + createBrowserRouter: (guardedRoutes: Array) => { + capturedRoutes = guardedRoutes + return { mocked: true } + }, + } + }) + + const { createAccessRouter } = await import('../src/createAccessRouter') + + createAccessRouter( + [ + { + path: '/private', + access: 'authenticated', + element: null, + children: [ + { + index: true, + action: async () => { + childActionCalls += 1 + return null + }, + element:
private child
, + }, + ], + }, + ], + { getUser: () => null } + ) + + vi.doUnmock('react-router-dom') + + const action = capturedRoutes?.[0]?.children?.[0]?.action + expect(action).toBeTypeOf('function') + if (typeof action !== 'function') throw new Error('Expected action to be function') + + const result = await action({ + request: new Request('https://example.test/private', { method: 'POST' }), + params: {}, + context: undefined, + } as ActionFunctionArgs) + + expect(childActionCalls).toBe(0) + expect(result instanceof Response).toBe(true) + if (!(result instanceof Response)) throw new Error('Expected Response') + expect(result.status).toBe(302) + expect(result.headers.get('Location')).toBe('/login') + }) + it('omits callbackUrl in action redirect when shouldAddCallbackUrl returns false', async () => { let capturedRoutes: Array | undefined diff --git a/packages/react-router/vite.config.ts b/packages/react-router/vite.config.ts index 8c972bc..6642ef4 100644 --- a/packages/react-router/vite.config.ts +++ b/packages/react-router/vite.config.ts @@ -19,12 +19,22 @@ export default defineConfig({ ], build: { lib: { - entry: resolve(packageRoot, 'src/index.ts'), + entry: { + index: resolve(packageRoot, 'src/index.ts'), + testing: resolve(packageRoot, 'src/testing.ts'), + }, formats: ['es', 'cjs'], - fileName: (format) => (format === 'es' ? 'index.js' : 'index.cjs'), + fileName: (format, entryName) => `${entryName}.${format === 'es' ? 'js' : 'cjs'}`, }, rollupOptions: { - external: ['react', 'react-dom', 'react-router-dom', '@react-protected/core', '@react-protected/react'], + external: [ + 'react', + 'react-dom', + 'react-router-dom', + '@react-protected/core', + '@react-protected/react', + '@react-protected/react/testing', + ], }, }, }) diff --git a/tsconfig.json b/tsconfig.json index 076598e..4fbed17 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -13,7 +13,9 @@ "paths": { "@react-protected/core": ["packages/core/src/index.ts"], "@react-protected/react": ["packages/react/src/index.ts"], - "@react-protected/react-router": ["packages/react-router/src/index.ts"] + "@react-protected/react/testing": ["packages/react/src/testing.tsx"], + "@react-protected/react-router": ["packages/react-router/src/index.ts"], + "@react-protected/react-router/testing": ["packages/react-router/src/testing.ts"] } }, "include": ["packages/*/src/**/*.ts", "packages/*/src/**/*.tsx"],