@shield-acl/react
Version:
Sistema ACL (Access Control List) inteligente e granular para aplicações React
234 lines (175 loc) • 6.46 kB
Markdown
# @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