diff --git a/.changeset/tired-webs-cut.md b/.changeset/tired-webs-cut.md
new file mode 100644
index 0000000..cd51270
--- /dev/null
+++ b/.changeset/tired-webs-cut.md
@@ -0,0 +1,7 @@
+---
+'@react-protected/core': minor
+'@react-protected/react': minor
+'@react-protected/react-router': minor
+---
+
+loader, action and middleware support
diff --git a/README.md b/README.md
index 2d78af4..5d9a255 100644
--- a/README.md
+++ b/README.md
@@ -3,11 +3,11 @@
- Router-agnostic route protection for React applications.
+ Access decisions for React applications.
- RBAC, ABAC, guest-only routes and callbackUrl without re-implementing guards in every project.
+ RBAC, ABAC, authenticated and unauthenticated route checks without baking app-specific redirect policy into the library.
@@ -18,217 +18,111 @@
MIT License
-## Badges
-
-
-
-
-
-
-
-
-
## Features
-- Framework-agnostic core for pure access-control logic (no React, no router)
-- Intermediate React package with context, hooks, and UI guard component
-- React Router adapter for data routers (`createAccessRouter`) and JSX guards (`AccessRoute`)
-- RBAC via `hasRole`, ABAC via `hasPermission`, `guest-only` routes at the adapter level
-- Optional `callbackUrlParam` for redirecting users back to the page they tried to visit after login
+- Framework-agnostic core for pure access-control decisions
+- React package with `AccessProvider`, `useHasAccess`, and `HasAccess`
+- React Router helpers for middleware, loaders, and actions
+- Explicit denied handling via `onDenied`
+- No built-in `loginPath`, `forbiddenPath`, `defaultPath`, or callback URL policy
## Packages
-| Package | Description |
-| -------------------------------- | ------------------------------------------------------------------------ |
-| `@react-protected/core` | Pure access-control logic — no React, no router, no redirects |
-| `@react-protected/react` | React context (`AccessProvider`), hooks, and `HasAccess` component |
-| `@react-protected/react-router` | Adapter for React Router: `createAccessRouter` and `AccessRoute`. Includes everything from `@react-protected/react` |
-
-## Roadmap
-
-- Add a TanStack Router adapter
-- Add a Wouter adapter
-- Add a `guard` field to route config — a custom function called after all standard checks (auth, roles, permissions), for business logic that cannot be expressed as a role or permission set alone:
-
- ```ts
- // Redirect to profile setup if email is missing
- {
- path: '/dashboard',
- guard: ({ session }) => {
- if (!session?.user.email) return { redirect: '/profile/setup' }
- },
- }
-
- // Combine with standard permission check
- {
- path: '/reports',
- permissions: ['reports:read'],
- guard: ({ session }) => {
- if (session?.user.subscriptionExpired) return { redirect: '/subscription/expired' }
- },
- }
-
- // Route param ownership check
- {
- path: '/users/:userId/edit',
- guard: ({ session, params }) => {
- if (session?.user.role !== 'admin' && params.userId !== session?.user.id) return false
- },
- }
- ```
-
- Standard checks run first; if they produce a redirect, `guard` is not called. When all standard checks pass, `guard` runs and its result is the final decision (`true` / `false` / `undefined` to pass through / `{ redirect: string }`).
-
-## Installation
-
-For React Router projects, install the adapter (it includes `@react-protected/react`):
-
-```bash
-npm install @react-protected/react-router
-```
-
-```bash
-yarn add @react-protected/react-router
-```
-
-```bash
-pnpm add @react-protected/react-router
-```
-
-For React projects without React Router:
-
-```bash
-npm install @react-protected/react
-```
+| Package | Description |
+| --- | --- |
+| `@react-protected/core` | Pure access-control logic |
+| `@react-protected/react` | React context, hooks, and `HasAccess` |
+| `@react-protected/react-router` | React Router helpers and `AccessRoute` fallback |
## Quick Start
-### Data router (recommended)
+### Data router
```tsx
-// router.ts
-import { createAccessRouter } from '@react-protected/react-router'
-import { useAuthStore } from './entities/auth'
-
-export const router = createAccessRouter(
+import { createBrowserRouter, redirect } from 'react-router-dom'
+import {
+ createAccessLoader,
+ createAccessMiddleware,
+} from '@react-protected/react-router'
+
+const accessOptions = {
+ getUser: () => authStore.getState().user,
+ hasRole: (user, roles) => roles.some((role) => user.roles.includes(role)),
+ hasPermission: (user, permissions) =>
+ permissions.every((permission) => user.permissions.includes(permission)),
+ onDenied: ({ result, request }) => {
+ const url = new URL(request.url)
+
+ switch (result.reason) {
+ case 'unauthenticated':
+ return redirect(`/login?next=${encodeURIComponent(url.pathname + url.search)}`)
+ case 'authenticated':
+ return redirect('/dashboard')
+ case 'forbidden':
+ return redirect('/403')
+ }
+ },
+}
+
+const accessMiddleware = createAccessMiddleware(accessOptions)
+const accessLoader = createAccessLoader(accessOptions)
+
+export const router = createBrowserRouter(
[
- { path: '/', element: },
- { path: '/login', element: , access: 'guest-only' },
- { path: '/dashboard', element: , access: 'authenticated' },
- { path: '/admin', element: , access: 'authenticated', roles: ['admin'] },
- { path: '/403', element: },
+ {
+ path: '/login',
+ middleware: [accessMiddleware({ access: 'unauthenticated' })],
+ element: ,
+ },
+ {
+ path: '/dashboard',
+ middleware: [accessMiddleware({ access: 'authenticated' })],
+ element: ,
+ },
+ {
+ path: '/reports',
+ loader: accessLoader(
+ { access: 'authenticated', permissions: ['reports:read'] },
+ async () => fetch('/api/reports').then((response) => response.json())
+ ),
+ element: ,
+ },
+ { path: '/403', element: },
],
- {
- getUser: () => useAuthStore.getState().user,
- hasRole: (user, roles) => roles.some((role) => user.roles.includes(role)),
- loginPath: '/login',
- forbiddenPath: '/403',
- defaultPath: '/dashboard',
- callbackUrlParam: 'next',
- }
+ { future: { v8_middleware: true } }
)
-
-// App.tsx
-import { RouterProvider } from 'react-router-dom'
-export const App = () =>
```
-### JSX routes
+### Component-level access
```tsx
-import { Route, Routes } from 'react-router-dom'
-import { AccessProvider, AccessRoute } from '@react-protected/react-router'
-
-const App = () => (
- useAuthStore.getState().user}
- hasRole={(user, roles) => roles.some((role) => user.roles.includes(role))}
- loginPath="/login"
- forbiddenPath="/403"
- defaultPath="/dashboard"
- callbackUrlParam="next"
- >
-
- } />
-
-
-
- }
- />
-
-
-
- }
- />
-
-
-)
+import { AccessProvider, HasAccess } from '@react-protected/react-router'
+
+function App() {
+ return (
+ authStore.getState().user}
+ hasRole={(user, roles) => roles.some((role) => user.roles.includes(role))}
+ >
+
+ Delete tenant
+
+
+ )
+}
```
-### Guarding UI elements
+### `AccessRoute` fallback
```tsx
-import { HasAccess } from '@react-protected/react'
-
-const Toolbar = () => (
-
-
- Delete
-
-
-)
+ {
+ if (reason === 'unauthenticated') return
+ if (reason === 'authenticated') return
+ return
+ }}
+>
+
+
```
-
-## Documentation
-
-### Start Here
-
-- [Documentation index](./docs/en/README.md)
-
-### Core
-
-- [Core API](./docs/en/api/core.md)
-
-### React Router Adapter
-
-- [React Router adapter API](./docs/en/api/react-router.md)
-
-### Examples
-
-- [Basic auth and guest flow](./docs/en/examples/basic.md)
-- [RBAC example](./docs/en/examples/rbac.md)
-- [ABAC example](./docs/en/examples/abac.md)
-- [Using the core package without an adapter](./docs/en/examples/core-only.md)
-
-## Contributing
-
-The project aims to keep the API small, predictable, and easy to integrate.
-
-If you want to contribute:
-
-- open an issue with the use case or problem you are solving
-- discuss API changes before sending a large PR
-- run `pnpm changeset` when your PR changes a published package
-- run `pnpm lint`, `pnpm typecheck` and `pnpm test` before submitting changes
-
-## Changelog And Releases
-
-Releases are managed with Changesets.
-
-- add a changeset with `pnpm changeset` whenever a PR changes a published package
-- `pnpm version-packages` generates or updates `packages/*/CHANGELOG.md` and bumps package versions
-- pushing to `main` triggers [`.github/workflows/release.yml`](./.github/workflows/release.yml), which opens the release PR and publishes to npm
-
-The repository-level overview lives in [CHANGELOG.md](./CHANGELOG.md).
-
-See [CONTRIBUTING.md](./CONTRIBUTING.md) for the full workflow.
-
-## License
-
-This project is licensed under the [MIT License](./LICENSE.md).
diff --git a/docs/en/api/core.md b/docs/en/api/core.md
index aed69aa..54bfb43 100644
--- a/docs/en/api/core.md
+++ b/docs/en/api/core.md
@@ -1,116 +1,42 @@
# @react-protected/core
-Framework-agnostic access-control logic. No dependency on React, a router, or a store.
+Framework-agnostic access decisions.
## createGuard(options)
-Creates a guard that evaluates access based on the current user.
-
```ts
-import { createGuard } from '@react-protected/core'
-
const guard = createGuard({
getUser: () => store.getState().user,
hasRole: (user, roles) => roles.some((role) => user.roles.includes(role)),
hasPermission: (user, permissions) =>
- permissions.every((p) => user.permissions.includes(p)),
+ permissions.every((permission) => user.permissions.includes(permission)),
})
```
-### Options
-
-| Field | Type | Default | Description |
-| ----------------- | -------------------------------- | --------------- | ---------------------------------------------------------- |
-| `getUser` | `() => TUser \| null` | — | **Required.** Returns the current user or `null` |
-| `isAuthenticated` | `(user) => boolean` | `user !== null` | Override the default authenticated check |
-| `hasRole` | `(user, roles) => boolean` | `() => false` | Role check for RBAC |
-| `hasPermission` | `(user, permissions) => boolean` | `() => false` | Permission check for ABAC-style access |
-
-### Recommended semantics
-
-The library does not enforce a specific matching strategy — the semantics are entirely determined by your `hasRole` and `hasPermission` implementations. The convention used across all examples:
-
-| Callback | Strategy | Rationale |
-| ----------------- | -------- | ------------------------------------------------------------------------- |
-| `hasRole` | OR | Roles grant alternative paths — `admin` **or** `manager` may access |
-| `hasPermission` | AND | Permissions accumulate — the user must hold **every** required one |
-
-```ts
-hasRole: (user, roles) => roles.some((r) => user.roles.includes(r))
-hasPermission: (user, perms) => perms.every((p) => user.permissions.includes(p))
-```
-
-You can use different semantics if your domain requires it — the callbacks are yours to define.
-
-Navigation paths (`loginPath`, `forbiddenPath`, `defaultPath`) and `callbackUrlParam` are not part of core — they live in `@react-protected/react` (via `AccessProvider`) and `@react-protected/react-router` (via `createAccessRouter`).
-
## guard.check(config)
-Evaluates whether the current user can access a route and returns an `AccessResult`.
-
```ts
const result = guard.check({ access: 'authenticated', roles: ['admin'] })
-
-if (result.allowed) {
- // permit access
-} else {
- // result.reason: 'unauthenticated' | 'forbidden'
- console.log(result.reason)
-}
```
-`check` is pure: it reads the user through `getUser()` on every call and has no side effects.
-
-### Implicit auth requirement
-
-When `roles` or `permissions` are set without an explicit `access` field, the route is treated as `'authenticated'` automatically:
-
-```ts
-guard.check({ roles: ['admin'] })
-// equivalent to: guard.check({ access: 'authenticated', roles: ['admin'] })
-```
-
-## AccessConfig
-
-The configuration object passed to `guard.check()`:
-
-```ts
-type AccessConfig = {
- access?: AccessLevel // default: 'public'
- roles?: string[]
- permissions?: string[]
- meta?: Record
-}
-```
-
-## AccessLevel
-
-```ts
-type AccessLevel = 'public' | 'authenticated'
-```
-
-`'guest-only'` is a routing-layer concern, not an access-control one. It is defined in the adapter (`@react-protected/react-router`) as `RouterAccessLevel = AccessLevel | 'guest-only'` and handled before `guard.check()` is called.
-
-## AccessResult
+Return shape:
```ts
type AccessResult =
| { allowed: true }
| { allowed: false; reason: 'unauthenticated' }
+ | { allowed: false; reason: 'authenticated' }
| { allowed: false; reason: 'forbidden' }
```
-Redirect targets are determined by the adapter, not by core.
-
-## Guard
-
-The object returned by `createGuard`:
+## Access levels
```ts
-type Guard = {
- check: (config: AccessConfig) => AccessResult
- options: Required>
-}
+type AccessLevel = 'public' | 'authenticated' | 'unauthenticated'
```
-`guard.options` exposes the resolved callbacks (with defaults filled in). Adapters use `guard.options.getUser()` and `guard.options.isAuthenticated()` to implement logic that must be evaluated before `check()` is called (e.g. `guest-only`).
+- `'public'`: always allowed
+- `'authenticated'`: requires a logged-in user
+- `'unauthenticated'`: requires the absence of a logged-in user
+
+When `roles` or `permissions` are provided without `access`, the guard treats the config as authenticated-only.
diff --git a/docs/en/api/react-router.md b/docs/en/api/react-router.md
index 9e8f5a4..0adcb06 100644
--- a/docs/en/api/react-router.md
+++ b/docs/en/api/react-router.md
@@ -1,231 +1,74 @@
# @react-protected/react-router
-Adapter for React Router. Includes everything from `@react-protected/react` — you do not need to install both packages.
+React Router helpers built on top of the shared guard.
-## AccessProvider
-
-Provides the guard and navigation config to the React component tree. Required for `AccessRoute`, `useAccess`, `useHasAccess`, and `HasAccess`.
+## createAccessMiddleware(options)
```tsx
-import { AccessProvider } from '@react-protected/react-router'
-
- authStore.user}
- hasRole={(user, roles) => roles.some((role) => user.roles.includes(role))}
- hasPermission={(user, perms) => perms.every((p) => user.permissions.includes(p))}
- loginPath="/login"
- forbiddenPath="/403"
- defaultPath="/dashboard"
- callbackUrlParam="next"
->
- {children}
-
+const accessMiddleware = createAccessMiddleware({
+ getUser,
+ hasRole,
+ hasPermission,
+ onDenied: ({ result, request }) => {
+ const url = new URL(request.url)
+
+ switch (result.reason) {
+ case 'unauthenticated':
+ return redirect(`/login?next=${encodeURIComponent(url.pathname + url.search)}`)
+ case 'authenticated':
+ return redirect('/dashboard')
+ case 'forbidden':
+ return redirect('/403')
+ }
+ },
+})
```
-### Props
-
-**Guard options** (passed to `createGuard` internally):
+## createAccessLoader(options)
-| Prop | Type | Default | Description |
-| ----------------- | -------------------------------- | --------------- | ---------------------------------------------- |
-| `getUser` | `() => TUser \| null` | — | **Required.** Returns the current user |
-| `isAuthenticated` | `(user) => boolean` | `user !== null` | Custom authenticated check |
-| `hasRole` | `(user, roles) => boolean` | `() => false` | Role check for RBAC |
-| `hasPermission` | `(user, perms) => boolean` | `() => false` | Permission check for ABAC |
-
-**Navigation config** (used by adapters for redirects):
+```tsx
+const accessLoader = createAccessLoader({
+ getUser,
+ hasPermission,
+ onDenied: ({ result }) => {
+ if (result.reason === 'forbidden') return redirect('/403')
+ return redirect('/login')
+ },
+})
+```
-| Prop | Type | Default | Description |
-| ----------------------- | --------------- | ------------ | ----------------------------------------------------------------------------------- |
-| `loginPath` | `string` | `'/login'` | Where unauthenticated users are redirected |
-| `forbiddenPath` | `string` | `'/403'` | Where users without the required role/permission go |
-| `defaultPath` | `string` | `'/'` | Where authenticated users go from `guest-only` routes |
-| `callbackUrlParam` | `string` | — | If set, appends the current path as a query param on login redirect |
-| `shouldAddCallbackUrl` | `() => boolean` | `() => true` | Called on each unauthenticated redirect to decide whether to append the callback URL |
+## createAccessAction(options)
-`AccessProvider` is declarative: when its props change, descendants receive a fresh guard with updated options.
+Same contract as `createAccessLoader`, but for actions.
## AccessRoute
-Protects a JSX route element. Renders ` ` when access is denied, `children` or ` ` when allowed.
+Render-time fallback. It does not redirect by itself:
```tsx
-import { AccessRoute } from '@react-protected/react-router'
-
-// Children pattern
-
-
-
- }
-/>
-
-// Layout (Outlet) pattern
- }>
- } />
- } />
-
-```
-
-### Props
-
-```ts
-type AccessRouteProps = {
- access?: 'public' | 'authenticated' | 'guest-only'
- roles?: string[]
- permissions?: string[]
- meta?: Record
- children?: ReactNode
-}
+ {reason}
}
+>
+
+
```
-### Redirect behavior
-
-| Condition | Redirect target |
-| ------------------------------------------- | ---------------------------------------------------------------------- |
-| `access: 'guest-only'` + authenticated | `defaultPath` |
-| `access: 'authenticated'` + not logged in | `loginPath` (with `?{callbackUrlParam}=...` if configured) |
-| Role or permission check fails | `forbiddenPath` |
-
-## createAccessRouter(routes, options, routerOptions?)
-
-Takes an array of protected routes and returns a standard React Router `router`. Guards are applied to `element`, `Component`, `loader`, `action`, and `lazy` routes.
+## Route decision
```ts
-import { createAccessRouter } from '@react-protected/react-router'
-
-const router = createAccessRouter(
- [
- { path: '/', element: },
- { path: '/login', element: , access: 'guest-only' },
- {
- path: '/dashboard',
- access: 'authenticated',
- lazy: async () => ({ Component: DashboardPage }),
- },
- {
- path: '/admin',
- access: 'authenticated',
- roles: ['admin'],
- loader: async () => fetchAdminData(),
- element: ,
- },
- { path: '/403', element: },
- ],
- {
- getUser: () => useAuthStore.getState().user,
- hasRole: (user, roles) => roles.some((role) => user.roles.includes(role)),
- loginPath: '/login',
- forbiddenPath: '/403',
- defaultPath: '/dashboard',
- callbackUrlParam: 'next',
- },
- { basename: '/app' } // forwarded to createBrowserRouter
-)
+type RouteAccessResult =
+ | { allowed: true }
+ | { allowed: false; reason: 'unauthenticated' | 'authenticated' | 'forbidden' }
```
-### routes — ProtectedRouteObject
-
-A superset of React Router's `RouteObject` with access fields:
+## Route config
```ts
-type ProtectedRouteObject = RouteObject & {
- access?: 'public' | 'authenticated' | 'guest-only'
+type RouterRouteConfig = {
+ access?: 'public' | 'authenticated' | 'unauthenticated'
roles?: string[]
permissions?: string[]
meta?: Record
- children?: ProtectedRouteObject[]
}
```
-
-### options — CreateAccessRouterConfig
-
-All `AccessProvider` props except `children`.
-
-### Behavior
-
-- If access is denied, `loader` and `action` are not executed — a redirect response is returned instead.
-- If a route has both static UI (`element` / `Component`) and `lazy`, static UI takes priority for rendering; `lazy` loader/action are still wrapped.
-
-## useAccess()
-
-Returns the full context value including the guard and navigation config.
-
-```tsx
-import { useAccess } from '@react-protected/react-router'
-
-const { guard, loginPath, forbiddenPath, defaultPath, callbackUrlParam, shouldAddCallbackUrl } = useAccess()
-const result = guard.check({ roles: ['admin'] })
-```
-
-Throws if called outside ``.
-
-## useRouteAccess(config)
-
-Calls `guard.check()` and returns the `AccessResult`. Useful for custom redirect logic.
-
-```tsx
-import { useRouteAccess } from '@react-protected/react-router'
-
-const result = useRouteAccess({ access: 'authenticated', roles: ['admin'] })
-// { allowed: boolean, reason?: 'unauthenticated' | 'forbidden' }
-```
-
-## useHasAccess(config)
-
-Returns `true` if `guard.check(config).allowed`, `false` otherwise. Use this for conditional UI rendering.
-
-```tsx
-import { useHasAccess } from '@react-protected/react-router'
-
-const canDelete = useHasAccess({ roles: ['admin'] })
-```
-
-## HasAccess
-
-Component version of `useHasAccess`. Renders `children` when access is allowed, `null` otherwise.
-
-```tsx
-import { HasAccess } from '@react-protected/react-router'
-
-
- Delete
-
-```
-
-## Callback URL flow
-
-When `callbackUrlParam` is set, unauthenticated redirects include the current path:
-
-```
-/dashboard?tab=overview → /login?next=%2Fdashboard%3Ftab%3Doverview
-```
-
-Handle the return in your login page:
-
-```ts
-const [params] = useSearchParams()
-const callbackUrl = params.get('next')
-navigate(callbackUrl ?? '/dashboard', { replace: true })
-```
-
-### Conditional callback URL
-
-`shouldAddCallbackUrl` lets you suppress the callback URL at runtime without removing `callbackUrlParam`. It is called on every unauthenticated redirect:
-
-```tsx
- !authStore.getState().loggedOut}
- ...
->
-```
-
-| Scenario | Result |
-| --------------------------------- | -------------------------------------- |
-| Session expired (normal timeout) | `/login?next=%2Fdashboard` — user returns to where they were |
-| User explicitly logged out | `/login` — no callback URL, clean start |
-
-When `shouldAddCallbackUrl` is not provided, the callback URL is always appended (existing behavior).
diff --git a/docs/en/examples/abac.md b/docs/en/examples/abac.md
index 52b852e..f64ffdd 100644
--- a/docs/en/examples/abac.md
+++ b/docs/en/examples/abac.md
@@ -1,81 +1,24 @@
-# ABAC: Attribute- or Permission-Based Access
-
-ABAC lets you control access through concrete permissions rather than broad roles.
-
-```ts
-type User = {
- id: string
- roles: string[]
- permissions: string[] // e.g. ['contracts:read', 'contracts:write', 'users:read']
-}
-```
+# ABAC: Permission-Based Access
```ts
-import { createAccessRouter } from '@react-protected/react-router'
-
-export const router = createAccessRouter(
- [
- {
- path: '/contracts',
- element: ,
- access: 'authenticated',
- permissions: ['contracts:read'],
- },
- {
- path: '/contracts/new',
- element: ,
- access: 'authenticated',
- permissions: ['contracts:write'],
- },
- {
- path: '/users',
- element: ,
- access: 'authenticated',
- permissions: ['users:read'],
- },
- ],
- {
- getUser: () => useAuthStore.getState().user,
-
- // AND semantics: the user must have every permission in the list
- hasPermission: (user: User, permissions) =>
- permissions.every((permission) => user.permissions.includes(permission)),
-
- forbiddenPath: '/403',
- }
-)
+const accessLoader = createAccessLoader({
+ getUser: () => useAuthStore.getState().user,
+ hasPermission: (user, permissions) =>
+ permissions.every((permission) => user.permissions.includes(permission)),
+ onDenied: ({ result }) => {
+ if (result.reason === 'forbidden') return redirect('/403')
+ return redirect('/login')
+ },
+})
```
-## Combining roles and permissions
-
-You can use both mechanisms together. Both checks must pass:
-
```ts
{
- path: '/admin/billing',
- access: 'authenticated',
- roles: ['admin'], // must be admin
- permissions: ['billing:manage'], // AND must have billing:manage
-}
-```
-
-## Guarding UI elements with permissions
-
-```tsx
-import { HasAccess, useHasAccess } from '@react-protected/react-router'
-
-// Component form
-const ContractActions = () => (
-
-
- Edit contract
-
-
-)
-
-// Hook form
-const ExportButton = () => {
- const canExport = useHasAccess({ permissions: ['reports:export'] })
- return canExport ? Export : null
+ path: '/reports',
+ loader: accessLoader(
+ { access: 'authenticated', permissions: ['reports:read'] },
+ async () => fetchReports()
+ ),
+ element: ,
}
```
diff --git a/docs/en/examples/basic.md b/docs/en/examples/basic.md
index 3498752..6b66326 100644
--- a/docs/en/examples/basic.md
+++ b/docs/en/examples/basic.md
@@ -1,103 +1,36 @@
-# Basic: Auth and Guest Flow
-
-Public home, a login page only for guests, a dashboard for authenticated users.
-
-## Data router
+# Basic: Auth and Unauth Flow
```ts
-// router.ts
-import { createAccessRouter } from '@react-protected/react-router'
-import { useAuthStore } from './entities/auth'
-import { LoginPage, DashboardPage, HomePage, Page403 } from './pages'
+const accessMiddleware = createAccessMiddleware({
+ getUser: () => useAuthStore.getState().user,
+ onDenied: ({ result }) => {
+ switch (result.reason) {
+ case 'unauthenticated':
+ return redirect('/login')
+ case 'authenticated':
+ return redirect('/dashboard')
+ case 'forbidden':
+ return redirect('/403')
+ }
+ },
+})
+```
-export const router = createAccessRouter(
+```ts
+const router = createBrowserRouter(
[
{ path: '/', element: },
{
path: '/login',
+ middleware: [accessMiddleware({ access: 'unauthenticated' })],
element: ,
- access: 'guest-only', // authenticated users are redirected to defaultPath
},
{
path: '/dashboard',
+ middleware: [accessMiddleware({ access: 'authenticated' })],
element: ,
- access: 'authenticated', // unauthenticated users go to /login?next=%2Fdashboard
},
- { path: '/403', element: },
],
- {
- getUser: () => useAuthStore.getState().user,
- loginPath: '/login',
- forbiddenPath: '/403',
- defaultPath: '/dashboard',
- callbackUrlParam: 'next',
- }
-)
-```
-
-```tsx
-// App.tsx
-import { RouterProvider } from 'react-router-dom'
-import { router } from './router'
-
-export const App = () =>
-```
-
-## JSX routes
-
-```tsx
-import { Route, Routes } from 'react-router-dom'
-import { AccessProvider, AccessRoute } from '@react-protected/react-router'
-
-export const App = () => (
- useAuthStore.getState().user}
- loginPath="/login"
- forbiddenPath="/403"
- defaultPath="/dashboard"
- callbackUrlParam="next"
- >
-
- } />
-
-
-
- }
- />
-
-
-
- }
- />
-
-
+ { future: { v8_middleware: true } }
)
```
-
-## Handling callbackUrl after login
-
-```tsx
-// pages/LoginPage.tsx
-import { useNavigate, useSearchParams } from 'react-router-dom'
-import { useAuthStore } from './entities/auth'
-
-export const LoginPage = () => {
- const setAuth = useAuthStore((state) => state.setAuth)
- const navigate = useNavigate()
- const [params] = useSearchParams()
-
- const handleLogin = async (email: string, password: string) => {
- const { user, token } = await AuthAPI.login(email, password)
- setAuth(user, token)
- navigate(params.get('next') ?? '/dashboard', { replace: true })
- }
-
- // ...
-}
-```
diff --git a/docs/en/examples/core-only.md b/docs/en/examples/core-only.md
index 6fedabb..dd71011 100644
--- a/docs/en/examples/core-only.md
+++ b/docs/en/examples/core-only.md
@@ -1,87 +1,16 @@
# Using the Core Package Without an Adapter
-If you are not using React Router, take `@react-protected/core` and wire it into any router manually. The guard itself is pure — it returns an `AccessResult` but never redirects.
-
-## Example with TanStack Router
-
```ts
-import { createGuard } from '@react-protected/core'
-import { createRouter, createRoute, redirect } from '@tanstack/react-router'
-
const guard = createGuard({
- getUser: () => useAuthStore.getState().user,
+ getUser: () => authStore.getState().user,
hasRole: (user, roles) => roles.some((role) => user.roles.includes(role)),
})
-const dashboardRoute = createRoute({
- path: '/dashboard',
- beforeLoad: ({ location }) => {
- const result = guard.check({ access: 'authenticated' })
- if (!result.allowed) {
- throw redirect({
- to: result.reason === 'unauthenticated' ? '/login' : '/403',
- search: { next: location.pathname },
- })
- }
- },
- component: DashboardPage,
-})
-```
-
-## Example with Vanilla JS
+const result = guard.check({ access: 'authenticated', roles: ['admin'] })
-```ts
-import { createGuard } from '@react-protected/core'
-
-const routeMap: Record = {
- '/dashboard': { access: 'authenticated' },
- '/admin': { access: 'authenticated', roles: ['admin'] },
+if (!result.allowed) {
+ if (result.reason === 'unauthenticated') redirect('/login')
+ if (result.reason === 'authenticated') redirect('/dashboard')
+ if (result.reason === 'forbidden') redirect('/403')
}
-
-const guard = createGuard({
- getUser: () => JSON.parse(sessionStorage.getItem('user') ?? 'null'),
- hasRole: (user, roles) => roles.some((r) => user.roles.includes(r)),
-})
-
-function navigate(path: string) {
- const config = routeMap[path] ?? {}
- const result = guard.check(config)
-
- if (!result.allowed) {
- const redirectTo = result.reason === 'unauthenticated' ? '/login' : '/403'
- history.replaceState(null, '', redirectTo)
- return
- }
-
- history.pushState(null, '', path)
- renderPage(path)
-}
-```
-
-## Using @react-protected/react outside React Router
-
-`@react-protected/react` can be used with any routing library that supports a React context pattern:
-
-```tsx
-import { AccessProvider, HasAccess, useHasAccess } from '@react-protected/react'
-
-// Wrap your app
-const App = () => (
- authStore.user}
- hasRole={(user, roles) => roles.some((r) => user.roles.includes(r))}
- loginPath="/login"
- forbiddenPath="/403"
- defaultPath="/"
- >
-
-
-)
-
-// Guard UI elements anywhere in the tree
-const AdminPanel = () => (
-
- Manage users
-
-)
```
diff --git a/docs/en/examples/rbac.md b/docs/en/examples/rbac.md
index e3b6561..d4b5ccc 100644
--- a/docs/en/examples/rbac.md
+++ b/docs/en/examples/rbac.md
@@ -1,80 +1,20 @@
# RBAC: Role-Based Access
```ts
-// types.ts — your own domain types; the library does not impose a user shape
-type User = {
- id: string
- roles: ('admin' | 'manager' | 'viewer')[]
-}
-```
-
-```ts
-// router.ts
-import { createAccessRouter } from '@react-protected/react-router'
-
-export const router = createAccessRouter(
- [
- { path: '/login', element: , access: 'guest-only' },
- { path: '/dashboard', element: , access: 'authenticated' },
- {
- path: '/admin',
- element: ,
- access: 'authenticated',
- roles: ['admin'],
- },
- {
- path: '/reports',
- element: ,
- access: 'authenticated',
- roles: ['admin', 'manager'], // admin OR manager
- },
- { path: '/403', element: },
- ],
- {
- getUser: () => useAuthStore.getState().user,
-
- // You define the rule: OR semantics here
- hasRole: (user: User, roles) => roles.some((role) => user.roles.includes(role)),
-
- loginPath: '/login',
- forbiddenPath: '/403',
- }
-)
-```
-
-## Guarding UI elements
-
-Use `HasAccess` or `useHasAccess` to hide elements based on roles — no route change required:
-
-```tsx
-import { HasAccess } from '@react-protected/react-router'
-
-const Toolbar = () => (
-
-
- Delete user
-
-
-
- Export report
-
-
-)
+const accessMiddleware = createAccessMiddleware({
+ getUser: () => useAuthStore.getState().user,
+ hasRole: (user, roles) => roles.some((role) => user.roles.includes(role)),
+ onDenied: ({ result }) => {
+ if (result.reason === 'forbidden') return redirect('/403')
+ return redirect('/login')
+ },
+})
```
-## Role hierarchy
-
-If your roles are hierarchical (e.g. `admin` includes `manager` rights):
-
```ts
-const HIERARCHY: Record = {
- admin: ['admin', 'manager', 'viewer'],
- manager: ['manager', 'viewer'],
- viewer: ['viewer'],
+{
+ path: '/admin',
+ middleware: [accessMiddleware({ access: 'authenticated', roles: ['admin'] })],
+ element: ,
}
-
-hasRole: (user, roles) =>
- user.roles.some((userRole) =>
- roles.some((required) => HIERARCHY[userRole]?.includes(required))
- )
```
diff --git a/docs/ru/api/core.md b/docs/ru/api/core.md
index 96635aa..16307eb 100644
--- a/docs/ru/api/core.md
+++ b/docs/ru/api/core.md
@@ -1,116 +1,42 @@
# @react-protected/core
-Фреймворк-агностик логика контроля доступа. Не зависит от React, роутера или стора.
+Framework-agnostic решения по доступу.
## createGuard(options)
-Создаёт guard, который проверяет доступ на основе текущего пользователя.
-
```ts
-import { createGuard } from '@react-protected/core'
-
const guard = createGuard({
getUser: () => store.getState().user,
- hasRole: (user, roles) => roles.some((r) => user.roles.includes(r)),
+ hasRole: (user, roles) => roles.some((role) => user.roles.includes(role)),
hasPermission: (user, permissions) =>
- permissions.every((p) => user.permissions.includes(p)),
+ permissions.every((permission) => user.permissions.includes(permission)),
})
```
-### Options
-
-| Поле | Тип | Default | Описание |
-| ----------------- | -------------------------------- | --------------- | -------------------------------------------------------- |
-| `getUser` | `() => TUser \| null` | — | **Required.** Возвращает текущего пользователя или `null`|
-| `isAuthenticated` | `(user) => boolean` | `user !== null` | Переопределяет проверку аутентификации |
-| `hasRole` | `(user, roles) => boolean` | `() => false` | Проверка ролей (RBAC) |
-| `hasPermission` | `(user, permissions) => boolean` | `() => false` | Проверка прав доступа (ABAC) |
-
-### Рекомендуемая семантика
-
-Библиотека не навязывает конкретную стратегию сопоставления — семантика полностью определяется твоими реализациями `hasRole` и `hasPermission`. Конвенция, которой следуют все примеры:
-
-| Колбэк | Стратегия | Обоснование |
-| ----------------- | --------- | -------------------------------------------------------------------------------- |
-| `hasRole` | OR | Роли дают альтернативный доступ — `admin` **или** `manager` могут зайти |
-| `hasPermission` | AND | Права накапливаются — пользователь должен иметь **каждое** из требуемых |
-
-```ts
-hasRole: (user, roles) => roles.some((r) => user.roles.includes(r))
-hasPermission: (user, perms) => perms.every((p) => user.permissions.includes(p))
-```
-
-При необходимости можно использовать другую семантику — колбэки полностью под твоим контролем.
-
-Пути для редиректов (`loginPath`, `forbiddenPath`, `defaultPath`) и `callbackUrlParam` не входят в ядро — они живут в `@react-protected/react` (через `AccessProvider`) и `@react-protected/react-router` (через `createAccessRouter`).
-
## guard.check(config)
-Проверяет доступ текущего пользователя к маршруту и возвращает `AccessResult`.
-
```ts
const result = guard.check({ access: 'authenticated', roles: ['admin'] })
-
-if (result.allowed) {
- // пропустить
-} else {
- // result.reason: 'unauthenticated' | 'forbidden'
- console.log(result.reason)
-}
```
-`check` — чистая функция: читает пользователя через `getUser()` при каждом вызове, побочных эффектов нет.
-
-### Неявное требование аутентификации
-
-Если указаны `roles` или `permissions` без явного `access`, маршрут автоматически считается `'authenticated'`:
-
-```ts
-guard.check({ roles: ['admin'] })
-// эквивалентно: guard.check({ access: 'authenticated', roles: ['admin'] })
-```
-
-## AccessConfig
-
-Объект, передаваемый в `guard.check()`:
-
-```ts
-type AccessConfig = {
- access?: AccessLevel // default: 'public'
- roles?: string[]
- permissions?: string[]
- meta?: Record
-}
-```
-
-## AccessLevel
-
-```ts
-type AccessLevel = 'public' | 'authenticated'
-```
-
-`'guest-only'` — это концепция роутинга, а не контроля доступа. Она определена в адаптере (`@react-protected/react-router`) как `RouterAccessLevel = AccessLevel | 'guest-only'` и обрабатывается до вызова `guard.check()`.
-
-## AccessResult
+Форма результата:
```ts
type AccessResult =
| { allowed: true }
| { allowed: false; reason: 'unauthenticated' }
+ | { allowed: false; reason: 'authenticated' }
| { allowed: false; reason: 'forbidden' }
```
-Целевые пути для редиректов определяются адаптером, а не ядром.
-
-## Guard
-
-Объект, возвращаемый `createGuard`:
+## Уровни доступа
```ts
-type Guard = {
- check: (config: AccessConfig) => AccessResult
- options: Required>
-}
+type AccessLevel = 'public' | 'authenticated' | 'unauthenticated'
```
-`guard.options` открывает доступ к resolved-коллбэкам (с заполненными дефолтами). Адаптеры используют `guard.options.getUser()` и `guard.options.isAuthenticated()` для логики, которую нужно выполнить до вызова `check()` (например, для `guest-only`).
+- `'public'`: доступ всегда открыт
+- `'authenticated'`: нужен залогиненный пользователь
+- `'unauthenticated'`: нужен незалогиненный пользователь
+
+Если заданы `roles` или `permissions` без `access`, guard автоматически считает проверку authenticated-only.
diff --git a/docs/ru/api/react-router.md b/docs/ru/api/react-router.md
index afeff5c..7e0ab06 100644
--- a/docs/ru/api/react-router.md
+++ b/docs/ru/api/react-router.md
@@ -1,231 +1,74 @@
# @react-protected/react-router
-Адаптер для React Router. Включает всё из `@react-protected/react` — устанавливать оба пакета не нужно.
+Helpers для React Router поверх общего guard.
-## AccessProvider
-
-Предоставляет guard и конфигурацию навигации дереву React-компонентов. Обязателен для `AccessRoute`, `useAccess`, `useHasAccess` и `HasAccess`.
+## createAccessMiddleware(options)
```tsx
-import { AccessProvider } from '@react-protected/react-router'
-
- authStore.user}
- hasRole={(user, roles) => roles.some((role) => user.roles.includes(role))}
- hasPermission={(user, perms) => perms.every((p) => user.permissions.includes(p))}
- loginPath="/login"
- forbiddenPath="/403"
- defaultPath="/dashboard"
- callbackUrlParam="next"
->
- {children}
-
+const accessMiddleware = createAccessMiddleware({
+ getUser,
+ hasRole,
+ hasPermission,
+ onDenied: ({ result, request }) => {
+ const url = new URL(request.url)
+
+ switch (result.reason) {
+ case 'unauthenticated':
+ return redirect(`/login?next=${encodeURIComponent(url.pathname + url.search)}`)
+ case 'authenticated':
+ return redirect('/dashboard')
+ case 'forbidden':
+ return redirect('/403')
+ }
+ },
+})
```
-### Props
-
-**Опции guard** (передаются в `createGuard` внутри):
+## createAccessLoader(options)
-| Prop | Тип | Default | Описание |
-| ----------------- | -------------------------------- | --------------- | ------------------------------------------------- |
-| `getUser` | `() => TUser \| null` | — | **Required.** Возвращает текущего пользователя |
-| `isAuthenticated` | `(user) => boolean` | `user !== null` | Переопределяет проверку аутентификации |
-| `hasRole` | `(user, roles) => boolean` | `() => false` | Проверка ролей (RBAC) |
-| `hasPermission` | `(user, perms) => boolean` | `() => false` | Проверка прав доступа (ABAC) |
-
-**Конфигурация навигации** (используется адаптером для редиректов):
+```tsx
+const accessLoader = createAccessLoader({
+ getUser,
+ hasPermission,
+ onDenied: ({ result }) => {
+ if (result.reason === 'forbidden') return redirect('/403')
+ return redirect('/login')
+ },
+})
+```
-| Prop | Тип | Default | Описание |
-| ----------------------- | --------------- | ------------ | --------------------------------------------------------------------------------------------- |
-| `loginPath` | `string` | `'/login'` | Куда перенаправлять незалогиненных пользователей |
-| `forbiddenPath` | `string` | `'/403'` | Куда перенаправлять при нехватке прав |
-| `defaultPath` | `string` | `'/'` | Куда перенаправлять залогиненных с `guest-only` маршрутов |
-| `callbackUrlParam` | `string` | — | Если указан, добавляет текущий путь как query-параметр при редиректе на логин |
-| `shouldAddCallbackUrl` | `() => boolean` | `() => true` | Вызывается при каждом редиректе незалогиненного — решает, добавлять ли callback URL |
+## createAccessAction(options)
-`AccessProvider` декларативный: при изменении props потомки получают новый guard с актуальными опциями.
+Тот же контракт, что и у `createAccessLoader`, но для action.
## AccessRoute
-Защищает JSX-элемент маршрута. При запрете отображает ` `, при разрешении — `children` или ` `.
+Render-time fallback. Сам по себе не редиректит:
```tsx
-import { AccessRoute } from '@react-protected/react-router'
-
-// Паттерн с children
-
-
-
- }
-/>
-
-// Паттерн layout (Outlet)
- }>
- } />
- } />
-
-```
-
-### Props
-
-```ts
-type AccessRouteProps = {
- access?: 'public' | 'authenticated' | 'guest-only'
- roles?: string[]
- permissions?: string[]
- meta?: Record
- children?: ReactNode
-}
+ {reason}
}
+>
+
+
```
-### Поведение редиректов
-
-| Условие | Куда редиректит |
-| ------------------------------------------------- | ------------------------------------------------------------------------ |
-| `access: 'guest-only'` + пользователь авторизован | `defaultPath` |
-| `access: 'authenticated'` + не авторизован | `loginPath` (с `?{callbackUrlParam}=...`, если настроен) |
-| Проверка роли или права не пройдена | `forbiddenPath` |
-
-## createAccessRouter(routes, options, routerOptions?)
-
-Принимает массив защищённых маршрутов и возвращает стандартный React Router router. Guards применяются к `element`, `Component`, `loader`, `action` и `lazy` маршрутам.
+## Route decision
```ts
-import { createAccessRouter } from '@react-protected/react-router'
-
-const router = createAccessRouter(
- [
- { path: '/', element: },
- { path: '/login', element: , access: 'guest-only' },
- {
- path: '/dashboard',
- access: 'authenticated',
- lazy: async () => ({ Component: DashboardPage }),
- },
- {
- path: '/admin',
- access: 'authenticated',
- roles: ['admin'],
- loader: async () => fetchAdminData(),
- element: ,
- },
- { path: '/403', element: },
- ],
- {
- getUser: () => useAuthStore.getState().user,
- hasRole: (user, roles) => roles.some((role) => user.roles.includes(role)),
- loginPath: '/login',
- forbiddenPath: '/403',
- defaultPath: '/dashboard',
- callbackUrlParam: 'next',
- },
- { basename: '/app' } // пробрасывается в createBrowserRouter
-)
+type RouteAccessResult =
+ | { allowed: true }
+ | { allowed: false; reason: 'unauthenticated' | 'authenticated' | 'forbidden' }
```
-### routes — ProtectedRouteObject
-
-Расширение React Router `RouteObject` с полями защиты:
+## Конфиг маршрута
```ts
-type ProtectedRouteObject = RouteObject & {
- access?: 'public' | 'authenticated' | 'guest-only'
+type RouterRouteConfig = {
+ access?: 'public' | 'authenticated' | 'unauthenticated'
roles?: string[]
permissions?: string[]
meta?: Record
- children?: ProtectedRouteObject[]
}
```
-
-### options — CreateAccessRouterConfig
-
-Все props `AccessProvider`, кроме `children`.
-
-### Поведение
-
-- При запрете доступа `loader` и `action` не выполняются — вместо этого возвращается redirect-ответ.
-- Если на маршруте есть и статический UI (`element` / `Component`), и `lazy` — приоритет остаётся за статическим UI.
-
-## useAccess()
-
-Возвращает полное значение контекста: guard и конфигурацию навигации.
-
-```tsx
-import { useAccess } from '@react-protected/react-router'
-
-const { guard, loginPath, forbiddenPath, defaultPath, callbackUrlParam, shouldAddCallbackUrl } = useAccess()
-const result = guard.check({ roles: ['admin'] })
-```
-
-Бросает ошибку, если вызван вне ``.
-
-## useRouteAccess(config)
-
-Вызывает `guard.check()` и возвращает `AccessResult`. Удобно для кастомной логики редиректов.
-
-```tsx
-import { useRouteAccess } from '@react-protected/react-router'
-
-const result = useRouteAccess({ access: 'authenticated', roles: ['admin'] })
-// { allowed: boolean, reason?: 'unauthenticated' | 'forbidden' }
-```
-
-## useHasAccess(config)
-
-Возвращает `true`, если `guard.check(config).allowed`, иначе `false`. Используй для условного рендеринга UI.
-
-```tsx
-import { useHasAccess } from '@react-protected/react-router'
-
-const canDelete = useHasAccess({ roles: ['admin'] })
-```
-
-## HasAccess
-
-Компонентная форма `useHasAccess`. Рендерит `children` при разрешении, иначе `null`.
-
-```tsx
-import { HasAccess } from '@react-protected/react-router'
-
-
- Удалить
-
-```
-
-## Callback URL flow
-
-Если указан `callbackUrlParam`, редирект незалогиненного включает текущий путь:
-
-```
-/dashboard?tab=overview → /login?next=%2Fdashboard%3Ftab%3Doverview
-```
-
-После логина обработай возврат в страницу логина:
-
-```ts
-const [params] = useSearchParams()
-const callbackUrl = params.get('next')
-navigate(callbackUrl ?? '/dashboard', { replace: true })
-```
-
-### Условный callback URL
-
-`shouldAddCallbackUrl` позволяет отключить добавление callback URL в рантайме, не убирая `callbackUrlParam`. Вызывается при каждом редиректе незалогиненного:
-
-```tsx
- !authStore.getState().loggedOut}
- ...
->
-```
-
-| Сценарий | Результат |
-| -------------------------------- | -------------------------------------------------------------- |
-| Сессия истекла (обычный таймаут) | `/login?next=%2Fdashboard` — пользователь вернётся куда шёл |
-| Явный выход из системы | `/login` — без callback URL, чистый старт |
-
-Если `shouldAddCallbackUrl` не передан, callback URL добавляется всегда (поведение не меняется).
diff --git a/docs/ru/examples/abac.md b/docs/ru/examples/abac.md
index 010bdfe..ce46e88 100644
--- a/docs/ru/examples/abac.md
+++ b/docs/ru/examples/abac.md
@@ -1,81 +1,24 @@
-# ABAC — доступ на основе атрибутов
-
-ABAC позволяет задавать доступ через конкретные права (permissions), а не роли.
-
-```ts
-type User = {
- id: string
- roles: string[]
- permissions: string[] // ['contracts:read', 'contracts:write', 'users:read']
-}
-```
+# ABAC — доступ по permissions
```ts
-import { createAccessRouter } from '@react-protected/react-router'
-
-export const router = createAccessRouter(
- [
- {
- path: '/contracts',
- element: ,
- access: 'authenticated',
- permissions: ['contracts:read'],
- },
- {
- path: '/contracts/new',
- element: ,
- access: 'authenticated',
- permissions: ['contracts:write'],
- },
- {
- path: '/users',
- element: ,
- access: 'authenticated',
- permissions: ['users:read'],
- },
- ],
- {
- getUser: () => useAuthStore.getState().user,
-
- // AND-семантика: пользователь должен иметь каждое право из списка
- hasPermission: (user: User, permissions) =>
- permissions.every((p) => user.permissions.includes(p)),
-
- forbiddenPath: '/403',
- }
-)
+const accessLoader = createAccessLoader({
+ getUser: () => useAuthStore.getState().user,
+ hasPermission: (user, permissions) =>
+ permissions.every((permission) => user.permissions.includes(permission)),
+ onDenied: ({ result }) => {
+ if (result.reason === 'forbidden') return redirect('/403')
+ return redirect('/login')
+ },
+})
```
-## Комбинация ролей и прав
-
-Можно использовать оба механизма одновременно — проверка пройдёт только если выполнены оба условия:
-
```ts
{
- path: '/admin/billing',
- access: 'authenticated',
- roles: ['admin'], // должен быть admin
- permissions: ['billing:manage'], // И иметь право billing:manage
-}
-```
-
-## Защита UI-элементов через права
-
-```tsx
-import { HasAccess, useHasAccess } from '@react-protected/react-router'
-
-// Компонентная форма
-const ContractActions = () => (
-
-
- Редактировать контракт
-
-
-)
-
-// Хуковая форма
-const ExportButton = () => {
- const canExport = useHasAccess({ permissions: ['reports:export'] })
- return canExport ? Экспорт : null
+ path: '/reports',
+ loader: accessLoader(
+ { access: 'authenticated', permissions: ['reports:read'] },
+ async () => fetchReports()
+ ),
+ element: ,
}
```
diff --git a/docs/ru/examples/basic.md b/docs/ru/examples/basic.md
index 4a00ffb..6b66326 100644
--- a/docs/ru/examples/basic.md
+++ b/docs/ru/examples/basic.md
@@ -1,103 +1,36 @@
-# Basic — авторизация и гость
-
-Публичная главная, страница логина только для гостей, дашборд только для авторизованных.
-
-## Data router
+# Basic: Auth and Unauth Flow
```ts
-// router.ts
-import { createAccessRouter } from '@react-protected/react-router'
-import { useAuthStore } from './entities/auth'
-import { LoginPage, DashboardPage, HomePage, Page403 } from './pages'
+const accessMiddleware = createAccessMiddleware({
+ getUser: () => useAuthStore.getState().user,
+ onDenied: ({ result }) => {
+ switch (result.reason) {
+ case 'unauthenticated':
+ return redirect('/login')
+ case 'authenticated':
+ return redirect('/dashboard')
+ case 'forbidden':
+ return redirect('/403')
+ }
+ },
+})
+```
-export const router = createAccessRouter(
+```ts
+const router = createBrowserRouter(
[
{ path: '/', element: },
{
path: '/login',
+ middleware: [accessMiddleware({ access: 'unauthenticated' })],
element: ,
- access: 'guest-only', // залогиненных редиректит на defaultPath
},
{
path: '/dashboard',
+ middleware: [accessMiddleware({ access: 'authenticated' })],
element: ,
- access: 'authenticated', // незалогиненных → /login?next=%2Fdashboard
},
- { path: '/403', element: },
],
- {
- getUser: () => useAuthStore.getState().user,
- loginPath: '/login',
- forbiddenPath: '/403',
- defaultPath: '/dashboard',
- callbackUrlParam: 'next',
- }
-)
-```
-
-```tsx
-// App.tsx
-import { RouterProvider } from 'react-router-dom'
-import { router } from './router'
-
-export const App = () =>
-```
-
-## JSX-роутинг
-
-```tsx
-import { Route, Routes } from 'react-router-dom'
-import { AccessProvider, AccessRoute } from '@react-protected/react-router'
-
-export const App = () => (
- useAuthStore.getState().user}
- loginPath="/login"
- forbiddenPath="/403"
- defaultPath="/dashboard"
- callbackUrlParam="next"
- >
-
- } />
-
-
-
- }
- />
-
-
-
- }
- />
-
-
+ { future: { v8_middleware: true } }
)
```
-
-## Обработка callbackUrl после логина
-
-```tsx
-// pages/LoginPage.tsx
-import { useNavigate, useSearchParams } from 'react-router-dom'
-import { useAuthStore } from './entities/auth'
-
-export const LoginPage = () => {
- const setAuth = useAuthStore((s) => s.setAuth)
- const navigate = useNavigate()
- const [params] = useSearchParams()
-
- const handleLogin = async (email: string, password: string) => {
- const { user, token } = await AuthAPI.login(email, password)
- setAuth(user, token)
- navigate(params.get('next') ?? '/dashboard', { replace: true })
- }
-
- // ...
-}
-```
diff --git a/docs/ru/examples/core-only.md b/docs/ru/examples/core-only.md
index 44a0985..af43a97 100644
--- a/docs/ru/examples/core-only.md
+++ b/docs/ru/examples/core-only.md
@@ -1,87 +1,16 @@
# Использование core без адаптера
-Если не используешь React Router — можно взять только `@react-protected/core` и встроить в любой роутер. Guard — чистая функция: возвращает `AccessResult`, но никогда сам не редиректит.
-
-## Пример с TanStack Router
-
```ts
-import { createGuard } from '@react-protected/core'
-import { createRouter, createRoute, redirect } from '@tanstack/react-router'
-
const guard = createGuard({
- getUser: () => useAuthStore.getState().user,
- hasRole: (user, roles) => roles.some((r) => user.roles.includes(r)),
+ getUser: () => authStore.getState().user,
+ hasRole: (user, roles) => roles.some((role) => user.roles.includes(role)),
})
-const dashboardRoute = createRoute({
- path: '/dashboard',
- beforeLoad: ({ location }) => {
- const result = guard.check({ access: 'authenticated' })
- if (!result.allowed) {
- throw redirect({
- to: result.reason === 'unauthenticated' ? '/login' : '/403',
- search: { next: location.pathname },
- })
- }
- },
- component: DashboardPage,
-})
-```
-
-## Пример с vanilla JS
+const result = guard.check({ access: 'authenticated', roles: ['admin'] })
-```ts
-import { createGuard } from '@react-protected/core'
-
-const routeMap: Record = {
- '/dashboard': { access: 'authenticated' },
- '/admin': { access: 'authenticated', roles: ['admin'] },
+if (!result.allowed) {
+ if (result.reason === 'unauthenticated') redirect('/login')
+ if (result.reason === 'authenticated') redirect('/dashboard')
+ if (result.reason === 'forbidden') redirect('/403')
}
-
-const guard = createGuard({
- getUser: () => JSON.parse(sessionStorage.getItem('user') ?? 'null'),
- hasRole: (user, roles) => roles.some((r) => user.roles.includes(r)),
-})
-
-function navigate(path: string) {
- const config = routeMap[path] ?? {}
- const result = guard.check(config)
-
- if (!result.allowed) {
- const redirectTo = result.reason === 'unauthenticated' ? '/login' : '/403'
- history.replaceState(null, '', redirectTo)
- return
- }
-
- history.pushState(null, '', path)
- renderPage(path)
-}
-```
-
-## Использование @react-protected/react без React Router
-
-`@react-protected/react` работает с любой библиотекой роутинга через паттерн React context:
-
-```tsx
-import { AccessProvider, HasAccess, useHasAccess } from '@react-protected/react'
-
-// Оборачиваем приложение
-const App = () => (
- authStore.user}
- hasRole={(user, roles) => roles.some((r) => user.roles.includes(r))}
- loginPath="/login"
- forbiddenPath="/403"
- defaultPath="/"
- >
-
-
-)
-
-// Защищаем UI-элементы в любом месте дерева
-const AdminPanel = () => (
-
- Управление пользователями
-
-)
```
diff --git a/docs/ru/examples/rbac.md b/docs/ru/examples/rbac.md
index 4456f7a..4fc6f51 100644
--- a/docs/ru/examples/rbac.md
+++ b/docs/ru/examples/rbac.md
@@ -1,80 +1,20 @@
# RBAC — ролевой доступ
```ts
-// types.ts — твои типы, библиотека не диктует форму
-type User = {
- id: string
- roles: ('admin' | 'manager' | 'viewer')[]
-}
-```
-
-```ts
-// router.ts
-import { createAccessRouter } from '@react-protected/react-router'
-
-export const router = createAccessRouter(
- [
- { path: '/login', element: , access: 'guest-only' },
- { path: '/dashboard', element: , access: 'authenticated' },
- {
- path: '/admin',
- element: ,
- access: 'authenticated',
- roles: ['admin'], // только admin
- },
- {
- path: '/reports',
- element: ,
- access: 'authenticated',
- roles: ['admin', 'manager'], // admin ИЛИ manager
- },
- { path: '/403', element: },
- ],
- {
- getUser: () => useAuthStore.getState().user,
-
- // Ты сам определяешь логику — здесь OR-семантика
- hasRole: (user: User, roles) => roles.some((r) => user.roles.includes(r)),
-
- loginPath: '/login',
- forbiddenPath: '/403',
- }
-)
-```
-
-## Защита UI-элементов
-
-Используй `HasAccess` или `useHasAccess`, чтобы скрывать элементы по ролям — без смены маршрута:
-
-```tsx
-import { HasAccess } from '@react-protected/react-router'
-
-const Toolbar = () => (
-
-
- Удалить пользователя
-
-
-
- Экспорт отчёта
-
-
-)
+const accessMiddleware = createAccessMiddleware({
+ getUser: () => useAuthStore.getState().user,
+ hasRole: (user, roles) => roles.some((role) => user.roles.includes(role)),
+ onDenied: ({ result }) => {
+ if (result.reason === 'forbidden') return redirect('/403')
+ return redirect('/login')
+ },
+})
```
-## Иерархия ролей
-
-Если у тебя иерархия (admin включает права manager, manager включает права viewer):
-
```ts
-const HIERARCHY: Record = {
- admin: ['admin', 'manager', 'viewer'],
- manager: ['manager', 'viewer'],
- viewer: ['viewer'],
+{
+ path: '/admin',
+ middleware: [accessMiddleware({ access: 'authenticated', roles: ['admin'] })],
+ element: ,
}
-
-hasRole: (user, roles) =>
- user.roles.some((userRole) =>
- roles.some((required) => HIERARCHY[userRole]?.includes(required))
- )
```
diff --git a/eslint.config.mjs b/eslint.config.mjs
index 9ab8ac4..c27308c 100644
--- a/eslint.config.mjs
+++ b/eslint.config.mjs
@@ -23,7 +23,7 @@ const compat = new FlatCompat({
const commonFiles = ['**/*.{js,cjs,mjs,ts,tsx}']
const tsFiles = ['**/*.{ts,tsx}']
const testFiles = ['**/tests/**/*.{ts,tsx}', '**/*.{test,spec}.{ts,tsx}']
-const configFiles = ['eslint.config.mjs', 'packages/*/vite.config.ts']
+const configFiles = ['eslint.config.mjs', 'packages/*/vite.config.ts', 'apps/*/vite.config.ts']
export default [
{
@@ -82,6 +82,7 @@ export default [
'**/*.{test,spec}.{ts,tsx}',
'eslint.config.mjs',
'packages/*/vite.config.ts',
+ 'apps/*/vite.config.ts',
],
},
],
diff --git a/package.json b/package.json
index d249083..1850039 100644
--- a/package.json
+++ b/package.json
@@ -1,18 +1,20 @@
{
"name": "react-protected",
- "version": "0.0.1-beta.2",
+ "version": "0.3.0",
"private": true,
"license": "MIT",
"packageManager": "pnpm@9.7.1",
"scripts": {
"build": "pnpm -r build",
+ "build:playground": "pnpm --filter @react-protected/playground build",
"changeset": "changeset",
+ "dev:playground": "pnpm --filter @react-protected/playground dev",
"version-packages": "changeset version",
"release": "changeset publish",
"test": "pnpm -r test",
"typecheck": "tsc --noEmit -p tsconfig.eslint.json",
- "lint": "ESLINT_USE_FLAT_CONFIG=true eslint \"packages/*/src/**/*.{ts,tsx}\" \"packages/*/tests/**/*.{ts,tsx}\" \"packages/*/vite.config.ts\"",
- "lint:fix": "ESLINT_USE_FLAT_CONFIG=true eslint \"packages/*/src/**/*.{ts,tsx}\" \"packages/*/tests/**/*.{ts,tsx}\" \"packages/*/vite.config.ts\" --fix",
+ "lint": "ESLINT_USE_FLAT_CONFIG=true eslint --no-error-on-unmatched-pattern \"packages/*/src/**/*.{ts,tsx}\" \"packages/*/tests/**/*.{ts,tsx}\" \"packages/*/vite.config.ts\" \"apps/*/src/**/*.{ts,tsx}\" \"apps/*/vite.config.ts\"",
+ "lint:fix": "ESLINT_USE_FLAT_CONFIG=true eslint --no-error-on-unmatched-pattern \"packages/*/src/**/*.{ts,tsx}\" \"packages/*/tests/**/*.{ts,tsx}\" \"packages/*/vite.config.ts\" \"apps/*/src/**/*.{ts,tsx}\" \"apps/*/vite.config.ts\" --fix",
"format": "prettier . --write",
"format:check": "prettier . --check"
},
diff --git a/packages/core/README.md b/packages/core/README.md
index 094e3aa..d216b70 100644
--- a/packages/core/README.md
+++ b/packages/core/README.md
@@ -1,22 +1,6 @@
# @react-protected/core
-Framework-agnostic access-control logic. No dependency on React, a router, or a store.
-
----
-
-## Installation
-
-```bash
-npm install @react-protected/core
-```
-
-```bash
-yarn add @react-protected/core
-```
-
-```bash
-pnpm add @react-protected/core
-```
+Framework-agnostic access-control logic. No dependency on React, a router, or redirect policy.
## Usage
@@ -27,36 +11,20 @@ const guard = createGuard({
getUser: () => store.getState().user,
hasRole: (user, roles) => roles.some((role) => user.roles.includes(role)),
hasPermission: (user, permissions) =>
- permissions.every((p) => user.permissions.includes(p)),
+ permissions.every((permission) => user.permissions.includes(permission)),
})
const result = guard.check({ access: 'authenticated', roles: ['admin'] })
-if (result.allowed) {
- // permit access
-} else {
- // result.reason: 'unauthenticated' | 'forbidden'
+if (!result.allowed) {
+ // result.reason: 'unauthenticated' | 'authenticated' | 'forbidden'
}
```
-### Implicit auth requirement
-
-When `roles` or `permissions` are set without an explicit `access` field, the route is treated as `'authenticated'` automatically:
-
-```ts
-guard.check({ roles: ['admin'] })
-// equivalent to: guard.check({ access: 'authenticated', roles: ['admin'] })
-```
-
-## Packages
-
-| Package | Description |
-| --- | --- |
-| `@react-protected/core` | This package — pure access-control logic |
-| `@react-protected/react` | React context, hooks, and `HasAccess` component |
-| `@react-protected/react-router` | Adapter for React Router |
+Supported access levels:
-## Documentation
+- `'public'`
+- `'authenticated'`
+- `'unauthenticated'`
-- [Core API](https://github.com/astakhovaskold/react-protected/blob/main/docs/en/api/core.md)
-- [Examples](https://github.com/astakhovaskold/react-protected/blob/main/docs/en/README.md)
+When `roles` or `permissions` are set without `access`, the guard treats the config as authenticated-only.
diff --git a/packages/core/src/createGuard.ts b/packages/core/src/createGuard.ts
index b5b073b..82f15bd 100644
--- a/packages/core/src/createGuard.ts
+++ b/packages/core/src/createGuard.ts
@@ -26,6 +26,12 @@ export function createGuard(options: GuardOptions): Guar
Boolean(config.roles?.length) ||
Boolean(config.permissions?.length)
+ if (access === 'unauthenticated') {
+ return authenticated
+ ? { allowed: false, reason: 'authenticated' }
+ : { allowed: true }
+ }
+
if (requiresAuth && !authenticated) {
return { allowed: false, reason: 'unauthenticated' }
}
diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts
index e12dbe6..7cf012d 100644
--- a/packages/core/src/types.ts
+++ b/packages/core/src/types.ts
@@ -1,14 +1,15 @@
/**
* Access level handled by the framework-agnostic guard.
*/
-export type AccessLevel = 'public' | 'authenticated'
+export type AccessLevel = 'public' | 'authenticated' | 'unauthenticated'
/**
* Access requirements consumed by `guard.check()` and adapter components.
*/
export type AccessConfig = {
/**
- * Declares whether access is public or requires an authenticated user.
+ * Declares whether access is public, requires an authenticated user, or requires
+ * an unauthenticated user.
* Defaults to `'public'` when omitted.
*/
access?: AccessLevel
@@ -32,6 +33,7 @@ export type AccessConfig = {
export type AccessResult =
| { allowed: true }
| { allowed: false; reason: 'unauthenticated' }
+ | { allowed: false; reason: 'authenticated' }
| { allowed: false; reason: 'forbidden' }
/**
diff --git a/packages/core/tests/createGuard.test.ts b/packages/core/tests/createGuard.test.ts
index 31d099d..0ce1c37 100644
--- a/packages/core/tests/createGuard.test.ts
+++ b/packages/core/tests/createGuard.test.ts
@@ -29,6 +29,19 @@ describe('createGuard', () => {
})
})
+ it('returns authenticated for unauthenticated-only route when user exists', () => {
+ const guard = makeGuard({ roles: ['viewer'] })
+ expect(guard.check({ access: 'unauthenticated' })).toEqual({
+ allowed: false,
+ reason: 'authenticated',
+ })
+ })
+
+ it('allows unauthenticated-only route when no user exists', () => {
+ const guard = makeGuard(null)
+ expect(guard.check({ access: 'unauthenticated' })).toEqual({ allowed: true })
+ })
+
it('allows user with correct role', () => {
const guard = makeGuard({ roles: ['admin'] })
expect(guard.check({ access: 'authenticated', roles: ['admin'] })).toEqual({
diff --git a/packages/react-router/README.md b/packages/react-router/README.md
index 295be29..bad5dbf 100644
--- a/packages/react-router/README.md
+++ b/packages/react-router/README.md
@@ -1,113 +1,50 @@
# @react-protected/react-router
-React Router adapter for [react-protected](https://github.com/astakhovaskold/react-protected). Includes `@react-protected/react` — no need to install both.
-
----
-
-## Installation
-
-```bash
-npm install @react-protected/react-router
-```
-
-```bash
-yarn add @react-protected/react-router
-```
-
-```bash
-pnpm add @react-protected/react-router
-```
+React Router helpers for `react-protected`. Includes `@react-protected/react`.
## Usage
-### Data router (recommended)
+### Middleware / loader / action
```tsx
-import { createAccessRouter } from '@react-protected/react-router'
-import { useAuthStore } from './entities/auth'
-
-const router = createAccessRouter(
- [
- { path: '/', element: },
- { path: '/login', element: , access: 'guest-only' },
- { path: '/dashboard', element: , access: 'authenticated' },
- { path: '/admin', element: , access: 'authenticated', roles: ['admin'] },
- { path: '/403', element: },
- ],
- {
- getUser: () => useAuthStore.getState().user,
- hasRole: (user, roles) => roles.some((role) => user.roles.includes(role)),
- loginPath: '/login',
- forbiddenPath: '/403',
- defaultPath: '/dashboard',
- }
-)
-
-// App.tsx
-import { RouterProvider } from 'react-router-dom'
-export const App = () =>
+import { createBrowserRouter, redirect } from 'react-router-dom'
+import {
+ createAccessAction,
+ createAccessLoader,
+ createAccessMiddleware,
+} from '@react-protected/react-router'
+
+const accessOptions = {
+ getUser: () => authStore.getState().user,
+ hasRole: (user, roles) => roles.some((role) => user.roles.includes(role)),
+ hasPermission: (user, permissions) =>
+ permissions.every((permission) => user.permissions.includes(permission)),
+ onDenied: ({ result }) => {
+ switch (result.reason) {
+ case 'unauthenticated':
+ return redirect('/login')
+ case 'authenticated':
+ return redirect('/dashboard')
+ case 'forbidden':
+ return redirect('/403')
+ }
+ },
+}
+
+const accessMiddleware = createAccessMiddleware(accessOptions)
+const accessLoader = createAccessLoader(accessOptions)
+const accessAction = createAccessAction(accessOptions)
```
-### JSX routes
+### `AccessRoute` fallback
```tsx
-import { Route, Routes } from 'react-router-dom'
-import { AccessProvider, AccessRoute } from '@react-protected/react-router'
-
-const App = () => (
- useAuthStore.getState().user}
- hasRole={(user, roles) => roles.some((role) => user.roles.includes(role))}
- loginPath="/login"
- forbiddenPath="/403"
- defaultPath="/dashboard"
- >
-
- } />
-
-
-
- }
- />
-
-
-
- }
- />
-
-
-)
+ {reason}
}
+>
+
+
```
-### Guarding UI elements
-
-```tsx
-import { HasAccess } from '@react-protected/react-router'
-
-const Toolbar = () => (
-
-
- Delete
-
-
-)
-```
-
-## Packages
-
-| Package | Description |
-| --- | --- |
-| `@react-protected/core` | Pure access-control logic — no React, no router |
-| `@react-protected/react` | React context, hooks, and `HasAccess` component |
-| `@react-protected/react-router` | This package — adapter for React Router |
-
-## Documentation
-
-- [React Router API](https://github.com/astakhovaskold/react-protected/blob/main/docs/en/api/react-router.md)
-- [Examples](https://github.com/astakhovaskold/react-protected/blob/main/docs/en/README.md)
+The router package decides whether access is allowed and why it was denied. Redirect policy belongs to the application through `onDenied` or `renderDenied`.
diff --git a/packages/react-router/src/AccessRoute.tsx b/packages/react-router/src/AccessRoute.tsx
index 964573f..90bccbf 100644
--- a/packages/react-router/src/AccessRoute.tsx
+++ b/packages/react-router/src/AccessRoute.tsx
@@ -1,10 +1,9 @@
-import type { AccessResult } from '@react-protected/core'
-import type { RouteProtection } from '@react-protected/react'
import { useAccess } from '@react-protected/react'
import { memo } from 'react'
-import { Navigate, Outlet, useLocation } from 'react-router-dom'
+import { Outlet } from 'react-router-dom'
-import type { AccessRouteProps } from './types'
+import type { AccessRouteProps, RouterRouteConfig } from './types'
+import { resolveRouteAccess, type RouteAccessResult } from './utils/route-access'
/**
* Evaluates route protection with the active access context.
@@ -12,16 +11,16 @@ import type { AccessRouteProps } from './types'
* @param config - Access requirements to evaluate for the current route.
* @returns The guard result for the provided route protection config.
*/
-export function useRouteAccess(config: RouteProtection): AccessResult {
+export function useRouteAccess(config: RouterRouteConfig): RouteAccessResult {
const { guard } = useAccess()
- return guard.check(config)
+ return resolveRouteAccess(guard, config)
}
/**
- * Protects a route element and redirects when access is denied.
+ * Protects a route element and renders a denied fallback when access is denied.
*
* @param props - Route protection rules and optional child content.
- * @returns The protected children, an `Outlet`, or a redirecting `Navigate` element.
+ * @returns The protected children, an `Outlet`, or denied fallback content.
*/
export const AccessRoute = memo(({
access,
@@ -29,30 +28,13 @@ export const AccessRoute = memo(({
permissions,
meta,
children,
+ renderDenied,
}: AccessRouteProps) => {
- const { guard, loginPath, forbiddenPath, defaultPath, callbackUrlParam, shouldAddCallbackUrl } = useAccess()
- const location = useLocation()
-
- if (access === 'guest-only') {
- const user = guard.options.getUser()
- const isAuth = guard.options.isAuthenticated(user)
- if (isAuth) return
- return children ??
- }
-
- const result = guard.check({ access, roles, permissions, meta })
+ const { guard } = useAccess()
+ const result = resolveRouteAccess(guard, { access, roles, permissions, meta })
if (!result.allowed) {
- if (result.reason === 'unauthenticated') {
- const currentPath = `${location.pathname}${location.search}${location.hash}`
- const addCallback = callbackUrlParam && (shouldAddCallbackUrl?.() ?? true)
- const redirectTo = addCallback
- ? `${loginPath}?${callbackUrlParam}=${encodeURIComponent(currentPath)}`
- : loginPath
- return
- }
-
- return
+ return renderDenied?.(result) ?? null
}
return children ??
diff --git a/packages/react-router/src/createAccessAction.ts b/packages/react-router/src/createAccessAction.ts
new file mode 100644
index 0000000..c39af22
--- /dev/null
+++ b/packages/react-router/src/createAccessAction.ts
@@ -0,0 +1,40 @@
+import { createGuard } from '@react-protected/core'
+import type { ActionFunction, ActionFunctionArgs } from 'react-router-dom'
+
+import type { CreateAccessHelpersConfig, RouterRouteConfig } from './types'
+import { resolveRouteAccess } from './utils/route-access'
+
+/**
+ * Creates a guarded React Router action.
+ *
+ * @typeParam TUser - User shape returned by `getUser`.
+ * @param options - Guard callbacks and denied handler used by protected actions.
+ * @returns A factory that wraps route actions with access checks and denied handling.
+ */
+export function createAccessAction(
+ options: CreateAccessHelpersConfig
+) {
+ const { onDenied, ...guardOptions } = options
+ const guard = createGuard(guardOptions)
+
+ return function accessAction(
+ config: RouterRouteConfig,
+ action?: (args: ActionFunctionArgs) => TResult
+ ): ActionFunction {
+ return (args) => {
+ const result = resolveRouteAccess(guard, config)
+
+ if (!result.allowed) {
+ return onDenied({
+ result,
+ request: args.request,
+ params: args.params,
+ context: args.context,
+ config,
+ })
+ }
+
+ return action ? action(args) : null
+ }
+ }
+}
diff --git a/packages/react-router/src/createAccessLoader.ts b/packages/react-router/src/createAccessLoader.ts
new file mode 100644
index 0000000..e18db80
--- /dev/null
+++ b/packages/react-router/src/createAccessLoader.ts
@@ -0,0 +1,40 @@
+import { createGuard } from '@react-protected/core'
+import type { LoaderFunction, LoaderFunctionArgs } from 'react-router-dom'
+
+import type { CreateAccessHelpersConfig, RouterRouteConfig } from './types'
+import { resolveRouteAccess } from './utils/route-access'
+
+/**
+ * Creates a guarded React Router loader.
+ *
+ * @typeParam TUser - User shape returned by `getUser`.
+ * @param options - Guard callbacks and denied handler used by protected loaders.
+ * @returns A factory that wraps route loaders with access checks and denied handling.
+ */
+export function createAccessLoader(
+ options: CreateAccessHelpersConfig
+) {
+ const { onDenied, ...guardOptions } = options
+ const guard = createGuard(guardOptions)
+
+ return function accessLoader(
+ config: RouterRouteConfig,
+ loader?: (args: LoaderFunctionArgs) => TResult
+ ): LoaderFunction {
+ return (args) => {
+ const result = resolveRouteAccess(guard, config)
+
+ if (!result.allowed) {
+ return onDenied({
+ result,
+ request: args.request,
+ params: args.params,
+ context: args.context,
+ config,
+ })
+ }
+
+ return loader ? loader(args) : null
+ }
+ }
+}
diff --git a/packages/react-router/src/createAccessMiddleware.ts b/packages/react-router/src/createAccessMiddleware.ts
new file mode 100644
index 0000000..d68d99c
--- /dev/null
+++ b/packages/react-router/src/createAccessMiddleware.ts
@@ -0,0 +1,37 @@
+import { createGuard } from '@react-protected/core'
+import type { MiddlewareFunction } from 'react-router-dom'
+
+import type { CreateAccessHelpersConfig, RouterRouteConfig } from './types'
+import { resolveRouteAccess } from './utils/route-access'
+
+/**
+ * Creates a React Router middleware factory for guarded routes.
+ *
+ * @typeParam TUser - User shape returned by `getUser`.
+ * @param options - Guard callbacks and denied handler used by protected routes.
+ * @returns A factory that creates middleware for route-level access checks.
+ */
+export function createAccessMiddleware(
+ options: CreateAccessHelpersConfig
+) {
+ const { onDenied, ...guardOptions } = options
+ const guard = createGuard(guardOptions)
+
+ return function accessMiddleware(config: RouterRouteConfig): MiddlewareFunction {
+ return async (args, next) => {
+ const result = resolveRouteAccess(guard, config)
+
+ if (!result.allowed) {
+ return onDenied({
+ result,
+ request: args.request,
+ params: args.params,
+ context: args.context,
+ config,
+ })
+ }
+
+ return next()
+ }
+ }
+}
diff --git a/packages/react-router/src/createAccessRouter.tsx b/packages/react-router/src/createAccessRouter.tsx
deleted file mode 100644
index 32e8cfa..0000000
--- a/packages/react-router/src/createAccessRouter.tsx
+++ /dev/null
@@ -1,222 +0,0 @@
-import type { AccessConfig, Guard } from '@react-protected/core'
-import { createGuard } from '@react-protected/core'
-import {
- createBrowserRouter,
- redirect,
- type RouteObject,
-} from 'react-router-dom'
-
-import type {
- CreateAccessRouterConfig,
- CreateAccessRouterOptions,
- ProtectedRouteObject,
- RouterRouteConfig,
-} from './types'
-
-type RouterGuardContext = {
- guard: Guard
- protection: RouterRouteConfig
- loginPath: string
- forbiddenPath: string
- defaultPath: string
- callbackUrlParam?: string
- shouldAddCallbackUrl?: () => boolean
-}
-
-type LazyRouteLoader = Record Promise) | undefined>
-
-function buildRedirect(
- reason: string,
- currentPath: string,
- loginPath: string,
- forbiddenPath: string,
- defaultPath: string,
- callbackUrlParam?: string,
- shouldAddCallbackUrl?: () => boolean
-): string {
- if (reason === 'unauthenticated') {
- const addCallback = callbackUrlParam && (shouldAddCallbackUrl?.() ?? true)
- return addCallback
- ? `${loginPath}?${callbackUrlParam}=${encodeURIComponent(currentPath)}`
- : loginPath
- }
- if (reason === 'forbidden') return forbiddenPath
- 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(
- (resolved, ctx) => resolved ?? getGuardRedirect(ctx, currentPath),
- null
- )
-}
-
-function wrapDataFunction(
- handler: ((args: TArgs) => TResult) | boolean | undefined,
- guardChain: Array>
-) {
- 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}`
- const redirectTo = getFirstGuardRedirect(guardChain, currentPath)
- if (redirectTo) return redirect(redirectTo) as TResult
- return handler(args)
- }) as typeof handler
-}
-
-async function resolveLazyObject(lazyObject: LazyRouteLoader) {
- const entries = await Promise.all(
- Object.entries(lazyObject).map(async ([key, load]) => [key, await load?.()] as const)
- )
- return Object.fromEntries(entries)
-}
-
-function wrapLazyRoute(
- lazy: ProtectedRouteObject['lazy'],
- guardChain: Array>,
- preserveStaticUi: boolean
-): RouteObject['lazy'] | undefined {
- if (!lazy) return undefined
-
- const resolveRoute =
- typeof lazy === 'function' ? lazy : () => resolveLazyObject(lazy as LazyRouteLoader)
-
- return async () => {
- const resolvedRoute = (await resolveRoute()) as
- | {
- loader?: ProtectedRouteObject['loader']
- action?: ProtectedRouteObject['action']
- [key: string]: unknown
- }
- | undefined
-
- if (!resolvedRoute) return {}
-
- const { loader, action, element, Component, ...routeProps } = resolvedRoute
-
- return {
- ...routeProps,
- ...(preserveStaticUi ? {} : { element, Component }),
- loader: guardChain.length > 0
- ? wrapDataFunction(loader ?? (() => null), guardChain)
- : loader,
- action: wrapDataFunction(action, guardChain),
- }
- }
-}
-
-/**
- * Creates a browser router with access checks applied to protected routes.
- *
- * @typeParam TUser - User shape returned by `getUser`.
- * @param routes - Route objects extended with access protection fields.
- * @param options - Guard callbacks and navigation settings used by protected routes.
- * @param routerOptions - Extra options forwarded to `createBrowserRouter`.
- * @returns A React Router browser router with protected UI, loaders, actions, and lazy routes.
- */
-export function createAccessRouter(
- routes: Array>,
- options: CreateAccessRouterConfig,
- routerOptions?: CreateAccessRouterOptions
-): ReturnType {
- const {
- loginPath = '/login',
- forbiddenPath = '/403',
- defaultPath = '/',
- callbackUrlParam,
- shouldAddCallbackUrl,
- ...guardOptions
- } = options
-
- const guard = createGuard(guardOptions)
-
- const transform = (
- inputRoutes: Array>,
- inheritedGuardChain: Array> = []
- ): Array =>
- inputRoutes.map((route) => {
- const {
- access, roles, permissions, meta,
- children, element, Component, loader, action, lazy,
- ...routeProps
- } = route
-
- const hasGuardConfig =
- access !== undefined ||
- Boolean(roles?.length) ||
- Boolean(permissions?.length) ||
- meta !== undefined
-
- const ownGuardContext = hasGuardConfig
- ? {
- guard,
- protection: { access, roles, permissions, meta },
- loginPath,
- forbiddenPath,
- defaultPath,
- callbackUrlParam,
- shouldAddCallbackUrl,
- }
- : undefined
-
- const guardChain = ownGuardContext
- ? [...inheritedGuardChain, ownGuardContext]
- : inheritedGuardChain
-
- // For guarded lazy routes without a static loader, skip injecting a static loader
- // so React Router uses the lazy-resolved loader (which wrapLazyRoute guards).
- const guardedLoader = hasGuardConfig && (!lazy || loader !== undefined)
- ? wrapDataFunction(loader ?? (() => null), guardChain)
- : wrapDataFunction(loader, guardChain)
-
- return {
- ...routeProps,
- element,
- Component,
- loader: guardedLoader,
- action: wrapDataFunction(action, guardChain),
- lazy: wrapLazyRoute(
- lazy,
- guardChain,
- hasGuardConfig && ((element !== undefined && element !== null) || Component != null)
- ),
- children: children ? transform(children, guardChain) : undefined,
- } as RouteObject
- })
-
- return createBrowserRouter(transform(routes), routerOptions)
-}
diff --git a/packages/react-router/src/index.ts b/packages/react-router/src/index.ts
index 6b6812b..2a25ab0 100644
--- a/packages/react-router/src/index.ts
+++ b/packages/react-router/src/index.ts
@@ -1,17 +1,17 @@
export { AccessRoute, useRouteAccess } from './AccessRoute'
-export { createAccessRouter } from './createAccessRouter'
+export { createAccessAction } from './createAccessAction'
+export { createAccessLoader } from './createAccessLoader'
+export { createAccessMiddleware } from './createAccessMiddleware'
export type {
+ AccessDeniedArgs,
AccessRouteProps,
- CreateAccessRouterConfig,
- CreateAccessRouterOptions,
- ProtectedRouteObject,
+ CreateAccessHelpersConfig,
RouterAccessLevel,
RouterRouteConfig,
} from './types'
export type {
AccessContextValue,
AccessProviderProps,
- NavigationConfig,
RouteProtection,
} from '@react-protected/react'
export {
diff --git a/packages/react-router/src/types.ts b/packages/react-router/src/types.ts
index 4b254ba..74dd7e7 100644
--- a/packages/react-router/src/types.ts
+++ b/packages/react-router/src/types.ts
@@ -1,22 +1,17 @@
-import type { AccessLevel, GuardOptions } from '@react-protected/core'
-import type { NavigationConfig, RouteProtection } from '@react-protected/react'
+import type { AccessLevel, AccessResult, GuardOptions } from '@react-protected/core'
+import type { RouteProtection } from '@react-protected/react'
import type { ReactNode } from 'react'
-import type { createBrowserRouter, RouteObject } from 'react-router-dom'
+import type { Params } from 'react-router-dom'
/**
* Access level supported by the React Router adapter.
*/
-export type RouterAccessLevel = AccessLevel | 'guest-only'
+export type RouterAccessLevel = AccessLevel
/**
* Route protection config accepted by router-aware APIs.
*/
-export type RouterRouteConfig = Omit & {
- /**
- * Access level for the route, including support for guest-only screens.
- */
- access?: RouterAccessLevel
-}
+export type RouterRouteConfig = RouteProtection
/**
* Props accepted by `AccessRoute`.
@@ -26,25 +21,45 @@ export type AccessRouteProps = RouterRouteConfig & {
* Route element rendered when access is allowed.
*/
children?: ReactNode
+ /**
+ * Rendered when access is denied.
+ */
+ renderDenied?: (result: Extract) => ReactNode
}
/**
- * React Router route object extended with access protection fields.
+ * Arguments passed to an `onDenied` callback.
*/
-export type ProtectedRouteObject = Omit &
- RouterRouteConfig & {
- /**
- * Nested child routes that inherit parent guard behavior.
- */
- children?: Array>
- }
-
-/**
- * Additional options forwarded to `createBrowserRouter`.
- */
-export type CreateAccessRouterOptions = Parameters[1]
+export type AccessDeniedArgs = {
+ /**
+ * Access result that caused the denial.
+ */
+ result: Extract
+ /**
+ * Original request handled by the route helper.
+ */
+ request: Request
+ /**
+ * Route params from the matched route.
+ */
+ params: Params
+ /**
+ * Route context received from React Router.
+ */
+ context: TContext
+ /**
+ * Route config evaluated for this access check.
+ */
+ config: RouterRouteConfig
+}
/**
- * Guard callbacks and navigation settings accepted by `createAccessRouter`.
+ * Guard callbacks and denied handling accepted by middleware/loader/action helpers.
*/
-export type CreateAccessRouterConfig = GuardOptions & NavigationConfig
+export type CreateAccessHelpersConfig =
+ GuardOptions & {
+ /**
+ * Called when access is denied.
+ */
+ onDenied: (args: AccessDeniedArgs) => TResult
+ }
diff --git a/packages/react-router/src/utils/route-access.ts b/packages/react-router/src/utils/route-access.ts
new file mode 100644
index 0000000..69b41f0
--- /dev/null
+++ b/packages/react-router/src/utils/route-access.ts
@@ -0,0 +1,14 @@
+import type { AccessResult, Guard } from '@react-protected/core'
+
+import type { RouterRouteConfig } from '../types'
+
+export type RouteAccessResult = AccessResult
+
+export type DeniedRouteAccessResult = Extract
+
+export function resolveRouteAccess(
+ guard: Guard,
+ config: RouterRouteConfig
+): RouteAccessResult {
+ return guard.check(config)
+}
diff --git a/packages/react-router/tests/access-helpers.test.tsx b/packages/react-router/tests/access-helpers.test.tsx
new file mode 100644
index 0000000..ae9be0a
--- /dev/null
+++ b/packages/react-router/tests/access-helpers.test.tsx
@@ -0,0 +1,206 @@
+/* @vitest-environment jsdom */
+
+import { cleanup, render, screen } from '@testing-library/react'
+import {
+ type ActionFunctionArgs,
+ createMemoryRouter,
+ type LoaderFunctionArgs,
+ type MiddlewareFunction,
+ redirect,
+ RouterProvider,
+} from 'react-router-dom'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+
+import {
+ createAccessAction,
+ createAccessLoader,
+ createAccessMiddleware,
+} from '../src'
+
+const NativeRequest = globalThis.Request
+
+class TestRequest extends NativeRequest {
+ constructor(input: ConstructorParameters[0], init?: RequestInit) {
+ super(input, init ? { ...init, signal: undefined } : init)
+ }
+}
+
+describe('route access helpers', () => {
+ afterEach(() => {
+ cleanup()
+ globalThis.Request = NativeRequest
+ })
+
+ it('does not execute wrapped loader when access is denied', async () => {
+ let loaderCalls = 0
+
+ const accessLoader = createAccessLoader({
+ getUser: () => null,
+ onDenied: ({ result }) => redirect(`/${result.reason}`),
+ })
+ const loader = accessLoader(
+ { access: 'authenticated' },
+ async () => {
+ loaderCalls += 1
+ return 'private data'
+ }
+ )
+
+ const result = await loader({
+ request: new Request('https://example.test/private?from=email'),
+ params: {},
+ context: undefined,
+ } as LoaderFunctionArgs)
+
+ expect(loaderCalls).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('/unauthenticated')
+ })
+
+ it('returns null when access is allowed and no loader is provided', async () => {
+ const accessLoader = createAccessLoader({
+ getUser: () => ({ id: 'user-1' }),
+ onDenied: () => redirect('/denied'),
+ })
+ const loader = accessLoader({ access: 'authenticated' })
+
+ expect(
+ loader({
+ request: new Request('https://example.test/private'),
+ params: {},
+ context: undefined,
+ } as LoaderFunctionArgs)
+ ).toBeNull()
+ })
+
+ it('does not execute wrapped action when access is denied and delegates to onDenied', async () => {
+ let actionCalls = 0
+
+ const accessAction = createAccessAction({
+ getUser: () => null,
+ onDenied: ({ result, request }) => {
+ const url = new URL(request.url)
+ return redirect(`/${result.reason}?next=${encodeURIComponent(url.pathname + url.search)}`)
+ },
+ })
+ const action = accessAction(
+ { access: 'authenticated' },
+ async () => {
+ actionCalls += 1
+ return { ok: true }
+ }
+ )
+
+ const result = await action({
+ request: new Request('https://example.test/private?from=email', { method: 'POST' }),
+ params: {},
+ context: undefined,
+ } as ActionFunctionArgs)
+
+ expect(actionCalls).toBe(0)
+ expect(result instanceof Response).toBe(true)
+ if (!(result instanceof Response)) throw new Error('Expected Response')
+ expect(result.headers.get('Location')).toBe('/unauthenticated?next=%2Fprivate%3Ffrom%3Demail')
+ })
+
+ it('blocks child branch before loader execution when parent middleware redirects', async () => {
+ let childLoaderCalls = 0
+
+ const accessMiddleware = createAccessMiddleware({
+ getUser: () => null,
+ onDenied: ({ result }) => redirect(`/${result.reason}`),
+ })
+ globalThis.Request = TestRequest as typeof Request
+
+ const router = createMemoryRouter(
+ [
+ {
+ path: '/private',
+ middleware: [accessMiddleware({ access: 'authenticated' })],
+ children: [
+ {
+ index: true,
+ loader: async () => {
+ childLoaderCalls += 1
+ return null
+ },
+ Component: () => private page
,
+ },
+ ],
+ },
+ {
+ path: '/unauthenticated',
+ Component: () => unauthenticated page
,
+ },
+ ],
+ {
+ initialEntries: ['/private'],
+ future: { v8_middleware: true },
+ }
+ )
+
+ render( )
+
+ expect(await screen.findByText('unauthenticated page')).toBeTruthy()
+ expect(childLoaderCalls).toBe(0)
+ router.dispose()
+ })
+
+ it('delegates authenticated denials for unauthenticated-only routes', async () => {
+ const accessMiddleware = createAccessMiddleware({
+ getUser: () => ({ id: 'user-1' }),
+ onDenied: ({ result }) => redirect(`/${result.reason}`),
+ })
+ globalThis.Request = TestRequest as typeof Request
+
+ const router = createMemoryRouter(
+ [
+ {
+ path: '/login',
+ middleware: [accessMiddleware({ access: 'unauthenticated' })],
+ Component: () => login page
,
+ },
+ {
+ path: '/authenticated',
+ Component: () => authenticated page
,
+ },
+ ],
+ {
+ initialEntries: ['/login'],
+ future: { v8_middleware: true },
+ }
+ )
+
+ render( )
+
+ expect(await screen.findByText('authenticated page')).toBeTruthy()
+ router.dispose()
+ })
+
+ it('calls next in middleware when access is allowed', async () => {
+ const accessMiddleware = createAccessMiddleware({
+ getUser: () => ({ role: 'admin' }),
+ hasRole: (user, roles) => roles.includes(user.role),
+ onDenied: () => redirect('/denied'),
+ })
+
+ const middleware = accessMiddleware({ roles: ['admin'] }) as MiddlewareFunction
+ const next = vi.fn(async () => redirect('/dashboard'))
+
+ const result = await middleware(
+ {
+ request: new Request('https://example.test/admin'),
+ params: {},
+ context: {} as LoaderFunctionArgs['context'],
+ } as Parameters>[0],
+ next
+ )
+
+ expect(next).toHaveBeenCalledTimes(1)
+ expect(result instanceof Response).toBe(true)
+ if (!(result instanceof Response)) throw new Error('Expected Response')
+ expect(result.headers.get('Location')).toBe('/dashboard')
+ })
+})
diff --git a/packages/react-router/tests/build.test.ts b/packages/react-router/tests/build.test.ts
index 32f297a..11ce833 100644
--- a/packages/react-router/tests/build.test.ts
+++ b/packages/react-router/tests/build.test.ts
@@ -35,17 +35,31 @@ describe('package build', () => {
logLevel: 'silent',
})
- const [accessRouteDeclarations, createAccessRouterDeclarations, testingDeclarations] = await Promise.all([
+ const [
+ accessRouteDeclarations,
+ createAccessActionDeclarations,
+ createAccessLoaderDeclarations,
+ createAccessMiddlewareDeclarations,
+ testingDeclarations,
+ ] = await Promise.all([
readFile(join(distDir, 'AccessRoute.d.ts'), 'utf8'),
- readFile(join(distDir, 'createAccessRouter.d.ts'), 'utf8'),
+ readFile(join(distDir, 'createAccessAction.d.ts'), 'utf8'),
+ readFile(join(distDir, 'createAccessLoader.d.ts'), 'utf8'),
+ readFile(join(distDir, 'createAccessMiddleware.d.ts'), 'utf8'),
readFile(join(distDir, 'testing.d.ts'), 'utf8'),
])
expect(accessRouteDeclarations).toContain(
- 'Protects a route element and redirects when access is denied.'
+ 'Protects a route element and renders a denied fallback when access is denied.'
)
- expect(createAccessRouterDeclarations).toContain(
- 'Creates a browser router with access checks applied to protected routes.'
+ expect(createAccessActionDeclarations).toContain(
+ 'Creates a guarded React Router action.'
+ )
+ expect(createAccessLoaderDeclarations).toContain(
+ 'Creates a guarded React Router loader.'
+ )
+ expect(createAccessMiddlewareDeclarations).toContain(
+ 'Creates a React Router middleware factory for guarded routes.'
)
expect(testingDeclarations).toContain('Test helper that provides a predictable access context.')
})
diff --git a/packages/react-router/tests/create-guarded-router.test.tsx b/packages/react-router/tests/create-guarded-router.test.tsx
deleted file mode 100644
index 8e97905..0000000
--- a/packages/react-router/tests/create-guarded-router.test.tsx
+++ /dev/null
@@ -1,394 +0,0 @@
-/* @vitest-environment jsdom */
-
-import { cleanup, render, screen } from '@testing-library/react'
-import {
- type ActionFunctionArgs,
- type RouteObject,
- RouterProvider,
-} from 'react-router-dom'
-import { afterEach, describe, expect, it, vi } from 'vitest'
-
-import type { ProtectedRouteObject } from '../src/types'
-import {
- captureCreateAccessRouterCall,
- createAccessMemoryRouter,
- NativeRequest,
-} from './test-helpers'
-
-describe('createAccessRouter', () => {
- afterEach(() => {
- cleanup()
- vi.resetModules()
- vi.doUnmock('react-router-dom')
- globalThis.Request = NativeRequest
- })
-
- it('renders child routes for guarded layout routes with element null', async () => {
- const router = await createAccessMemoryRouter(
- [
- {
- path: '/layout',
- access: 'authenticated',
- element: null,
- children: [{ index: true, element: layout child
}],
- },
- ],
- { getUser: () => ({ id: 'user-1' }) },
- ['/layout']
- )
-
- render( )
- expect(await screen.findByText('layout child')).toBeTruthy()
- router.dispose()
- })
-
- it('renders guarded Component routes', async () => {
- const router = await createAccessMemoryRouter(
- [
- {
- path: '/component',
- access: 'authenticated',
- Component: () => component dashboard
,
- },
- ],
- { getUser: () => ({ id: 'user-1' }) },
- ['/component']
- )
-
- render( )
- expect(await screen.findByText('component dashboard')).toBeTruthy()
- router.dispose()
- })
-
- it('renders guarded function-form lazy routes', async () => {
- const router = await createAccessMemoryRouter(
- [
- {
- path: '/lazy',
- access: 'authenticated',
- lazy: async () => ({ Component: () => lazy dashboard
}),
- },
- ],
- { getUser: () => ({ id: 'user-1' }) },
- ['/lazy']
- )
-
- render( )
- expect(await screen.findByText('lazy dashboard')).toBeTruthy()
- router.dispose()
- })
-
- it('renders guarded object-form lazy routes', async () => {
- const router = await createAccessMemoryRouter(
- [
- {
- path: '/lazy-object',
- access: 'authenticated',
- lazy: { Component: async () => () => lazy object dashboard
},
- },
- ],
- { getUser: () => ({ id: 'user-1' }) },
- ['/lazy-object']
- )
-
- render( )
- expect(await screen.findByText('lazy object dashboard')).toBeTruthy()
- router.dispose()
- })
-
- it('does not execute loader when access is denied', async () => {
- let loaderCalls = 0
-
- const router = await createAccessMemoryRouter(
- [
- {
- path: '/private',
- access: 'authenticated',
- loader: () => { loaderCalls += 1; return null },
- element: private page
,
- },
- { path: '/login', element: login page
},
- ],
- { getUser: () => null },
- ['/private']
- )
-
- render( )
- expect(await screen.findByText('login page')).toBeTruthy()
- expect(loaderCalls).toBe(0)
- 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
-
- 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',
- action: async () => { actionCalls += 1; return null },
- element: private page
,
- },
- ],
- { getUser: () => null }
- )
-
- vi.doUnmock('react-router-dom')
-
- const action = capturedRoutes?.[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(actionCalls).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('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
-
- 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',
- action: async () => null,
- element: private
,
- },
- ],
- { getUser: () => null, callbackUrlParam: 'next', shouldAddCallbackUrl: () => false }
- )
-
- vi.doUnmock('react-router-dom')
-
- const action = capturedRoutes?.[0]?.action
- 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(result instanceof Response).toBe(true)
- if (!(result instanceof Response)) throw new Error('Expected Response')
- expect(result.headers.get('Location')).toBe('/login')
- })
-
- it('appends callbackUrl in action redirect when callbackUrlParam is set', async () => {
- 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',
- action: async () => null,
- element: private
,
- },
- ],
- { getUser: () => null, callbackUrlParam: 'callbackUrl' }
- )
-
- vi.doUnmock('react-router-dom')
-
- const action = capturedRoutes?.[0]?.action
- if (typeof action !== 'function') throw new Error('Expected action to be function')
-
- const result = await action({
- request: new Request('https://example.test/private?from=email', { method: 'POST' }),
- params: {},
- context: undefined,
- } as ActionFunctionArgs)
-
- expect(result instanceof Response).toBe(true)
- if (!(result instanceof Response)) throw new Error('Expected Response')
- expect(result.headers.get('Location')).toBe('/login?callbackUrl=%2Fprivate%3Ffrom%3Demail')
- })
-
- it('preserves static UI over function-form lazy UI', async () => {
- const router = await createAccessMemoryRouter(
- [
- {
- path: '/lazy-static',
- access: 'authenticated',
- element: static dashboard
,
- lazy: async () => ({
- Component: () => lazy dashboard
,
- loader: async () => 'loader data',
- }),
- },
- ],
- { getUser: () => ({ id: 'user-1' }) },
- ['/lazy-static']
- )
-
- render( )
- expect(await screen.findByText('static dashboard')).toBeTruthy()
- expect(screen.queryByText('lazy dashboard')).toBeNull()
- router.dispose()
- })
-
- it('passes router options through to createBrowserRouter', async () => {
- const [, routerOptions] = await captureCreateAccessRouterCall(
- [{ path: '/app', element: app
} satisfies ProtectedRouteObject<{ id: string }>],
- { getUser: () => ({ id: 'user-1' }) },
- { basename: '/base', future: { v8_middleware: true } }
- )
-
- expect(routerOptions).toEqual({
- basename: '/base',
- future: { v8_middleware: true },
- })
- })
-})
diff --git a/packages/react-router/tests/guard-provider.test.tsx b/packages/react-router/tests/guard-provider.test.tsx
index c0bda73..9fc9e11 100644
--- a/packages/react-router/tests/guard-provider.test.tsx
+++ b/packages/react-router/tests/guard-provider.test.tsx
@@ -1,8 +1,8 @@
/* @vitest-environment jsdom */
import { AccessProvider } from '@react-protected/react'
-import { cleanup, render, renderHook,screen } from '@testing-library/react'
-import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom'
+import { cleanup, render, renderHook, screen } from '@testing-library/react'
+import { MemoryRouter, Route, Routes } from 'react-router-dom'
import { afterEach, describe, expect, it } from 'vitest'
import { AccessRoute, useRouteAccess } from '../src/AccessRoute'
@@ -17,273 +17,150 @@ describe('AccessRoute', () => {
getUser={() => ({ role: 'admin', permissions: ['reports:read'] })}
hasRole={(user, roles) => roles.includes(user.role)}
hasPermission={(user, permissions) =>
- permissions.every((p) => user.permissions.includes(p))
+ permissions.every((permission) => user.permissions.includes(permission))
}
>
{reason}
}
>
dashboard
- }
+ )}
/>
)
+
expect(screen.getByText('dashboard')).toBeTruthy()
})
- it('renders Outlet when no children provided (layout guard pattern)', () => {
+ it('renders Outlet when no children are provided', () => {
render(
({ id: 1 })}>
- }>
+ denied
} />}
+ >
outlet content} />
)
+
expect(screen.getByText('outlet content')).toBeTruthy()
})
- it('redirects unauthenticated user to loginPath', () => {
+ it('renders denied content for unauthenticated users', () => {
render(
- null} loginPath="/login">
+ null}>
- private
-
- }
- />
- } />
-
-
-
- )
- expect(screen.getByTestId('login')).toBeTruthy()
- })
-
- it('appends callbackUrl when callbackUrlParam is configured', () => {
- function LoginPage() {
- const location = useLocation()
- return (
-
- {new URLSearchParams(location.search).get('next')}
-
- )
- }
-
- render(
-
- null} callbackUrlParam="next">
-
-
+ element={(
+ {reason}
}
+ >
private
- }
+ )}
/>
- } />
)
- expect(screen.getByTestId('callback').textContent).toBe(
- '/private?tab=overview#section'
- )
- })
- it('omits callbackUrl when shouldAddCallbackUrl returns false', () => {
- function LoginPage() {
- const location = useLocation()
- return (
-
- {location.pathname}
- {location.search}
-
- )
- }
-
- render(
-
- null}
- callbackUrlParam="next"
- shouldAddCallbackUrl={() => false}
- >
-
-
- private
-
- }
- />
- } />
-
-
-
- )
- expect(screen.getByTestId('login').textContent).toBe('/login')
+ expect(screen.getByText('unauthenticated')).toBeTruthy()
})
- it('appends callbackUrl when shouldAddCallbackUrl returns true', () => {
- function LoginPage() {
- const location = useLocation()
- return (
-
- {new URLSearchParams(location.search).get('next')}
-
- )
- }
-
- render(
-
- null}
- callbackUrlParam="next"
- shouldAddCallbackUrl={() => true}
- >
-
-
- private
-
- }
- />
- } />
-
-
-
- )
- expect(screen.getByTestId('callback').textContent).toBe('/private')
- })
-
- it('redirects authenticated user away from guest-only route', () => {
+ it('renders denied content for authenticated users on unauthenticated-only routes', () => {
render(
- ({ role: 'member' })} defaultPath="/home">
+ ({ role: 'member' })}>
- guest page
+ element={(
+ {reason}
}
+ >
+ login page
- }
+ )}
/>
- home page} />
)
- expect(screen.getByText('home page')).toBeTruthy()
+
+ expect(screen.getByText('authenticated')).toBeTruthy()
})
- it('redirects user without required role to forbiddenPath', () => {
+ it('renders denied content for forbidden users', () => {
render(
({ role: 'member' })}
hasRole={(user, roles) => roles.includes(user.role)}
- forbiddenPath="/403"
>
+ element={(
+ {reason}
}
+ >
admin
- }
+ )}
/>
- forbidden} />
)
- expect(screen.getByText('forbidden')).toBeTruthy()
- })
-
- it('updates redirect path when provider loginPath changes', () => {
- function LoginPage() {
- return {useLocation().pathname}
- }
- const { rerender } = render(
-
- null} loginPath="/login">
-
-
- private
-
- }
- />
- } />
- } />
-
-
-
- )
- expect(screen.getByTestId('login-path').textContent).toBe('/login')
-
- rerender(
-
- null} loginPath="/signin">
-
-
- private
-
- }
- />
- } />
- } />
-
-
-
- )
- expect(screen.getByTestId('login-path').textContent).toBe('/signin')
+ expect(screen.getByText('forbidden')).toBeTruthy()
})
})
describe('useRouteAccess', () => {
afterEach(cleanup)
- it('returns full AccessResult from guard.check()', () => {
- const { result } = renderHook(
- () => useRouteAccess({ access: 'authenticated' }),
- {
- wrapper: ({ children }) => (
-
- null}>{children}
-
- ),
- }
- )
+ it('returns unauthenticated for protected routes without a user', () => {
+ const { result } = renderHook(() => useRouteAccess({ access: 'authenticated' }), {
+ wrapper: ({ children }) => (
+
+ null}>{children}
+
+ ),
+ })
+
expect(result.current).toEqual({ allowed: false, reason: 'unauthenticated' })
})
+
+ it('returns authenticated for unauthenticated-only routes with a user', () => {
+ const { result } = renderHook(() => useRouteAccess({ access: 'unauthenticated' }), {
+ wrapper: ({ children }) => (
+
+ ({ id: 1 })}>{children}
+
+ ),
+ })
+
+ expect(result.current).toEqual({ allowed: false, reason: 'authenticated' })
+ })
})
diff --git a/packages/react-router/tests/route-access.test.ts b/packages/react-router/tests/route-access.test.ts
new file mode 100644
index 0000000..503b785
--- /dev/null
+++ b/packages/react-router/tests/route-access.test.ts
@@ -0,0 +1,59 @@
+import { createGuard } from '@react-protected/core'
+import { describe, expect, it } from 'vitest'
+
+import {
+ resolveRouteAccess,
+ type RouteAccessResult,
+} from '../src/utils/route-access'
+
+function createTestGuard(user: null | { role?: string; permissions?: Array }) {
+ return createGuard({
+ getUser: () => user,
+ hasRole: (currentUser, roles) => roles.includes(currentUser.role ?? ''),
+ hasPermission: (currentUser, permissions) =>
+ permissions.every((permission) => currentUser.permissions?.includes(permission)),
+ })
+}
+
+describe('route access primitives', () => {
+ it.each<[string, null | { role?: string; permissions?: Array }, Parameters[1], RouteAccessResult]>([
+ [
+ 'allows unauthenticated-only routes for anonymous users',
+ null,
+ { access: 'unauthenticated' },
+ { allowed: true },
+ ],
+ [
+ 'rejects authenticated users from unauthenticated-only routes',
+ { role: 'member' },
+ { access: 'unauthenticated' },
+ { allowed: false, reason: 'authenticated' },
+ ],
+ [
+ 'returns unauthenticated for protected routes without user',
+ null,
+ { access: 'authenticated' },
+ { allowed: false, reason: 'unauthenticated' },
+ ],
+ [
+ 'returns forbidden when role check fails',
+ { role: 'member' },
+ { roles: ['admin'] },
+ { allowed: false, reason: 'forbidden' },
+ ],
+ [
+ 'returns forbidden when permission check fails',
+ { permissions: ['reports:read'] },
+ { permissions: ['reports:write'] },
+ { allowed: false, reason: 'forbidden' },
+ ],
+ [
+ 'allows access when permission check passes',
+ { permissions: ['reports:read'] },
+ { permissions: ['reports:read'] },
+ { allowed: true },
+ ],
+ ])('%s', (_, user, config, expected) => {
+ expect(resolveRouteAccess(createTestGuard(user), config)).toEqual(expected)
+ })
+})
diff --git a/packages/react-router/tests/test-helpers.tsx b/packages/react-router/tests/test-helpers.tsx
deleted file mode 100644
index 908b07e..0000000
--- a/packages/react-router/tests/test-helpers.tsx
+++ /dev/null
@@ -1,78 +0,0 @@
-import type { RouteObject } from 'react-router-dom'
-import { vi } from 'vitest'
-
-import type { CreateAccessRouterConfig,ProtectedRouteObject } from '../src/types'
-
-export const NativeRequest = globalThis.Request
-
-export class TestRequest extends NativeRequest {
- constructor(input: ConstructorParameters[0], init?: RequestInit) {
- super(input, init ? { ...init, signal: undefined } : init)
- }
-}
-
-export async function createAccessMemoryRouter(
- routes: Array>,
- options: CreateAccessRouterConfig,
- initialEntries: Array,
- routerOptions?: { basename?: string; future?: Record }
-) {
- vi.resetModules()
- globalThis.Request = TestRequest as typeof Request
-
- vi.doMock('react-router-dom', async () => {
- const actual =
- await vi.importActual('react-router-dom')
-
- return {
- ...actual,
- createBrowserRouter: (
- guardedRoutes: Array,
- createRouterOptions?: { basename?: string; future?: Record }
- ) =>
- actual.createMemoryRouter(guardedRoutes, {
- initialEntries,
- basename: createRouterOptions?.basename,
- future: createRouterOptions?.future,
- }),
- }
- })
-
- const { createAccessRouter } = await import('../src/createAccessRouter')
- const router = createAccessRouter(routes, options, routerOptions)
-
- vi.doUnmock('react-router-dom')
-
- return router
-}
-
-export async function captureCreateAccessRouterCall(
- routes: Array>,
- options: CreateAccessRouterConfig,
- routerOptions?: { basename?: string; future?: Record }
-) {
- vi.resetModules()
-
- const createBrowserRouterSpy = vi.fn(() => ({ mocked: true }))
-
- vi.doMock('react-router-dom', async () => {
- const actual =
- await vi.importActual('react-router-dom')
-
- return {
- ...actual,
- createBrowserRouter: createBrowserRouterSpy,
- }
- })
-
- const { createAccessRouter } = await import('../src/createAccessRouter')
-
- createAccessRouter(routes, options, routerOptions)
-
- vi.doUnmock('react-router-dom')
-
- return createBrowserRouterSpy.mock.calls[0] as unknown as [
- unknown,
- { basename?: string; future?: Record } | undefined,
- ]
-}
diff --git a/packages/react-router/vite.config.ts b/packages/react-router/vite.config.ts
index 6642ef4..54e46e9 100644
--- a/packages/react-router/vite.config.ts
+++ b/packages/react-router/vite.config.ts
@@ -37,4 +37,11 @@ export default defineConfig({
],
},
},
+ test: {
+ alias: {
+ '@react-protected/core': resolve(packageRoot, '../core/src/index.ts'),
+ '@react-protected/react': resolve(packageRoot, '../react/src/index.ts'),
+ '@react-protected/react/testing': resolve(packageRoot, '../react/src/testing.tsx'),
+ },
+ },
})
diff --git a/packages/react/README.md b/packages/react/README.md
index b1d13aa..a6ffdf2 100644
--- a/packages/react/README.md
+++ b/packages/react/README.md
@@ -1,82 +1,33 @@
# @react-protected/react
-React context, hooks, and `HasAccess` component for [react-protected](https://github.com/astakhovaskold/react-protected).
-
-> **Using React Router?** Install [`@react-protected/react-router`](https://www.npmjs.com/package/@react-protected/react-router) instead — it includes this package.
-
----
-
-## Installation
-
-```bash
-npm install @react-protected/core @react-protected/react
-```
-
-```bash
-yarn add @react-protected/core @react-protected/react
-```
-
-```bash
-pnpm add @react-protected/core @react-protected/react
-```
+React context, hooks, and `HasAccess` component for `react-protected`.
## Usage
-### AccessProvider
-
-Wrap your app with `AccessProvider` to provide the guard to the component tree:
-
```tsx
-import { AccessProvider } from '@react-protected/react'
+import { AccessProvider, HasAccess, useHasAccess } from '@react-protected/react'
const App = () => (
authStore.user}
hasRole={(user, roles) => roles.some((role) => user.roles.includes(role))}
- loginPath="/login"
- forbiddenPath="/403"
- defaultPath="/dashboard"
>
-
+
)
-```
-
-### HasAccess
-
-Conditionally render UI elements based on access:
-
-```tsx
-import { HasAccess } from '@react-protected/react'
-
-const Toolbar = () => (
-
-
- Delete
-
-
-)
-```
-
-### useHasAccess
-Hook version of `HasAccess`:
-
-```tsx
-import { useHasAccess } from '@react-protected/react'
-
-const canDelete = useHasAccess({ roles: ['admin'] })
+const Toolbar = () => {
+ const canDelete = useHasAccess({ roles: ['admin'] })
+
+ return (
+
+
+ Delete
+
+ {canDelete ? Danger zone : null}
+
+ )
+}
```
-## Packages
-
-| Package | Description |
-| --- | --- |
-| `@react-protected/core` | Pure access-control logic — no React, no router |
-| `@react-protected/react` | This package — React context, hooks, and `HasAccess` |
-| `@react-protected/react-router` | Adapter for React Router |
-
-## Documentation
-
-- [Full documentation](https://github.com/astakhovaskold/react-protected/blob/main/docs/en/README.md)
-- [Examples](https://github.com/astakhovaskold/react-protected/blob/main/docs/en/examples/basic.md)
+`AccessProvider` only provides the guard. It does not store redirect paths or route policy.
diff --git a/packages/react/src/AccessProvider.tsx b/packages/react/src/AccessProvider.tsx
index 07d1a5f..348c0b2 100644
--- a/packages/react/src/AccessProvider.tsx
+++ b/packages/react/src/AccessProvider.tsx
@@ -14,11 +14,6 @@ const AccessContext = createContext(null)
*/
export function AccessProvider({
children,
- loginPath = '/login',
- forbiddenPath = '/403',
- defaultPath = '/',
- callbackUrlParam,
- shouldAddCallbackUrl,
getUser,
isAuthenticated,
hasRole,
@@ -32,13 +27,8 @@ export function AccessProvider({
const value = useMemo(
() => ({
guard: guard as AccessContextValue['guard'],
- loginPath,
- forbiddenPath,
- defaultPath,
- callbackUrlParam,
- shouldAddCallbackUrl,
}),
- [guard, loginPath, forbiddenPath, defaultPath, callbackUrlParam, shouldAddCallbackUrl]
+ [guard]
)
return (
@@ -52,7 +42,7 @@ export function AccessProvider({
* Returns the active access context from `AccessProvider`.
*
* @typeParam TUser - User shape stored in the access context.
- * @returns The guard instance and navigation settings for the current subtree.
+ * @returns The guard instance for the current subtree.
* @throws {Error} When called outside an `AccessProvider`.
*/
export function useAccess(): AccessContextValue {
diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts
index f1512c2..5bcc249 100644
--- a/packages/react/src/index.ts
+++ b/packages/react/src/index.ts
@@ -4,6 +4,5 @@ export type {
AccessContextValue,
AccessProviderProps,
AccessRouteProps,
- NavigationConfig,
RouteProtection,
} from './types'
diff --git a/packages/react/src/testing.tsx b/packages/react/src/testing.tsx
index 1046a05..73c96f2 100644
--- a/packages/react/src/testing.tsx
+++ b/packages/react/src/testing.tsx
@@ -2,13 +2,12 @@ import type { GuardOptions } from '@react-protected/core'
import type { ReactNode } from 'react'
import { AccessProvider } from './AccessProvider'
-import type { NavigationConfig } from './types'
/**
* Props accepted by `MockAccessProvider`.
*/
export type MockAccessProviderProps = Partial> &
- NavigationConfig & {
+ {
/**
* User returned by the default `getUser` implementation.
*/
@@ -28,6 +27,7 @@ export type MockAccessProviderProps = Partial({
isAuthenticated,
hasRole,
hasPermission,
- loginPath,
- forbiddenPath,
- defaultPath,
- callbackUrlParam,
- shouldAddCallbackUrl,
}: MockAccessProviderProps) {
return (
({
isAuthenticated={isAuthenticated ?? (() => allowed)}
hasRole={hasRole ?? (() => allowed)}
hasPermission={hasPermission ?? (() => allowed)}
- loginPath={loginPath}
- forbiddenPath={forbiddenPath}
- defaultPath={defaultPath}
- callbackUrlParam={callbackUrlParam}
- shouldAddCallbackUrl={shouldAddCallbackUrl}
>
{children}
diff --git a/packages/react/src/types.ts b/packages/react/src/types.ts
index bb416aa..2e57713 100644
--- a/packages/react/src/types.ts
+++ b/packages/react/src/types.ts
@@ -6,32 +6,6 @@ import type { ReactNode } from 'react'
*/
export type RouteProtection = CoreAccessConfig
-/**
- * Navigation paths used when access-aware components need to redirect.
- */
-export type NavigationConfig = {
- /**
- * Redirect target for unauthenticated users.
- */
- loginPath?: string
- /**
- * Redirect target when the user is authenticated but lacks access.
- */
- forbiddenPath?: string
- /**
- * Redirect target for authenticated users visiting guest-only screens.
- */
- defaultPath?: string
- /**
- * Query parameter name used to preserve the current location during login redirects.
- */
- callbackUrlParam?: string
- /**
- * Decides whether the callback URL should be attached to an unauthenticated redirect.
- */
- shouldAddCallbackUrl?: () => boolean
-}
-
/**
* Access context exposed by `useAccess()`.
*/
@@ -40,38 +14,17 @@ export type AccessContextValue = {
* Guard instance used to evaluate access rules.
*/
guard: Guard
- /**
- * Redirect target for unauthenticated users.
- */
- loginPath: string
- /**
- * Redirect target when the user is authenticated but forbidden.
- */
- forbiddenPath: string
- /**
- * Redirect target for authenticated users on guest-only screens.
- */
- defaultPath: string
- /**
- * Query parameter name used to preserve the current location during login redirects.
- */
- callbackUrlParam?: string
- /**
- * Decides whether the callback URL should be attached to an unauthenticated redirect.
- */
- shouldAddCallbackUrl?: () => boolean
}
/**
* Props accepted by `AccessProvider`.
*/
-export type AccessProviderProps = GuardOptions &
- NavigationConfig & {
- /**
- * React subtree that consumes the access context.
- */
- children?: ReactNode
- }
+export type AccessProviderProps = GuardOptions & {
+ /**
+ * React subtree that consumes the access context.
+ */
+ children?: ReactNode
+}
/**
* Access requirements accepted by `useHasAccess()` and `HasAccess`.
diff --git a/packages/react/tests/access-provider.test.tsx b/packages/react/tests/access-provider.test.tsx
index 21cca27..c5a40a2 100644
--- a/packages/react/tests/access-provider.test.tsx
+++ b/packages/react/tests/access-provider.test.tsx
@@ -5,7 +5,6 @@ import { renderToString } from 'react-dom/server'
import { describe, expect, it } from 'vitest'
import { AccessProvider, useAccess } from '../src/AccessProvider'
-import type { AccessContextValue } from '../src/types'
describe('AccessProvider', () => {
it('throws when useAccess is called outside the provider', () => {
@@ -19,51 +18,26 @@ describe('AccessProvider', () => {
)
})
- it('provides guard and navigation config to descendants', () => {
- let ctx: AccessContextValue<{ role: string }> | undefined
+ it('provides guard to descendants', () => {
+ let ctx: ReturnType> | undefined
function Consumer() {
ctx = useAccess<{ role: string }>()
return null
}
- const shouldAddCallbackUrl = () => true
-
renderToString(
({ role: 'admin' })}
hasRole={(user, roles) => roles.includes(user.role)}
- loginPath="/auth"
- forbiddenPath="/no-access"
- defaultPath="/home"
- callbackUrlParam="next"
- shouldAddCallbackUrl={shouldAddCallbackUrl}
>
)
- expect(ctx?.loginPath).toBe('/auth')
- expect(ctx?.forbiddenPath).toBe('/no-access')
- expect(ctx?.defaultPath).toBe('/home')
- expect(ctx?.callbackUrlParam).toBe('next')
- expect(ctx?.shouldAddCallbackUrl).toBe(shouldAddCallbackUrl)
expect(ctx?.guard.check({ roles: ['admin'] })).toEqual({ allowed: true })
})
- it('uses default navigation paths when not provided', () => {
- const { result } = renderHook(() => useAccess(), {
- wrapper: ({ children }) => (
- null}>{children}
- ),
- })
-
- expect(result.current.loginPath).toBe('/login')
- expect(result.current.forbiddenPath).toBe('/403')
- expect(result.current.defaultPath).toBe('/')
- expect(result.current.callbackUrlParam).toBeUndefined()
- })
-
it('creates a new guard when props change', () => {
// unauthenticated
expect(
@@ -83,4 +57,17 @@ describe('AccessProvider', () => {
}).result.current.guard.check({ access: 'authenticated' }).allowed
).toBe(true)
})
+
+ it('supports unauthenticated access checks through the shared guard', () => {
+ const { result } = renderHook(() => useAccess(), {
+ wrapper: ({ children }) => (
+ ({ id: 1 })}>{children}
+ ),
+ })
+
+ expect(result.current.guard.check({ access: 'unauthenticated' })).toEqual({
+ allowed: false,
+ reason: 'authenticated',
+ })
+ })
})
diff --git a/packages/react/tests/mock-access-provider.test.tsx b/packages/react/tests/mock-access-provider.test.tsx
index ebe72f2..07adff1 100644
--- a/packages/react/tests/mock-access-provider.test.tsx
+++ b/packages/react/tests/mock-access-provider.test.tsx
@@ -31,6 +31,7 @@ describe('MockAccessProvider — defaults', () => {
const { result } = renderHook(
() => ({
auth: useHasAccess({ access: 'authenticated' }),
+ unauth: useHasAccess({ access: 'unauthenticated' }),
role: useHasAccess({ roles: ['admin'] }),
perm: useHasAccess({ permissions: ['reports:write'] }),
}),
@@ -42,34 +43,17 @@ describe('MockAccessProvider — defaults', () => {
)
expect(result.current.auth).toBe(false)
+ expect(result.current.unauth).toBe(true)
expect(result.current.role).toBe(false)
expect(result.current.perm).toBe(false)
})
-
- it('uses default navigation paths', () => {
+
+ it('provides a guard through useAccess()', () => {
const { result } = renderHook(() => useAccess(), {
wrapper: ({ children }) => {children} ,
})
- expect(result.current.loginPath).toBe('/login')
- expect(result.current.forbiddenPath).toBe('/403')
- expect(result.current.defaultPath).toBe('/')
- })
-})
-
-describe('MockAccessProvider — navigation config', () => {
- it('forwards custom navigation paths', () => {
- const { result } = renderHook(() => useAccess(), {
- wrapper: ({ children }) => (
-
- {children}
-
- ),
- })
-
- expect(result.current.loginPath).toBe('/auth')
- expect(result.current.forbiddenPath).toBe('/no-access')
- expect(result.current.defaultPath).toBe('/home')
+ expect(result.current.guard.check({ access: 'authenticated' })).toEqual({ allowed: true })
})
})
@@ -145,11 +129,11 @@ describe('MockAccessProvider — custom guard overrides', () => {
})
it('uses custom isAuthenticated when provided', () => {
- const { result } = renderHook(() => useHasAccess({ access: 'authenticated' }), {
+ const { result } = renderHook(() => useHasAccess({ access: 'unauthenticated' }), {
wrapper: ({ children }) => (
false}
+ user={{ id: 1, roles: [], authorities: [] }}
+ isAuthenticated={() => true}
>
{children}
diff --git a/packages/react/vite.config.ts b/packages/react/vite.config.ts
index a2b1a44..27b945f 100644
--- a/packages/react/vite.config.ts
+++ b/packages/react/vite.config.ts
@@ -30,4 +30,9 @@ export default defineConfig({
external: ['react', 'react-dom', '@react-protected/core'],
},
},
+ test: {
+ alias: {
+ '@react-protected/core': resolve(packageRoot, '../core/src/index.ts'),
+ },
+ },
})
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 5b2edc2..5c6926a 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -75,6 +75,43 @@ importers:
specifier: ^2.0.0
version: 2.1.9(@types/node@24.10.1)(jsdom@29.1.1)
+ apps/playground:
+ dependencies:
+ '@mantine/core':
+ specifier: ^7.0.0
+ version: 7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.28)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+ '@react-protected/react-router':
+ specifier: workspace:*
+ version: link:../../packages/react-router
+ react:
+ specifier: ^19.2.0
+ version: 19.2.6
+ react-dom:
+ specifier: ^19.2.0
+ version: 19.2.6(react@19.2.6)
+ react-router-dom:
+ specifier: ^7.15.0
+ version: 7.15.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+ devDependencies:
+ '@testing-library/react':
+ specifier: ^16.3.2
+ version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+ '@vitejs/plugin-react':
+ specifier: ^4.0.0
+ version: 4.7.0(vite@5.4.21(@types/node@24.10.1))
+ jsdom:
+ specifier: ^29.1.1
+ version: 29.1.1
+ typescript:
+ specifier: ^5.5.0
+ version: 5.9.3
+ vite:
+ specifier: ^5.0.0
+ version: 5.4.21(@types/node@24.10.1)
+ vitest:
+ specifier: ^2.0.0
+ version: 2.1.9(@types/node@24.10.1)(jsdom@29.1.1)
+
packages/core:
devDependencies:
vite:
@@ -523,6 +560,27 @@ packages:
'@noble/hashes':
optional: true
+ '@floating-ui/core@1.7.5':
+ resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==}
+
+ '@floating-ui/dom@1.7.6':
+ resolution: {integrity: sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==}
+
+ '@floating-ui/react-dom@2.1.8':
+ resolution: {integrity: sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==}
+ peerDependencies:
+ react: '>=16.8.0'
+ react-dom: '>=16.8.0'
+
+ '@floating-ui/react@0.26.28':
+ resolution: {integrity: sha512-yORQuuAtVpiRjpMhdc0wJj06b9JFjrYF4qp96j++v2NBpbi6SEGF7donUJ3TMieerQ6qVkAv1tgr7L4r5roTqw==}
+ peerDependencies:
+ react: '>=16.8.0'
+ react-dom: '>=16.8.0'
+
+ '@floating-ui/utils@0.2.11':
+ resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==}
+
'@humanwhocodes/config-array@0.11.14':
resolution: {integrity: sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==}
engines: {node: '>=10.10.0'}
@@ -561,6 +619,18 @@ packages:
'@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
+ '@mantine/core@7.17.8':
+ resolution: {integrity: sha512-42sfdLZSCpsCYmLCjSuntuPcDg3PLbakSmmYfz5Auea8gZYLr+8SS5k647doVu0BRAecqYOytkX2QC5/u/8VHw==}
+ peerDependencies:
+ '@mantine/hooks': 7.17.8
+ react: ^18.x || ^19.x
+ react-dom: ^18.x || ^19.x
+
+ '@mantine/hooks@7.17.8':
+ resolution: {integrity: sha512-96qygbkTjRhdkzd5HDU8fMziemN/h758/EwrFu7TlWrEP10Vw076u+Ap/sG6OT4RGPZYYoHrTlT+mkCZblWHuw==}
+ peerDependencies:
+ react: ^18.x || ^19.x
+
'@manypkg/find-root@1.1.0':
resolution: {integrity: sha512-mki5uBvhHzO8kYYix/WRy2WX8S3B5wdVSc9D6KcU5lQNglP2yt58/VfLuAK49glRXChosY8ap2oJ1qgma3GUVA==}
@@ -1250,6 +1320,10 @@ packages:
resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==}
engines: {node: '>= 16'}
+ clsx@2.1.1:
+ resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
+ engines: {node: '>=6'}
+
color-convert@2.0.1:
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
engines: {node: '>=7.0.0'}
@@ -1355,6 +1429,9 @@ packages:
resolution: {integrity: sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA==}
engines: {node: '>=8'}
+ detect-node-es@1.1.0:
+ resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==}
+
diff@8.0.4:
resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==}
engines: {node: '>=0.3.1'}
@@ -1713,6 +1790,10 @@ packages:
resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==}
engines: {node: '>= 0.4'}
+ get-nonce@1.0.1:
+ resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==}
+ engines: {node: '>=6'}
+
get-proto@1.0.1:
resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==}
engines: {node: '>= 0.4'}
@@ -2328,10 +2409,36 @@ packages:
react-is@17.0.2:
resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==}
+ react-number-format@5.4.5:
+ resolution: {integrity: sha512-y8O2yHHj3w0aE9XO8d2BCcUOOdQTRSVq+WIuMlLVucAm5XNjJAy+BoOJiuQMldVYVOKTMyvVNfnbl2Oqp+YxGw==}
+ peerDependencies:
+ react: ^0.14 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+ react-dom: ^0.14 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+
react-refresh@0.17.0:
resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==}
engines: {node: '>=0.10.0'}
+ react-remove-scroll-bar@2.3.8:
+ resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==}
+ engines: {node: '>=10'}
+ peerDependencies:
+ '@types/react': '*'
+ react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
+ react-remove-scroll@2.7.2:
+ resolution: {integrity: sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==}
+ engines: {node: '>=10'}
+ peerDependencies:
+ '@types/react': '*'
+ react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
react-router-dom@7.15.0:
resolution: {integrity: sha512-VcrVg64Fo8nwBvDscajG8gRTLIuTC6N50nb22l2HOOV4PTOHgoGp8mUjy9wLiHYoYTSYI36tUnXZgasSRFZorQ==}
engines: {node: '>=20.0.0'}
@@ -2349,6 +2456,22 @@ packages:
react-dom:
optional: true
+ react-style-singleton@2.2.3:
+ resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==}
+ engines: {node: '>=10'}
+ peerDependencies:
+ '@types/react': '*'
+ react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
+ react-textarea-autosize@8.5.9:
+ resolution: {integrity: sha512-U1DGlIQN5AwgjTyOEnI1oCcMuEr1pv1qOtklB2l4nyMGbHzWrI0eFsYK0zos2YWqAolJyG0IWJaqWmWj5ETh0A==}
+ engines: {node: '>=10'}
+ peerDependencies:
+ react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+
react@19.2.6:
resolution: {integrity: sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==}
engines: {node: '>=0.10.0'}
@@ -2570,6 +2693,9 @@ packages:
symbol-tree@3.2.4:
resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==}
+ tabbable@6.4.0:
+ resolution: {integrity: sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==}
+
term-size@2.2.1:
resolution: {integrity: sha512-wK0Ri4fOGjv/XPy8SBHZChl8CM7uMc5VML7SqiQ0zG7+J5Vr+RMQDoHa2CNT6KHUnTGIXH34UDMkPzAUyapBZg==}
engines: {node: '>=8'}
@@ -2638,6 +2764,10 @@ packages:
resolution: {integrity: sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==}
engines: {node: '>=10'}
+ type-fest@4.41.0:
+ resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==}
+ engines: {node: '>=16'}
+
typed-array-buffer@1.0.3:
resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==}
engines: {node: '>= 0.4'}
@@ -2693,6 +2823,53 @@ packages:
uri-js@4.4.1:
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
+ use-callback-ref@1.3.3:
+ resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==}
+ engines: {node: '>=10'}
+ peerDependencies:
+ '@types/react': '*'
+ react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
+ use-composed-ref@1.4.0:
+ resolution: {integrity: sha512-djviaxuOOh7wkj0paeO1Q/4wMZ8Zrnag5H6yBvzN7AKKe8beOaED9SF5/ByLqsku8NP4zQqsvM2u3ew/tJK8/w==}
+ peerDependencies:
+ '@types/react': '*'
+ react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
+ use-isomorphic-layout-effect@1.2.1:
+ resolution: {integrity: sha512-tpZZ+EX0gaghDAiFR37hj5MgY6ZN55kLiPkJsKxBMZ6GZdOSPJXiOzPM984oPYZ5AnehYx5WQp1+ME8I/P/pRA==}
+ peerDependencies:
+ '@types/react': '*'
+ react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
+ use-latest@1.3.0:
+ resolution: {integrity: sha512-mhg3xdm9NaM8q+gLT8KryJPnRFOz1/5XPBhmDEVZK1webPzDjrPk7f/mbpeLqTgB9msytYWANxgALOCJKnLvcQ==}
+ peerDependencies:
+ '@types/react': '*'
+ react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
+ use-sidecar@1.1.3:
+ resolution: {integrity: sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==}
+ engines: {node: '>=10'}
+ peerDependencies:
+ '@types/react': '*'
+ react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
vite-node@2.1.9:
resolution: {integrity: sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==}
engines: {node: ^18.0.0 || >=20.0.0}
@@ -3260,6 +3437,31 @@ snapshots:
'@exodus/bytes@1.15.0': {}
+ '@floating-ui/core@1.7.5':
+ dependencies:
+ '@floating-ui/utils': 0.2.11
+
+ '@floating-ui/dom@1.7.6':
+ dependencies:
+ '@floating-ui/core': 1.7.5
+ '@floating-ui/utils': 0.2.11
+
+ '@floating-ui/react-dom@2.1.8(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
+ dependencies:
+ '@floating-ui/dom': 1.7.6
+ react: 19.2.6
+ react-dom: 19.2.6(react@19.2.6)
+
+ '@floating-ui/react@0.26.28(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
+ dependencies:
+ '@floating-ui/react-dom': 2.1.8(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+ '@floating-ui/utils': 0.2.11
+ react: 19.2.6
+ react-dom: 19.2.6(react@19.2.6)
+ tabbable: 6.4.0
+
+ '@floating-ui/utils@0.2.11': {}
+
'@humanwhocodes/config-array@0.11.14':
dependencies:
'@humanwhocodes/object-schema': 2.0.3
@@ -3298,6 +3500,24 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5
+ '@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.28)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
+ dependencies:
+ '@floating-ui/react': 0.26.28(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+ '@mantine/hooks': 7.17.8(react@19.2.6)
+ clsx: 2.1.1
+ react: 19.2.6
+ react-dom: 19.2.6(react@19.2.6)
+ react-number-format: 5.4.5(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+ react-remove-scroll: 2.7.2(@types/react@18.3.28)(react@19.2.6)
+ react-textarea-autosize: 8.5.9(@types/react@18.3.28)(react@19.2.6)
+ type-fest: 4.41.0
+ transitivePeerDependencies:
+ - '@types/react'
+
+ '@mantine/hooks@7.17.8(react@19.2.6)':
+ dependencies:
+ react: 19.2.6
+
'@manypkg/find-root@1.1.0':
dependencies:
'@babel/runtime': 7.29.2
@@ -4021,6 +4241,8 @@ snapshots:
check-error@2.1.3: {}
+ clsx@2.1.1: {}
+
color-convert@2.0.1:
dependencies:
color-name: 1.1.4
@@ -4113,6 +4335,8 @@ snapshots:
detect-indent@6.1.0: {}
+ detect-node-es@1.1.0: {}
+
diff@8.0.4: {}
dir-glob@3.0.1:
@@ -4630,6 +4854,8 @@ snapshots:
hasown: 2.0.3
math-intrinsics: 1.1.0
+ get-nonce@1.0.1: {}
+
get-proto@1.0.1:
dependencies:
dunder-proto: 1.0.1
@@ -5241,8 +5467,32 @@ snapshots:
react-is@17.0.2: {}
+ react-number-format@5.4.5(react-dom@19.2.6(react@19.2.6))(react@19.2.6):
+ dependencies:
+ react: 19.2.6
+ react-dom: 19.2.6(react@19.2.6)
+
react-refresh@0.17.0: {}
+ react-remove-scroll-bar@2.3.8(@types/react@18.3.28)(react@19.2.6):
+ dependencies:
+ react: 19.2.6
+ react-style-singleton: 2.2.3(@types/react@18.3.28)(react@19.2.6)
+ tslib: 2.8.1
+ optionalDependencies:
+ '@types/react': 18.3.28
+
+ react-remove-scroll@2.7.2(@types/react@18.3.28)(react@19.2.6):
+ dependencies:
+ react: 19.2.6
+ react-remove-scroll-bar: 2.3.8(@types/react@18.3.28)(react@19.2.6)
+ react-style-singleton: 2.2.3(@types/react@18.3.28)(react@19.2.6)
+ tslib: 2.8.1
+ use-callback-ref: 1.3.3(@types/react@18.3.28)(react@19.2.6)
+ use-sidecar: 1.1.3(@types/react@18.3.28)(react@19.2.6)
+ optionalDependencies:
+ '@types/react': 18.3.28
+
react-router-dom@7.15.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6):
dependencies:
react: 19.2.6
@@ -5257,6 +5507,23 @@ snapshots:
optionalDependencies:
react-dom: 19.2.6(react@19.2.6)
+ react-style-singleton@2.2.3(@types/react@18.3.28)(react@19.2.6):
+ dependencies:
+ get-nonce: 1.0.1
+ react: 19.2.6
+ tslib: 2.8.1
+ optionalDependencies:
+ '@types/react': 18.3.28
+
+ react-textarea-autosize@8.5.9(@types/react@18.3.28)(react@19.2.6):
+ dependencies:
+ '@babel/runtime': 7.29.2
+ react: 19.2.6
+ use-composed-ref: 1.4.0(@types/react@18.3.28)(react@19.2.6)
+ use-latest: 1.3.0(@types/react@18.3.28)(react@19.2.6)
+ transitivePeerDependencies:
+ - '@types/react'
+
react@19.2.6: {}
read-yaml-file@1.1.0:
@@ -5540,6 +5807,8 @@ snapshots:
symbol-tree@3.2.4: {}
+ tabbable@6.4.0: {}
+
term-size@2.2.1: {}
text-table@0.2.0: {}
@@ -5588,8 +5857,7 @@ snapshots:
minimist: 1.2.8
strip-bom: 3.0.0
- tslib@2.8.1:
- optional: true
+ tslib@2.8.1: {}
type-check@0.4.0:
dependencies:
@@ -5597,6 +5865,8 @@ snapshots:
type-fest@0.20.2: {}
+ type-fest@4.41.0: {}
+
typed-array-buffer@1.0.3:
dependencies:
call-bound: 1.0.4
@@ -5683,6 +5953,40 @@ snapshots:
dependencies:
punycode: 2.3.1
+ use-callback-ref@1.3.3(@types/react@18.3.28)(react@19.2.6):
+ dependencies:
+ react: 19.2.6
+ tslib: 2.8.1
+ optionalDependencies:
+ '@types/react': 18.3.28
+
+ use-composed-ref@1.4.0(@types/react@18.3.28)(react@19.2.6):
+ dependencies:
+ react: 19.2.6
+ optionalDependencies:
+ '@types/react': 18.3.28
+
+ use-isomorphic-layout-effect@1.2.1(@types/react@18.3.28)(react@19.2.6):
+ dependencies:
+ react: 19.2.6
+ optionalDependencies:
+ '@types/react': 18.3.28
+
+ use-latest@1.3.0(@types/react@18.3.28)(react@19.2.6):
+ dependencies:
+ react: 19.2.6
+ use-isomorphic-layout-effect: 1.2.1(@types/react@18.3.28)(react@19.2.6)
+ optionalDependencies:
+ '@types/react': 18.3.28
+
+ use-sidecar@1.1.3(@types/react@18.3.28)(react@19.2.6):
+ dependencies:
+ detect-node-es: 1.1.0
+ react: 19.2.6
+ tslib: 2.8.1
+ optionalDependencies:
+ '@types/react': 18.3.28
+
vite-node@2.1.9(@types/node@24.10.1):
dependencies:
cac: 6.7.14
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index 18ec407..4e708bd 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -1,2 +1,3 @@
packages:
- 'packages/*'
+ - 'apps/*'
diff --git a/tsconfig.eslint.json b/tsconfig.eslint.json
index 1e76863..4520df4 100644
--- a/tsconfig.eslint.json
+++ b/tsconfig.eslint.json
@@ -8,7 +8,10 @@
"packages/*/src/**/*.tsx",
"packages/*/tests/**/*.ts",
"packages/*/tests/**/*.tsx",
- "packages/*/vite.config.ts"
+ "packages/*/vite.config.ts",
+ "apps/*/src/**/*.ts",
+ "apps/*/src/**/*.tsx",
+ "apps/*/vite.config.ts"
],
"exclude": ["**/dist/**", "node_modules"]
}