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: 0 additions & 7 deletions .changeset/lemon-rice-create.md

This file was deleted.

15 changes: 0 additions & 15 deletions .changeset/pre.json

This file was deleted.

5 changes: 0 additions & 5 deletions .changeset/smooth-cougars-scream.md

This file was deleted.

7 changes: 0 additions & 7 deletions .changeset/ten-kings-grow.md

This file was deleted.

7 changes: 0 additions & 7 deletions .changeset/wide-candles-cover.md

This file was deleted.

11 changes: 11 additions & 0 deletions packages/core/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,16 @@
# @react-protected/core

## 0.2.0

### Minor Changes

- 05cda4d: Update roadmap and docs

### Patch Changes

- 4b835f6: Docs added
- 11a6537: Configure Changesets-based release automation and public package publish metadata.

## 0.2.0-beta.2

### Minor Changes
Expand Down
9 changes: 9 additions & 0 deletions packages/core/dist/createGuard.d.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,11 @@
import { Guard, GuardOptions } from './types';
/**
* Creates a guard that evaluates access against the current user.
*
* @typeParam TUser - User shape returned by `getUser`.
* @param options - Access callbacks and user accessors used by the guard.
* @returns A reusable guard with resolved defaults for authentication, role, and permission checks.
* @remarks When `roles` or `permissions` are provided without `access`, the guard treats the config
* as authenticated-only.
*/
export declare function createGuard<TUser = unknown>(options: GuardOptions<TUser>): Guard<TUser>;
2 changes: 1 addition & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@react-protected/core",
"version": "0.2.0-beta.2",
"version": "0.2.0",
"license": "MIT",
"description": "Framework-agnostic route protection logic",
"main": "./dist/index.cjs",
Expand Down
9 changes: 9 additions & 0 deletions packages/core/src/createGuard.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
import type { AccessConfig, AccessResult, Guard, GuardOptions } from './types'

/**
* Creates a guard that evaluates access against the current user.
*
* @typeParam TUser - User shape returned by `getUser`.
* @param options - Access callbacks and user accessors used by the guard.
* @returns A reusable guard with resolved defaults for authentication, role, and permission checks.
* @remarks When `roles` or `permissions` are provided without `access`, the guard treats the config
* as authenticated-only.
*/
export function createGuard<TUser = unknown>(options: GuardOptions<TUser>): Guard<TUser> {
const resolved = {
getUser: options.getUser,
Expand Down
46 changes: 46 additions & 0 deletions packages/core/src/types.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,71 @@
/**
* Access level handled by the framework-agnostic guard.
*/
export type AccessLevel = 'public' | 'authenticated'

/**
* Access requirements consumed by `guard.check()` and adapter components.
*/
export type AccessConfig = {
/**
* Declares whether access is public or requires an authenticated user.
* Defaults to `'public'` when omitted.
*/
access?: AccessLevel
/**
* Roles that must be satisfied by your `hasRole` callback.
*/
roles?: Array<string>
/**
* Permissions that must be satisfied by your `hasPermission` callback.
*/
permissions?: Array<string>
/**
* Optional metadata for application-specific access logic.
*/
meta?: Record<string, unknown>
}

/**
* Result returned by `guard.check()`.
*/
export type AccessResult =
| { allowed: true }
| { allowed: false; reason: 'unauthenticated' }
| { allowed: false; reason: 'forbidden' }

/**
* Callbacks and accessors used to create a guard instance.
*/
export type GuardOptions<TUser = unknown> = {
/**
* Returns the current user or `null` when no user is available.
*/
getUser: () => TUser | null
/**
* Overrides the default authenticated check of `user !== null`.
*/
isAuthenticated?: (user: TUser | null) => boolean
/**
* Determines whether the current user satisfies the requested roles.
*/
hasRole?: (user: TUser, roles: Array<string>) => boolean
/**
* Determines whether the current user satisfies the requested permissions.
*/
hasPermission?: (user: TUser, permissions: Array<string>) => boolean
}

/**
* Guard instance returned by `createGuard()`.
*/
export type Guard<TUser = unknown> = {
/**
* Evaluates whether the current user satisfies the provided access config.
*/
check: (config: AccessConfig) => AccessResult
/**
* Resolved guard callbacks with built-in defaults applied.
*/
options: Required<GuardOptions<TUser>>
}
23 changes: 21 additions & 2 deletions packages/core/tests/build.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { access, rm } from 'node:fs/promises'
import { access, readFile, rm } from 'node:fs/promises'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'

import { build } from 'vite'
import { describe, it } from 'vitest'
import { describe, expect, it } from 'vitest'

const packageRoot = fileURLToPath(new URL('..', import.meta.url))
const distDir = join(packageRoot, 'dist')
Expand All @@ -23,4 +23,23 @@ describe('package build', () => {
access(join(distDir, 'index.d.ts')),
])
})

it('preserves public JSDoc in declaration output', async () => {
await rm(distDir, { recursive: true, force: true })

await build({
root: packageRoot,
logLevel: 'silent',
})

const [createGuardDeclarations, typeDeclarations] = await Promise.all([
readFile(join(distDir, 'createGuard.d.ts'), 'utf8'),
readFile(join(distDir, 'types.d.ts'), 'utf8'),
])

expect(createGuardDeclarations).toContain(
'Creates a guard that evaluates access against the current user.'
)
expect(typeDeclarations).toContain('Access requirements consumed by')
})
})
17 changes: 17 additions & 0 deletions packages/react-router/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,22 @@
# @react-protected/react-router

## 0.2.0

### Minor Changes

- 05cda4d: Update roadmap and docs

### Patch Changes

- 4b835f6: Docs added
- 9054621: Add testing
- 11a6537: Configure Changesets-based release automation and public package publish metadata.
- Updated dependencies [4b835f6]
- Updated dependencies [11a6537]
- Updated dependencies [05cda4d]
- @react-protected/react@0.2.0
- @react-protected/core@0.2.0

## 0.2.0-beta.3

### Patch Changes
Expand Down
2 changes: 1 addition & 1 deletion packages/react-router/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@react-protected/react-router",
"version": "0.2.0-beta.3",
"version": "0.2.0",
"license": "MIT",
"description": "React Router data router adapter for react-protected",
"main": "./dist/index.cjs",
Expand Down
12 changes: 12 additions & 0 deletions packages/react-router/src/AccessRoute.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,23 @@ import { Navigate, Outlet, useLocation } from 'react-router-dom'

import type { AccessRouteProps } from './types'

/**
* Evaluates route protection with the active access context.
*
* @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 {
const { guard } = useAccess()
return guard.check(config)
}

/**
* Protects a route element and redirects when access is denied.
*
* @param props - Route protection rules and optional child content.
* @returns The protected children, an `Outlet`, or a redirecting `Navigate` element.
*/
export const AccessRoute = memo(({
access,
roles,
Expand Down
9 changes: 9 additions & 0 deletions packages/react-router/src/createAccessRouter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,15 @@ function wrapLazyRoute<TUser>(
}
}

/**
* 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<TUser = unknown>(
routes: Array<ProtectedRouteObject<TUser>>,
options: CreateAccessRouterConfig<TUser>,
Expand Down
16 changes: 14 additions & 2 deletions packages/react-router/src/testing.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,14 @@
export type { MockAccessProviderProps } from '@react-protected/react/testing'
export { MockAccessProvider } from '@react-protected/react/testing'
import {
MockAccessProvider as ReactMockAccessProvider,
type MockAccessProviderProps as ReactMockAccessProviderProps,
} from '@react-protected/react/testing'

/**
* Props accepted by the React Router testing helper.
*/
export type MockAccessProviderProps<TUser = unknown> = ReactMockAccessProviderProps<TUser>

/**
* Test helper that provides a predictable access context.
*/
export const MockAccessProvider: typeof ReactMockAccessProvider = ReactMockAccessProvider
27 changes: 27 additions & 0 deletions packages/react-router/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,48 @@ import type { NavigationConfig, RouteProtection } from '@react-protected/react'
import type { ReactNode } from 'react'
import type { createBrowserRouter, RouteObject } from 'react-router-dom'

/**
* Access level supported by the React Router adapter.
*/
export type RouterAccessLevel = AccessLevel | 'guest-only'

/**
* Route protection config accepted by router-aware APIs.
*/
export type RouterRouteConfig = Omit<RouteProtection, 'access'> & {
/**
* Access level for the route, including support for guest-only screens.
*/
access?: RouterAccessLevel
}

/**
* Props accepted by `AccessRoute`.
*/
export type AccessRouteProps = RouterRouteConfig & {
/**
* Route element rendered when access is allowed.
*/
children?: ReactNode
}

/**
* React Router route object extended with access protection fields.
*/
export type ProtectedRouteObject<TUser = unknown> = Omit<RouteObject, 'children'> &
RouterRouteConfig & {
/**
* Nested child routes that inherit parent guard behavior.
*/
children?: Array<ProtectedRouteObject<TUser>>
}

/**
* Additional options forwarded to `createBrowserRouter`.
*/
export type CreateAccessRouterOptions = Parameters<typeof createBrowserRouter>[1]

/**
* Guard callbacks and navigation settings accepted by `createAccessRouter`.
*/
export type CreateAccessRouterConfig<TUser = unknown> = GuardOptions<TUser> & NavigationConfig
27 changes: 25 additions & 2 deletions packages/react-router/tests/build.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { access, rm } from 'node:fs/promises'
import { access, readFile, rm } from 'node:fs/promises'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'

import { build } from 'vite'
import { describe, it } from 'vitest'
import { describe, expect, it } from 'vitest'

const packageRoot = fileURLToPath(new URL('..', import.meta.url))
const distDir = join(packageRoot, 'dist')
Expand All @@ -26,4 +26,27 @@ describe('package build', () => {
access(join(distDir, 'testing.d.ts')),
])
})

it('preserves public JSDoc in declaration output', async () => {
await rm(distDir, { recursive: true, force: true })

await build({
root: packageRoot,
logLevel: 'silent',
})

const [accessRouteDeclarations, createAccessRouterDeclarations, testingDeclarations] = await Promise.all([
readFile(join(distDir, 'AccessRoute.d.ts'), 'utf8'),
readFile(join(distDir, 'createAccessRouter.d.ts'), 'utf8'),
readFile(join(distDir, 'testing.d.ts'), 'utf8'),
])

expect(accessRouteDeclarations).toContain(
'Protects a route element and redirects when access is denied.'
)
expect(createAccessRouterDeclarations).toContain(
'Creates a browser router with access checks applied to protected routes.'
)
expect(testingDeclarations).toContain('Test helper that provides a predictable access context.')
})
})
Loading
Loading