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 - -

- License: MIT - TypeScript - Tests: Vitest - Package manager: pnpm - Router: React Router -

- ## 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))} + > + + + + + ) +} ``` -### Guarding UI elements +### `AccessRoute` fallback ```tsx -import { HasAccess } from '@react-protected/react' - -const Toolbar = () => ( - -) + { + 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' - - - - -``` - -## 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 = () => ( -
- - - -
-) - -// Hook form -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/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 = () => ( - - - -) ``` 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 = () => ( - -) +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 = () => ( - -) -``` - -## 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 = () => ( - -) -``` - -### 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 ( + + ) +} ``` -## 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"] }