@shield-acl/react
v2.0.0
Published
Sistema ACL (Access Control List) inteligente e granular para aplicações React
Maintainers
Readme
@shield-acl/react
Poucos primitivos, muita composição. Cada hook mapeia 1:1 num método do
@shield-acl/core, com override de scope, assíncrono e
reatividade quando roles ou grants mudam.
Guia detalhado dos hooks:
docs/REACT-HOOKS.md. Design da v3:../../docs/REACT-HOOKS-DESIGN-v3.md. Porquês das decisões:DECISIONS.md.
Instalação
pnpm add @shield-acl/react @shield-acl/core react- React 18+ (usa
useSyncExternalStore).
Setup
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:
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
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
const canEdit = useCan("update", "posts", { record: post });
const canInB = useCan("read", "posts", { scope: "app:B" }); // outro app
const cannotDelete = useCannot("delete", "posts");CheckOptions:
interface CheckOptions<TRecord = unknown> {
scope?: Scope; // override do scope
record?: TRecord; // instância do recurso (conditions)
environment?: Environment; // merge com o do Provider
}useEvaluate
const { allowed, reason, matchedRule, scope } = useEvaluate("delete", "posts");useCanAsync — conditions assíncronas / ReBAC / policySource
const { allowed, loading, error, refetch } = useCanAsync("edit", "docs", {
record: doc,
});
if (loading) return <Spinner />;
return allowed ? <Editor /> : <Denied />;useChecks + anyOf / allOf — batch tipado
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
const acl = useResource("posts", post)
acl.canRead() acl.canUpdate() acl.canDelete()
acl.can("publish")
acl.can("read", { scope: "app:B" }) // overrideIntrospecção
const roles = useGrantedRoles(); // ["editor"] no scope
const { allRoles, hasRole, hasAnyRole } = useRoleHierarchy();
const { all, direct, byRole, actions } = usePermissions(); // admin/debug UIsuseAcl — o primitivo
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
// 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
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
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. 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
pnpm test
pnpm test:coverageLicença
MIT © Anderson D. Rosa
