Fix the valid issues raised in the scaffold review. UI kit: the Button loading state now replaces only the leading icon instead of dropping the label, Button consumes the `active` variant so it no longer leaks onto the DOM, and every v2 skeleton sets aria-hidden after the prop spread so a consumer cannot override it. @probo/relay: guard the caller-supplied onCompleted/onError callbacks so a throwing callback still settles the awaitable mutation promise instead of leaving it pending. compliance-portal: normalize external website hrefs and read hostname via URL.hostname, add a localized catch-all not-found route, and widen the .gitattributes glob so colocated __generated__ artifacts at any depth are marked generated. Docs: correct the forms guide (Base UI passes plain values, Zod v3 flatten API), spread the child fragment in the permissions example, and drop references to v2 components that do not exist in the ui guide. Signed-off-by: Émile Ré <emile@probo.com>
96 lines
4.3 KiB
Markdown
96 lines
4.3 KiB
Markdown
# Permission-gated UI
|
|
|
|
Authorization is enforced on the **server** (see [`authorization.md`](authorization.md)). The frontend never decides what a user is *allowed* to do — it asks the API and renders accordingly. The API exposes per-record permissions through the `permission(action:)` field, which a component selects as a boolean alias (`canUpdate`, `canDelete`, …) on the node it renders.
|
|
|
|
This guide covers how to **consume** those booleans. It does not grant access; hiding a button is a UX nicety, not a security control — the mutation is still authorized server-side.
|
|
|
|
## Related guides
|
|
|
|
| Topic | Guide |
|
|
|-------|--------|
|
|
| Server-side IAM policies and actions | [`contrib/claude/authorization.md`](authorization.md) |
|
|
| Fragments, colocated data | [`contrib/claude/relay.md`](relay.md) |
|
|
| Component shape and props | [`contrib/claude/react-components.md`](react-components.md) |
|
|
|
|
## Select permissions in the fragment that needs them
|
|
|
|
A component that renders an action selects the matching permission **in its own fragment**, aliased to a `can…` boolean. Keep the action string identical to the IAM action it guards.
|
|
|
|
```tsx
|
|
const documentListItemFragment = graphql`
|
|
fragment DocumentListItem_document on Document {
|
|
id
|
|
title
|
|
canUpdate: permission(action: "core:document:update")
|
|
canDelete: permission(action: "core:document:delete")
|
|
...EditDocumentDialog_document
|
|
}
|
|
`;
|
|
```
|
|
|
|
Colocate the permission with the action it gates — never drill a `canDelete` boolean down as a prop from a parent (the same data-as-props rule as everywhere else; see [`react-components.md`](react-components.md#props-are-for-configuration-and-composition-not-data)).
|
|
|
|
Spread a child's fragment (`...EditDocumentDialog_document`) when you forward the node to that child as a fragment key. `useFragment` returns plain data, but Relay keeps the spread fragment refs on it, so the resolved `document` doubles as the key the child's own `useFragment` expects. Without the spread, `documentKey={document}` would hand the child masked data with no ref and fail at runtime (see [`relay.md`](relay.md)).
|
|
|
|
## Gate the action on the boolean
|
|
|
|
Read the boolean via `useFragment` and gate the control. Default to **hiding** an action the user cannot perform; **disable** (with an explanatory tooltip) only when the action's *absence* would be confusing.
|
|
|
|
```tsx
|
|
export function DocumentListItem({ documentKey }: DocumentListItemProps) {
|
|
const document = useFragment(documentListItemFragment, documentKey);
|
|
return (
|
|
<Tr>
|
|
<Td>{document.title}</Td>
|
|
<Td>
|
|
{document.canUpdate && <EditDocumentDialog documentKey={document} />}
|
|
{document.canDelete && <DeleteDocumentButton documentId={document.id} />}
|
|
</Td>
|
|
</Tr>
|
|
);
|
|
}
|
|
```
|
|
|
|
### Hide vs. disable
|
|
|
|
```text
|
|
// Hide — the user has no business with this action (most cases)
|
|
{canDelete && <DeleteButton … />}
|
|
|
|
// Disable — the action is expected to be there, but is currently unavailable;
|
|
// pair with a tooltip explaining why
|
|
<Button disabled={!canPublish} title={!canPublish ? t("noPublishPermission") : undefined}>
|
|
{t("publish")}
|
|
</Button>
|
|
```
|
|
|
|
## Bulk / toolbar actions
|
|
|
|
For list toolbars, derive the aggregate from the items and hide the bulk control when no row qualifies.
|
|
|
|
```tsx
|
|
const canDeleteAny = documents.some(({ canDelete }) => canDelete);
|
|
|
|
{canDeleteAny && <BulkDeleteButton ids={selection} />}
|
|
```
|
|
|
|
## Don't
|
|
|
|
```text
|
|
// Bad — client-side role check standing in for a server permission
|
|
if (currentUser.role === "ADMIN") { showDelete(); }
|
|
|
|
// Bad — drilling a permission boolean as a prop instead of selecting it where used
|
|
<DocumentListItem canDelete={doc.canDelete} />
|
|
|
|
// Bad — gating on a hand-rolled action string that drifts from the IAM action
|
|
permission(action: "document_delete") // must match "core:document:delete"
|
|
|
|
// Bad — treating a hidden button as the security boundary
|
|
// (the mutation must still be authorized server-side; UI gating is UX only)
|
|
```
|
|
|
|
## Why server-derived, not role-based
|
|
|
|
Roles are coarse and change; resource-level permissions answer the exact question the UI asks ("can *this* user act on *this* record?"). Selecting `permission(action:)` keeps the frontend in lockstep with the IAM policies in [`authorization.md`](authorization.md) without re-encoding any authorization logic in the client.
|