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
5 changes: 5 additions & 0 deletions packages/react-router/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down
162 changes: 101 additions & 61 deletions packages/react-router/src/createAccessRouter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,44 @@ function buildRedirect(
return defaultPath
}

function getGuardRedirect<TUser>(
ctx: RouterGuardContext<TUser>,
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<TUser>(
contexts: Array<RouterGuardContext<TUser>>,
currentPath: string
): string | null {
return contexts.reduce<string | null>(
(resolvedRedirect, ctx) => resolvedRedirect ?? getGuardRedirect(ctx, currentPath),
null
)
}

function GuardedElement<TUser>({
access,
roles,
Expand All @@ -76,30 +114,21 @@ function GuardedElement<TUser>({
}: GuardedElementProps<TUser>) {
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 <Navigate to={defaultPath} replace />
if (RouteComponent) return <RouteComponent />
if (routeElement !== undefined && routeElement !== null) return routeElement
return <Outlet />
}

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 <Navigate to={redirectTo} replace />
}
shouldAddCallbackUrl,
},
currentPath
)

if (redirectTo) return <Navigate to={redirectTo} replace />

if (RouteComponent) return <RouteComponent />
if (routeElement !== undefined && routeElement !== null) return routeElement
Expand Down Expand Up @@ -127,33 +156,19 @@ function wrapGuardedElement<TUser>(ctx: RouterGuardContext<TUser>, element?: Rea

function wrapDataFunction<TUser, TArgs extends { request: Request }, TResult>(
handler: ((args: TArgs) => TResult) | boolean | undefined,
ctx: RouterGuardContext<TUser>
guardChain: Array<RouterGuardContext<TUser>>
) {
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
}

Expand All @@ -170,7 +185,8 @@ async function resolveLazyObject(lazyObject: LazyRouteLoader) {

function wrapLazyRoute<TUser>(
lazy: ProtectedRouteObject<TUser>['lazy'],
ctx: RouterGuardContext<TUser>
guardChain: Array<RouterGuardContext<TUser>>,
ctx?: RouterGuardContext<TUser>
): RouteObject['lazy'] | undefined {
if (!lazy) return undefined

Expand All @@ -189,14 +205,21 @@ function wrapLazyRoute<TUser>(
| undefined

if (!resolvedRoute) {
if (!ctx) return {}
return ctx.hasStaticUi ? {} : { element: wrapGuardedElement(ctx) }
}

const { element, Component, loader, action, ...routeProps } = resolvedRoute
const wrappedRoute: Record<string, unknown> = {
...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) {
Expand All @@ -223,7 +246,10 @@ export function createAccessRouter<TUser = unknown>(

const guard = createGuard(guardOptions)

const transform = (inputRoutes: Array<ProtectedRouteObject<TUser>>): Array<RouteObject> =>
const transform = (
inputRoutes: Array<ProtectedRouteObject<TUser>>,
inheritedGuardChain: Array<RouterGuardContext<TUser>> = []
): Array<RouteObject> =>
inputRoutes.map((route) => {
const {
access, roles, permissions, meta,
Expand All @@ -237,38 +263,52 @@ export function createAccessRouter<TUser = unknown>(
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<TUser> = {
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
})

Expand Down
2 changes: 2 additions & 0 deletions packages/react-router/src/testing.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export type { MockAccessProviderProps } from '@react-protected/react/testing'
export { MockAccessProvider } from '@react-protected/react/testing'
3 changes: 3 additions & 0 deletions packages/react-router/tests/build.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')),
])
})
})
92 changes: 92 additions & 0 deletions packages/react-router/tests/create-guarded-router.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: <div>private child</div>,
},
],
},
{ path: '/login', element: <div>login page</div> },
],
{ getUser: () => null },
['/private']
)

render(<RouterProvider router={router} />)
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<RouteObject> | undefined
Expand Down Expand Up @@ -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<RouteObject> | undefined

vi.resetModules()
globalThis.Request = NativeRequest

vi.doMock('react-router-dom', async () => {
const actual = await vi.importActual<typeof import('react-router-dom')>('react-router-dom')
return {
...actual,
createBrowserRouter: (guardedRoutes: Array<RouteObject>) => {
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: <div>private child</div>,
},
],
},
],
{ 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<RouteObject> | undefined

Expand Down
16 changes: 13 additions & 3 deletions packages/react-router/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
],
},
},
})
Loading
Loading