diff --git a/.changeset/lemon-rice-create.md b/.changeset/lemon-rice-create.md deleted file mode 100644 index 9b67a07..0000000 --- a/.changeset/lemon-rice-create.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -'@react-protected/react-router': patch -'@react-protected/react': patch -'@react-protected/core': patch ---- - -Docs added diff --git a/.changeset/pre.json b/.changeset/pre.json deleted file mode 100644 index a82ee4d..0000000 --- a/.changeset/pre.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "mode": "pre", - "tag": "beta", - "initialVersions": { - "@react-protected/core": "0.1.0", - "@react-protected/react": "0.1.0", - "@react-protected/react-router": "0.1.0" - }, - "changesets": [ - "lemon-rice-create", - "smooth-cougars-scream", - "ten-kings-grow", - "wide-candles-cover" - ] -} diff --git a/.changeset/smooth-cougars-scream.md b/.changeset/smooth-cougars-scream.md deleted file mode 100644 index 94cdb6b..0000000 --- a/.changeset/smooth-cougars-scream.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@react-protected/react-router': patch ---- - -Add testing diff --git a/.changeset/ten-kings-grow.md b/.changeset/ten-kings-grow.md deleted file mode 100644 index cd701aa..0000000 --- a/.changeset/ten-kings-grow.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -'@react-protected/core': patch -'@react-protected/react': patch -'@react-protected/react-router': patch ---- - -Configure Changesets-based release automation and public package publish metadata. diff --git a/.changeset/wide-candles-cover.md b/.changeset/wide-candles-cover.md deleted file mode 100644 index a245f65..0000000 --- a/.changeset/wide-candles-cover.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -'@react-protected/react-router': minor -'@react-protected/react': minor -'@react-protected/core': minor ---- - -Update roadmap and docs diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md index f080ac2..1c0babc 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -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 diff --git a/packages/core/dist/createGuard.d.ts b/packages/core/dist/createGuard.d.ts index 1059644..c4c9519 100644 --- a/packages/core/dist/createGuard.d.ts +++ b/packages/core/dist/createGuard.d.ts @@ -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(options: GuardOptions): Guard; diff --git a/packages/core/package.json b/packages/core/package.json index aaf2919..703281c 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -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", diff --git a/packages/core/src/createGuard.ts b/packages/core/src/createGuard.ts index 58909e6..b5b073b 100644 --- a/packages/core/src/createGuard.ts +++ b/packages/core/src/createGuard.ts @@ -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(options: GuardOptions): Guard { const resolved = { getUser: options.getUser, diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index add2f64..e12dbe6 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -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 + /** + * Permissions that must be satisfied by your `hasPermission` callback. + */ permissions?: Array + /** + * Optional metadata for application-specific access logic. + */ meta?: Record } +/** + * 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 = { + /** + * 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) => boolean + /** + * Determines whether the current user satisfies the requested permissions. + */ hasPermission?: (user: TUser, permissions: Array) => boolean } +/** + * Guard instance returned by `createGuard()`. + */ export type Guard = { + /** + * Evaluates whether the current user satisfies the provided access config. + */ check: (config: AccessConfig) => AccessResult + /** + * Resolved guard callbacks with built-in defaults applied. + */ options: Required> } diff --git a/packages/core/tests/build.test.ts b/packages/core/tests/build.test.ts index b426eb5..7cfc893 100644 --- a/packages/core/tests/build.test.ts +++ b/packages/core/tests/build.test.ts @@ -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') @@ -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') + }) }) diff --git a/packages/react-router/CHANGELOG.md b/packages/react-router/CHANGELOG.md index 72466f1..685d210 100644 --- a/packages/react-router/CHANGELOG.md +++ b/packages/react-router/CHANGELOG.md @@ -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 diff --git a/packages/react-router/package.json b/packages/react-router/package.json index 9e09f9c..5252031 100644 --- a/packages/react-router/package.json +++ b/packages/react-router/package.json @@ -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", diff --git a/packages/react-router/src/AccessRoute.tsx b/packages/react-router/src/AccessRoute.tsx index de13f95..964573f 100644 --- a/packages/react-router/src/AccessRoute.tsx +++ b/packages/react-router/src/AccessRoute.tsx @@ -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, diff --git a/packages/react-router/src/createAccessRouter.tsx b/packages/react-router/src/createAccessRouter.tsx index 8b12502..32e8cfa 100644 --- a/packages/react-router/src/createAccessRouter.tsx +++ b/packages/react-router/src/createAccessRouter.tsx @@ -139,6 +139,15 @@ function wrapLazyRoute( } } +/** + * 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, diff --git a/packages/react-router/src/testing.ts b/packages/react-router/src/testing.ts index 84076a8..f14a71e 100644 --- a/packages/react-router/src/testing.ts +++ b/packages/react-router/src/testing.ts @@ -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 = ReactMockAccessProviderProps + +/** + * Test helper that provides a predictable access context. + */ +export const MockAccessProvider: typeof ReactMockAccessProvider = ReactMockAccessProvider diff --git a/packages/react-router/src/types.ts b/packages/react-router/src/types.ts index 23033ed..4b254ba 100644 --- a/packages/react-router/src/types.ts +++ b/packages/react-router/src/types.ts @@ -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 & { + /** + * 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 = Omit & RouterRouteConfig & { + /** + * Nested child routes that inherit parent guard behavior. + */ children?: Array> } +/** + * Additional options forwarded to `createBrowserRouter`. + */ export type CreateAccessRouterOptions = Parameters[1] +/** + * Guard callbacks and navigation settings accepted by `createAccessRouter`. + */ export type CreateAccessRouterConfig = GuardOptions & NavigationConfig diff --git a/packages/react-router/tests/build.test.ts b/packages/react-router/tests/build.test.ts index c112aa2..32f297a 100644 --- a/packages/react-router/tests/build.test.ts +++ b/packages/react-router/tests/build.test.ts @@ -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') @@ -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.') + }) }) diff --git a/packages/react/CHANGELOG.md b/packages/react/CHANGELOG.md index 286c0e3..45f041a 100644 --- a/packages/react/CHANGELOG.md +++ b/packages/react/CHANGELOG.md @@ -1,5 +1,20 @@ # @react-protected/react +## 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. +- Updated dependencies [4b835f6] +- Updated dependencies [11a6537] +- Updated dependencies [05cda4d] + - @react-protected/core@0.2.0 + ## 0.2.0-beta.2 ### Minor Changes diff --git a/packages/react/package.json b/packages/react/package.json index d49acad..620b23c 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -1,6 +1,6 @@ { "name": "@react-protected/react", - "version": "0.2.0-beta.2", + "version": "0.2.0", "license": "MIT", "description": "React context and hooks for react-protected", "main": "./dist/index.cjs", diff --git a/packages/react/src/AccessProvider.tsx b/packages/react/src/AccessProvider.tsx index e2c0ca9..07d1a5f 100644 --- a/packages/react/src/AccessProvider.tsx +++ b/packages/react/src/AccessProvider.tsx @@ -5,6 +5,13 @@ import type { AccessContextValue, AccessProviderProps } from './types' const AccessContext = createContext(null) +/** + * Provides access control configuration to the React subtree. + * + * @typeParam TUser - User shape returned by `getUser`. + * @param props - Guard callbacks, navigation settings, and descendant elements. + * @returns A context provider that enables access-aware hooks and components. + */ export function AccessProvider({ children, loginPath = '/login', @@ -41,6 +48,13 @@ 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. + * @throws {Error} When called outside an `AccessProvider`. + */ export function useAccess(): AccessContextValue { const ctx = useContext(AccessContext) diff --git a/packages/react/src/HasAccess.tsx b/packages/react/src/HasAccess.tsx index deb57d9..55d9f7a 100644 --- a/packages/react/src/HasAccess.tsx +++ b/packages/react/src/HasAccess.tsx @@ -7,11 +7,23 @@ type HasAccessProps = RouteProtection & { children?: ReactNode } +/** + * Returns `true` when the current user satisfies the provided access config. + * + * @param config - Access requirements to evaluate with the active guard. + * @returns `true` when access is allowed, otherwise `false`. + */ export function useHasAccess(config: RouteProtection): boolean { const { guard } = useAccess() return guard.check(config).allowed } +/** + * Renders its children only when the current user satisfies the access config. + * + * @param props - Access requirements and the children to render when allowed. + * @returns The provided children when access is allowed, otherwise `null`. + */ export function HasAccess({ access, roles, permissions, meta, children }: HasAccessProps) { const allowed = useHasAccess({ access, roles, permissions, meta }) return allowed ? children ?? null : null diff --git a/packages/react/src/testing.tsx b/packages/react/src/testing.tsx index c30b341..1046a05 100644 --- a/packages/react/src/testing.tsx +++ b/packages/react/src/testing.tsx @@ -4,13 +4,34 @@ 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. + */ user?: TUser | null + /** + * Default outcome used by generated access callbacks when explicit callbacks are not provided. + */ allowed?: boolean + /** + * React subtree that consumes the mocked access context. + */ children?: ReactNode } +/** + * Test helper that provides a predictable access context. + * + * @typeParam TUser - User shape returned by the mocked `getUser`. + * @param props - Mocked user, optional callback overrides, navigation settings, and children. + * @returns An `AccessProvider` configured for deterministic tests. + * @remarks When explicit callbacks are omitted, authentication, role, and permission checks all + * resolve to the `allowed` flag. + */ export function MockAccessProvider({ user = null, allowed = true, diff --git a/packages/react/src/types.ts b/packages/react/src/types.ts index 1709f52..bb416aa 100644 --- a/packages/react/src/types.ts +++ b/packages/react/src/types.ts @@ -1,30 +1,84 @@ -import type { AccessConfig, Guard, GuardOptions } from '@react-protected/core' +import type { AccessConfig as CoreAccessConfig, Guard, GuardOptions } from '@react-protected/core' import type { ReactNode } from 'react' -export type { AccessConfig as RouteProtection } from '@react-protected/core' +/** + * Access requirements consumed by React hooks and components in this package. + */ +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()`. + */ 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 AccessRouteProps = AccessConfig & { +/** + * Access requirements accepted by `useHasAccess()` and `HasAccess`. + */ +export type AccessRouteProps = RouteProtection & { + /** + * React subtree rendered when the access check passes. + */ children?: ReactNode } diff --git a/packages/react/tests/build.test.ts b/packages/react/tests/build.test.ts index c112aa2..adeba21 100644 --- a/packages/react/tests/build.test.ts +++ b/packages/react/tests/build.test.ts @@ -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') @@ -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 [accessProviderDeclarations, hasAccessDeclarations, testingDeclarations] = await Promise.all([ + readFile(join(distDir, 'AccessProvider.d.ts'), 'utf8'), + readFile(join(distDir, 'HasAccess.d.ts'), 'utf8'), + readFile(join(distDir, 'testing.d.ts'), 'utf8'), + ]) + + expect(accessProviderDeclarations).toContain( + 'Provides access control configuration to the React subtree.' + ) + expect(hasAccessDeclarations).toContain( + 'Renders its children only when the current user satisfies the access config.' + ) + expect(testingDeclarations).toContain('Test helper that provides a predictable access context.') + }) })