/** * React integration for duck-iam. * * Two patterns: * 1. Server-driven (recommended): generate IamClient.PermissionMap on server, pass to client. * 2. IamClient-evaluated: load Engine on client with HttpAdapter or MemoryAdapter. * * Usage (server-driven): * * // Server (Next.js layout, RSC, or API): * const perms = await engine.permissions(userId, [ * { action: "create", resource: "post" }, * { action: "delete", resource: "post" }, * { action: "manage", resource: "team" }, * ]); * * // IamClient: * * * * * // In any component: * const { can } = useAccess(); * if (can("delete", "post")) { ... } * if (can("manage", "user", undefined, "admin")) { ... } * * // Or declaratively: * * * */ import type { ReactNode } from 'react' import type { IamClient } from '../../core/types' import { iamBuildPermissionKey } from '../../shared/keys' // React is a peer dep; consumers inject their own React via createIamAccessControl(React). /** Minimal React context type. */ interface ReactContext<_T> { Provider: unknown } /** Minimal React API surface for dependency injection. */ interface ReactLike { createContext(defaultValue: T): ReactContext useContext(context: ReactContext): T useMemo(factory: () => T, deps: readonly unknown[]): T // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type -- matches React's own useCallback useCallback(callback: T, deps: readonly unknown[]): T createElement(type: unknown, props: Record | null, ...children: ReactNode[]): ReactNode useState(initialState: T | (() => T)): [T, (value: T | ((prev: T) => T)) => void] useEffect(effect: () => undefined | (() => void), deps?: readonly unknown[]): void } /** * React client integration types. Type-only namespace - zero bundle cost. * * Named `IamReactClient` (rather than `React`) to avoid clashing with the React * package namespace when consumers import this module alongside React. */ export namespace IamReactClient { /** * Describes the value exposed by the {@link createIamAccessControl} React context. * * @template TAction - Constrains valid action strings. * @template TResource - Constrains valid resource strings. * @template TScope - Constrains valid scope strings. */ export interface IContextValue< TAction extends string = string, TResource extends string = string, TScope extends string = string, > { /** The resolved permission map. */ permissions: IamClient.PermissionMap /** Returns `true` if the action/resource combination is allowed. */ can: (action: TAction, resource: TResource, resourceId?: string, scope?: TScope) => boolean /** Returns `true` if the action/resource combination is denied. */ cannot: (action: TAction, resource: TResource, resourceId?: string, scope?: TScope) => boolean } } /** * Builds the React access control surface (Provider, hook, components). * * Call once at app init and export the result so the entire app shares a * single context. * * @template TAction - Constrains valid action strings. * @template TResource - Constrains valid resource strings. * @template TScope - Constrains valid scope strings. * @param React - Provides the host React module so we never bundle our own copy. * @returns `{ AccessContext, AccessProvider, useAccess, usePermissions, Can, Cannot }`. * @example * ```ts * import React from 'react' * import { createIamAccessControl } from 'duck-iam/client/react' * * export const { AccessProvider, useAccess, Can, Cannot } = createIamAccessControl(React) * ``` */ export function createIamAccessControl< TAction extends string = string, TResource extends string = string, TScope extends string = string, >(React: ReactLike) { const { createContext, useContext, useMemo, useCallback } = React const AccessContext = createContext>({ permissions: {} as IamClient.PermissionMap, can: () => false, cannot: () => true, }) /** Context provider component that supplies permission data to the tree. */ function AccessProvider({ permissions, children, }: { permissions: IamClient.PermissionMap children: ReactNode }): ReactNode { const value = useMemo(() => { const can = (action: TAction, resource: TResource, resourceId?: string, scope?: TScope): boolean => { const key = iamBuildPermissionKey(action, resource, resourceId, scope) return (permissions as Record)[key] ?? false } return { permissions, can, cannot: (a: TAction, r: TResource, id?: string, s?: TScope) => !can(a, r, id, s), } }, [permissions]) return React.createElement(AccessContext.Provider, { value }, children) } /** Hook to access the permission context. */ function useAccess(): IamReactClient.IContextValue { return useContext(AccessContext) } /** Declarative component that renders children only when the permission is granted. */ function Can({ action, resource, resourceId, scope, children, fallback = null, }: { action: TAction resource: TResource resourceId?: string scope?: TScope children: ReactNode fallback?: ReactNode }): ReactNode { const { can } = useAccess() return can(action, resource, resourceId, scope) ? children : fallback } /** Declarative component that renders children only when the permission is denied. */ function Cannot({ action, resource, resourceId, scope, children, }: { action: TAction resource: TResource resourceId?: string scope?: TScope children: ReactNode }): ReactNode { const { cannot } = useAccess() return cannot(action, resource, resourceId, scope) ? children : null } /** Hook to asynchronously fetch permissions from a server endpoint. */ function usePermissions( fetchFn: () => Promise>, deps: readonly unknown[] = [], ) { const [permissions, setPermissions] = React.useState({} as IamClient.PermissionMap) const [loading, setLoading] = React.useState(true) const [error, setError] = React.useState(null) React.useEffect(() => { let cancelled = false setLoading(true) fetchFn() .then((perms: IamClient.PermissionMap) => { if (!cancelled) { setPermissions(perms) setLoading(false) } }) .catch((err: Error) => { if (!cancelled) { setError(err) setLoading(false) } }) return () => { cancelled = true } }, deps) const can = useCallback( (action: TAction, resource: TResource, resourceId?: string, scope?: TScope) => { const key = iamBuildPermissionKey(action, resource, resourceId, scope) return (permissions as Record)[key] ?? false }, [permissions], ) return { permissions, can, loading, error } } return { AccessContext, AccessProvider, useAccess, usePermissions, Can, Cannot, } } /** * Builds a standalone permission checker that does not require React. * * Useful for one-off checks, hooks outside the provider, or non-React paths. * * @template TAction - Constrains valid action strings. * @template TResource - Constrains valid resource strings. * @template TScope - Constrains valid scope strings. * @param permissions - Provides the permission map (typically from `engine.permissions(...)`). * @returns `{ can, cannot, permissions }`. */ export function createIamPermissionChecker< TAction extends string = string, TResource extends string = string, TScope extends string = string, >(permissions: IamClient.PermissionMap) { const can = (action: TAction, resource: TResource, resourceId?: string, scope?: TScope): boolean => { const key = iamBuildPermissionKey(action, resource, resourceId, scope) return (permissions as Record)[key] ?? false } return { can, cannot: (action: TAction, resource: TResource, resourceId?: string, scope?: TScope): boolean => { return !can(action, resource, resourceId, scope) }, permissions, } }