react-resource-view

Permissions & quotas

One permissions object, rules per record, and a creation quota with a fallback.

One permissions object#

Each key maps to an action — list, read, create, update, delete, and the CSV export — and each is either a boolean or a function. A function is told the record the action would apply to, so a rule can be about one record; it may answer with a promise, and it is asked again as the session changes, without anything being re-declared.

// hasRole() reads a session loaded once — see Authentication.
createViewResource<Article>("articles", {
  path: "/api/articles",

  // Everything is allowed by default: only the restrictions are written.
  // A boolean, or a function of the record — which may answer with a promise.
  permissions: {
    create: () => hasRole("editor"),
    update: ({ item }) => hasRole("editor") && item?.status !== "archived",
    delete: () => hasRole("admin"),
  },
})

Where hasRole comes from — a session fetched once and read synchronously afterwards — is shown step by step in Authentication.

NameTypeDefaultDescription
listPermission—The list and the way back to it. Refused, the list page draws the forbidden fallback.
readPermission—The read button, the read view, and a board's cards opening.
createPermission—The create button and the create view.
updatePermission—The edit button, and editing in place in a table.
deletePermission—The delete button, its confirmation, and the bulk deletion.
exportPermission—The CSV export button of the list.

Permission is boolean or ({ action, item, context }) => boolean | Promise<boolean>. The asynchronous answers are kept per action and record while a list is on screen, so a list of fifty rows asks once per row. Three shorthands cover the common cases:

permissions: "readonly"                  // list, read and export
permissions: ["list", "read", "update"]  // those, and nothing else
permissions: { export: false }           // everything but the CSV export

A refused action removes its button entirely rather than disabling it, and protects its route: /articles/update/42 typed by hand draws the scope's forbiddenFallback instead of the form. A component of yours asks the same question with usePermission — and permissionResource(resource, action, { item }) outside React — so a menu or a dashboard stays consistent.

import { usePermission } from "react-resource-view"

function ArchiveButton({ article }: { article: Article }) {
  // false while an asynchronous permission is still answering.
  const canUpdate = usePermission("update", article)
  return canUpdate ? <button onClick={() => archive(article)}>Archive</button> : null
}

Allowed by default#

Nothing declared, everything offered

A resource that declares no permissions offers every action: the list, the read and edit buttons, the create button, the delete confirmation and the export. A resource meant to be read-only says so with permissions: "readonly".

Allow-by-default is the right way round for presentation: the interface only decides what is offered, and the API is where access is enforced. A declaration says only what it restricts, and a first resource works without a single permission in it.

Creation quotas#

A permission answers may they; a quota answers how many more. quota.get receives the current context, so the count can come from data already loaded rather than another request.

import { createViewResource, getCollectionTotal } from "react-resource-view"

createViewResource("projects", {
  path: "/api/projects",
  quota: {
    // Synchronous: count what the context already holds, whatever the dialect.
    get: (context) => ({
      current: getCollectionTotal(context.data, context.resource) ?? 0,
      max: subscription.plan === "free" ? 3 : Infinity,
    }),
    // Rendered in place of the create button once current >= max.
    fallback: ({ quota }) => (
      <UpgradePrompt used={quota.current} allowed={quota.max} />
    ),
  },
})
quota: {
  // Or asynchronous: a quota only the API knows.
  get: async () => {
    const { data } = await api.get("/quota/projects")
    return { current: data.used, max: data.allowed }
  },
}
NameTypeDefaultDescription
getrequired(context) => QuotaState | Promise<QuotaState>—Returns { current, max }. A promise is unwrapped in an effect and the last result kept.
fallbackFC<{ quota: QuotaState }>—Rendered instead of the create button once current >= max. Without it the button is simply hidden.
  • max: Infinity means unlimited — the honest way to express “this plan has no cap”.
  • A quota can also be injected at runtime through the view context, which is how a nested view's create button is driven by how many rows the parent has selected.
  • useQuota is exported, should a component of yours need to ask the same question.

Authorising a whole scope#

Per-resource permissions are about actions. Whether a reader may be in this part of the application at all is a scope question.

// Scope-level, for a whole area of the application.
{
  name: "admin",
  // Synchronous or async: nothing of the scope renders until it has answered.
  authorization: async () => {
    const { data } = await authClient.getSession()
    if (!data) throw new UnauthorizedError()                  // 401
    if (data.user.role !== "admin") throw new ForbiddenError() // 403
    return true
  },
}

UnauthorizedError (401) and ForbiddenError (403) are exported and carry their status, thrown or rejected alike. onUnauthorized on the resource configuration is where you send the reader to sign in; a 403, or a false, renders forbiddenFallback instead of the scope. Authentication wires it to Better Auth, sign-out included.

This is not security#

Everything here is presentation

These permissions decide what is offered. They run in the browser, where anyone can change them. The API is what decides what is allowed, and it has to enforce the same rules independently.

Their real job is to stop the reader being shown an action that will be rejected — and the 422 mapping is what handles the case where they disagree anyway.