Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/wide-candles-cover.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@react-protected/react-router': minor
'@react-protected/react': minor
'@react-protected/core': minor
---

Update roadmap and docs
36 changes: 34 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,20 +34,50 @@
- 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 `callbackUrl` flow for returning users after login
- Optional `callbackUrlParam` for redirecting users back to the page they tried to visit after login

## 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` |
| `@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

Expand Down Expand Up @@ -94,6 +124,7 @@ export const router = createAccessRouter(
loginPath: '/login',
forbiddenPath: '/403',
defaultPath: '/dashboard',
callbackUrlParam: 'next',
}
)

Expand All @@ -115,6 +146,7 @@ const App = () => (
loginPath="/login"
forbiddenPath="/403"
defaultPath="/dashboard"
callbackUrlParam="next"
>
<Routes>
<Route path="/" element={<HomePage />} />
Expand Down
18 changes: 17 additions & 1 deletion docs/en/api/core.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,23 @@ const guard = createGuard({
| `hasRole` | `(user, roles) => boolean` | `() => false` | Role check for RBAC |
| `hasPermission` | `(user, permissions) => boolean` | `() => false` | Permission check for ABAC-style access |

Navigation paths (`loginPath`, `forbiddenPath`, `defaultPath`) and `callbackUrlParam` are not part of core — they live in the adapter layer (`AccessProvider` / `createAccessRouter`).
### 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)

Expand Down
36 changes: 28 additions & 8 deletions docs/en/api/react-router.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,12 +35,13 @@ import { AccessProvider } from '@react-protected/react-router'

**Navigation config** (used by adapters for redirects):

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

`AccessProvider` is declarative: when its props change, descendants receive a fresh guard with updated options.

Expand Down Expand Up @@ -155,7 +156,7 @@ 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 } = useAccess<User>()
const { guard, loginPath, forbiddenPath, defaultPath, callbackUrlParam, shouldAddCallbackUrl } = useAccess<User>()
const result = guard.check({ roles: ['admin'] })
```

Expand Down Expand Up @@ -194,7 +195,7 @@ import { HasAccess } from '@react-protected/react-router'
</HasAccess>
```

## callbackUrl flow
## Callback URL flow

When `callbackUrlParam` is set, unauthenticated redirects include the current path:

Expand All @@ -209,3 +210,22 @@ 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
<AccessProvider
callbackUrlParam="next"
shouldAddCallbackUrl={() => !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).
18 changes: 17 additions & 1 deletion docs/ru/api/core.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,23 @@ const guard = createGuard({
| `hasRole` | `(user, roles) => boolean` | `() => false` | Проверка ролей (RBAC) |
| `hasPermission` | `(user, permissions) => boolean` | `() => false` | Проверка прав доступа (ABAC) |

Пути для редиректов (`loginPath`, `forbiddenPath`, `defaultPath`) и `callbackUrlParam` не входят в ядро — они живут на уровне адаптера (`AccessProvider` / `createAccessRouter`).
### Рекомендуемая семантика

Библиотека не навязывает конкретную стратегию сопоставления — семантика полностью определяется твоими реализациями `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)

Expand Down
36 changes: 28 additions & 8 deletions docs/ru/api/react-router.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,12 +35,13 @@ import { AccessProvider } from '@react-protected/react-router'

**Конфигурация навигации** (используется адаптером для редиректов):

| Prop | Тип | Default | Описание |
| ------------------ | -------- | ------------ | --------------------------------------------------------------------- |
| `loginPath` | `string` | `'/login'` | Куда перенаправлять незалогиненных пользователей |
| `forbiddenPath` | `string` | `'/403'` | Куда перенаправлять при нехватке прав |
| `defaultPath` | `string` | `'/'` | Куда перенаправлять залогиненных с `guest-only` маршрутов |
| `callbackUrlParam` | `string` | — | Если указан, добавляет текущий путь как query-параметр при редиректе на логин |
| Prop | Тип | Default | Описание |
| ----------------------- | --------------- | ------------ | --------------------------------------------------------------------------------------------- |
| `loginPath` | `string` | `'/login'` | Куда перенаправлять незалогиненных пользователей |
| `forbiddenPath` | `string` | `'/403'` | Куда перенаправлять при нехватке прав |
| `defaultPath` | `string` | `'/'` | Куда перенаправлять залогиненных с `guest-only` маршрутов |
| `callbackUrlParam` | `string` | — | Если указан, добавляет текущий путь как query-параметр при редиректе на логин |
| `shouldAddCallbackUrl` | `() => boolean` | `() => true` | Вызывается при каждом редиректе незалогиненного — решает, добавлять ли callback URL |

`AccessProvider` декларативный: при изменении props потомки получают новый guard с актуальными опциями.

Expand Down Expand Up @@ -155,7 +156,7 @@ type ProtectedRouteObject = RouteObject & {
```tsx
import { useAccess } from '@react-protected/react-router'

const { guard, loginPath, forbiddenPath, defaultPath, callbackUrlParam } = useAccess<User>()
const { guard, loginPath, forbiddenPath, defaultPath, callbackUrlParam, shouldAddCallbackUrl } = useAccess<User>()
const result = guard.check({ roles: ['admin'] })
```

Expand Down Expand Up @@ -194,7 +195,7 @@ import { HasAccess } from '@react-protected/react-router'
</HasAccess>
```

## Поток callbackUrl
## Callback URL flow

Если указан `callbackUrlParam`, редирект незалогиненного включает текущий путь:

Expand All @@ -209,3 +210,22 @@ const [params] = useSearchParams()
const callbackUrl = params.get('next')
navigate(callbackUrl ?? '/dashboard', { replace: true })
```

### Условный callback URL

`shouldAddCallbackUrl` позволяет отключить добавление callback URL в рантайме, не убирая `callbackUrlParam`. Вызывается при каждом редиректе незалогиненного:

```tsx
<AccessProvider
callbackUrlParam="next"
shouldAddCallbackUrl={() => !authStore.getState().loggedOut}
...
>
```

| Сценарий | Результат |
| -------------------------------- | -------------------------------------------------------------- |
| Сессия истекла (обычный таймаут) | `/login?next=%2Fdashboard` — пользователь вернётся куда шёл |
| Явный выход из системы | `/login` — без callback URL, чистый старт |

Если `shouldAddCallbackUrl` не передан, callback URL добавляется всегда (поведение не меняется).
5 changes: 3 additions & 2 deletions packages/react-router/src/AccessRoute.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export const AccessRoute = memo(({
meta,
children,
}: AccessRouteProps) => {
const { guard, loginPath, forbiddenPath, defaultPath, callbackUrlParam } = useAccess()
const { guard, loginPath, forbiddenPath, defaultPath, callbackUrlParam, shouldAddCallbackUrl } = useAccess()
const location = useLocation()

if (access === 'guest-only') {
Expand All @@ -33,7 +33,8 @@ export const AccessRoute = memo(({
if (!result.allowed) {
if (result.reason === 'unauthenticated') {
const currentPath = `${location.pathname}${location.search}${location.hash}`
const redirectTo = callbackUrlParam
const addCallback = callbackUrlParam && (shouldAddCallbackUrl?.() ?? true)
const redirectTo = addCallback
? `${loginPath}?${callbackUrlParam}=${encodeURIComponent(currentPath)}`
: loginPath
return <Navigate to={redirectTo} replace />
Expand Down
18 changes: 14 additions & 4 deletions packages/react-router/src/createAccessRouter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ type RouterGuardContext<TUser> = {
forbiddenPath: string
defaultPath: string
callbackUrlParam?: string
shouldAddCallbackUrl?: () => boolean
}

type GuardedElementProps<TUser> = RouterRouteConfig & {
Expand All @@ -35,6 +36,7 @@ type GuardedElementProps<TUser> = RouterRouteConfig & {
forbiddenPath: string
defaultPath: string
callbackUrlParam?: string
shouldAddCallbackUrl?: () => boolean
}

type LazyRouteLoader = Record<string, (() => Promise<unknown>) | undefined>
Expand All @@ -45,10 +47,12 @@ function buildRedirect(
loginPath: string,
forbiddenPath: string,
defaultPath: string,
callbackUrlParam?: string
callbackUrlParam?: string,
shouldAddCallbackUrl?: () => boolean
): string {
if (reason === 'unauthenticated') {
return callbackUrlParam
const addCallback = callbackUrlParam && (shouldAddCallbackUrl?.() ?? true)
return addCallback
? `${loginPath}?${callbackUrlParam}=${encodeURIComponent(currentPath)}`
: loginPath
}
Expand All @@ -68,6 +72,7 @@ function GuardedElement<TUser>({
forbiddenPath,
defaultPath,
callbackUrlParam,
shouldAddCallbackUrl,
}: GuardedElementProps<TUser>) {
const location = useLocation()
const currentPath = `${location.pathname}${location.search}${location.hash}`
Expand All @@ -90,7 +95,8 @@ function GuardedElement<TUser>({
loginPath,
forbiddenPath,
defaultPath,
callbackUrlParam
callbackUrlParam,
shouldAddCallbackUrl
)
return <Navigate to={redirectTo} replace />
}
Expand All @@ -114,6 +120,7 @@ function wrapGuardedElement<TUser>(ctx: RouterGuardContext<TUser>, element?: Rea
forbiddenPath={ctx.forbiddenPath}
defaultPath={ctx.defaultPath}
callbackUrlParam={ctx.callbackUrlParam}
shouldAddCallbackUrl={ctx.shouldAddCallbackUrl}
/>
)
}
Expand Down Expand Up @@ -144,7 +151,8 @@ function wrapDataFunction<TUser, TArgs extends { request: Request }, TResult>(
ctx.loginPath,
ctx.forbiddenPath,
ctx.defaultPath,
ctx.callbackUrlParam
ctx.callbackUrlParam,
ctx.shouldAddCallbackUrl
)
return redirect(redirectTo) as TResult
}
Expand Down Expand Up @@ -209,6 +217,7 @@ export function createAccessRouter<TUser = unknown>(
forbiddenPath = '/403',
defaultPath = '/',
callbackUrlParam,
shouldAddCallbackUrl,
...guardOptions
} = options

Expand Down Expand Up @@ -244,6 +253,7 @@ export function createAccessRouter<TUser = unknown>(
forbiddenPath,
defaultPath,
callbackUrlParam,
shouldAddCallbackUrl,
}

const guardedElement =
Expand Down
Loading
Loading