Files
probo/contrib/claude/permissions.md
Émile Ré 393c538de1 Fill frontend rule gaps and broaden v2 tokens
Add the frontend guides the v2 UI kit and compliance-portal need but
that the first rework left uncovered: forms, routing, client state, and
permission-gated UI.

forms.md documents a tiered approach on Base UI Field/Form -- native
constraints, then a validate function, then zod parsed in onSubmit, and
react-hook-form only for large or dynamic forms -- and drops the custom
useFormWithSchema wrapper. routing.md covers @probo/routes, navigation,
typed params, URL-as-state, redirects, auth/protected routes, and the
folded-in no-outlet-context rule. state-management.md gives a decision
order across Relay, URL, local state, context, and zustand.
permissions.md gates UI on the canUpdate/canDelete permission(action:)
fields without re-encoding authorization in the client.

Rename v2-colors.md to v2-tokens.md and add the typography, radius,
shadow, and native-spacing scales alongside color. Extend ui.md with
user feedback, empty-state, and accessibility sections; standardize
toasts on Base UI's Toast (Toast.useToastManager) and retire the legacy
useToast across ui.md, forms.md, error-handling.md, and relay.md. Add an
Intl formatting section to i18n.md and a non-Relay HTTP / file
upload-download section to ts-style.md. Update the AGENTS.md index and
the v2-color-scale cursor rule for the new and renamed guides.

Signed-off-by: Émile Ré <emile@probo.com>
2026-06-26 18:52:05 +02:00

3.8 KiB

Permission-gated UI

Authorization is enforced on the server (see 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.

Topic Guide
Server-side IAM policies and actions contrib/claude/authorization.md
Fragments, colocated data contrib/claude/relay.md
Component shape and props contrib/claude/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.

const documentListItemFragment = graphql`
  fragment DocumentListItem_document on Document {
    id
    title
    canUpdate: permission(action: "core:document:update")
    canDelete: permission(action: "core:document:delete")
  }
`;

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).

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.

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

// 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.

const canDeleteAny = documents.some(({ canDelete }) => canDelete);

{canDeleteAny && <BulkDeleteButton ids={selection} />}

Don't

// 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 without re-encoding any authorization logic in the client.