UNPKG

@shield-acl/react

Version:

Sistema ACL (Access Control List) inteligente e granular para aplicações React

234 lines (175 loc) 6.46 kB
# @shield-acl/react <div align="center"> <img src="https://raw.githubusercontent.com/andersondrosa/andersondrosa/refs/heads/main/images/shield-acl.png" alt="Shield ACL React" width="400" /> </div> <div align="center"> <p><strong>Hooks e componentes declarativos para o Shield ACL — multi-app, assíncrono e reativo em tempo real.</strong></p> </div> Poucos primitivos, muita composição. Cada hook mapeia 1:1 num método do [`@shield-acl/core`](../core), com **override de scope**, **assíncrono** e **reatividade** quando roles ou grants mudam. > Guia detalhado dos hooks: [`docs/REACT-HOOKS.md`](./docs/REACT-HOOKS.md). > Design da v3: [`../../docs/REACT-HOOKS-DESIGN-v3.md`](../../docs/REACT-HOOKS-DESIGN-v3.md). > Porquês das decisões: [`DECISIONS.md`](./docs/DECISIONS.md). ## Instalação ```bash pnpm add @shield-acl/react @shield-acl/core react ``` - React **18+** (usa `useSyncExternalStore`). ## Setup ```tsx import { ACL } from "@shield-acl/core"; import { ACLProvider } from "@shield-acl/react"; const acl = new ACL(); acl.defineRole({ name: "editor", permissions: [{ action: "read", resource: "posts" }], }); const user = { id: 1, grants: [{ scope: "app:crm", roles: ["editor"] }] }; function App() { return ( <ACLProvider engine={acl} user={user} scope="app:crm" environment={{ mfa }}> <Dashboard /> </ACLProvider> ); } ``` Props do Provider: ```typescript interface ACLProviderProps { engine: ACL; user?: User | null; // inicial (não-controlado) OU atualize a prop (controlado) scope?: Scope; // default "*" — o app desta subárvore environment?: Environment; // reativo (MFA/hora/IP) } ``` ## Componentes declarativos ```tsx import { Can, Cannot } from "@shield-acl/react" <Can action="update" resource="posts" record={post} fallback={<Locked />}> <EditButton /> </Can> <Can action="delete" resource="posts" scope="app:outro">…</Can> {/* outro app */} <Cannot action="publish" resource="posts">Sem permissão para publicar</Cannot> <Can.Any checks={[["create", "posts"], ["update", "posts"]]}>…</Can.Any> <Can.All checks={[["read", "reports"], ["export", "reports"]]}>…</Can.All> <Can.Async action="edit" resource="docs" record={doc} pending={<Spinner />} fallback={<Denied />}> <Editor /> </Can.Async> ``` - `resource` = tipo (string, matching). `record` = instância (conditions ABAC). - `scope` = override do scope do Provider. ## Hooks ### `useCan` / `useCannot` ```tsx const canEdit = useCan("update", "posts", { record: post }); const canInB = useCan("read", "posts", { scope: "app:B" }); // outro app const cannotDelete = useCannot("delete", "posts"); ``` `CheckOptions`: ```typescript interface CheckOptions<TRecord = unknown> { scope?: Scope; // override do scope record?: TRecord; // instância do recurso (conditions) environment?: Environment; // merge com o do Provider } ``` ### `useEvaluate` ```tsx const { allowed, reason, matchedRule, scope } = useEvaluate("delete", "posts"); ``` ### `useCanAsync` — conditions assíncronas / ReBAC / policySource ```tsx const { allowed, loading, error, refetch } = useCanAsync("edit", "docs", { record: doc, }); if (loading) return <Spinner />; return allowed ? <Editor /> : <Denied />; ``` ### `useChecks` + `anyOf` / `allOf` — batch tipado ```tsx const c = useChecks({ edit: ["update", "posts", { record: post }], del: ["delete", "posts", { record: post }], }); // c: { edit: boolean; del: boolean } if (anyOf(c)) { /* ... */ } if (allOf(c)) { /* ... */ } ``` ### `useResource` — vincula tipo + instância ```tsx const acl = useResource("posts", post) acl.canRead() acl.canUpdate() acl.canDelete() acl.can("publish") acl.can("read", { scope: "app:B" }) // override ``` ### Introspecção ```tsx const roles = useGrantedRoles(); // ["editor"] no scope const { allRoles, hasRole, hasAnyRole } = useRoleHierarchy(); const { all, direct, byRole, actions } = usePermissions(); // admin/debug UIs ``` ### `useAcl` — o primitivo ```tsx const { user, setUser, scope, can, cannot, evaluate, canAsync, engine } = useAcl(); ``` ## Reatividade em tempo real Quando um admin muda permissões, a UI precisa atualizar **ao vivo**. Há dois tipos de mudança: ### A) Grants do usuário atual mudaram — `useUserSync` ```tsx // PUSH: a notificação traz o novo usuário useUserSync((apply) => { return socket.on("acl:user-changed", (msg) => apply(msg.user)); }); // PULL: a notificação é só um sinal → refetch → aplica useUserSync((apply) => { return socket.on("acl:invalidate", async () => apply(await api.getMe())); }); ``` ### B) A definição de uma role mudou (afeta todos que a têm) — `useRolesSync` ```tsx useRolesSync((reload) => { return socket.on("acl:roles-changed", async () => reload(await api.getRoles()), ); }); ``` Isso funciona porque o Provider **assina o engine** (`useSyncExternalStore`): qualquer `defineRole/setRoles/touch` reavalia toda a árvore. ### Reagir a ganho/perda de permissão ```tsx usePermissionEffect("admin.access", undefined, { onGain: () => toast.success("Você agora é admin"), onLose: () => router.push("/"), // tira da tela proibida na hora }); ``` > **⚠️ Segurança:** o update no front é **UX, não fronteira**. A verdade é o > backend (que rechecha cada request). Quando a permissão **cai**, prefira > fail-safe: esconder/redirecionar no `onLose`. ## Migração da 2.x A 2.x (API simples, sem scope) continua publicada. Mapa de-para completo em [`docs/REACT-HOOKS.md`](./docs/REACT-HOOKS.md). Resumo: | 2.x | 3.x | | ----------------------------------------- | -------------------------------------------------------- | | `useCan(a, r, ctx)` | `useCan(a, r, { record, scope })` | | `useCanAny/All/Multiple/Map/Array` | `useChecks` + `anyOf`/`allOf` | | `usePermissionHelpers` / `useResourceACL` | `useResource` | | `usePermissionChange*` | `useUserSync` / `usePermissionEffect` | | — | `useCanAsync`, override de `scope`, `environment` tipado | ## Compatibilidade - React **18.x / 19.x** · TypeScript 5+. ## Testes ```bash pnpm test pnpm test:coverage ``` ## Licença MIT © Anderson D. Rosa