react-resource-view

Permissions & quotas

canCreate, canDelete, and a creation limit with a fallback.

Five flags#

Each maps to an action, and each is either a boolean or a function evaluated on render — so a permission can follow a session that changes without anything being re-declared.

createViewResource("articles", {
  path: "/api/articles",

  // A boolean, or a function evaluated on every render.
  canRead: true,
  canCreate: () => user.hasRole("editor"),
  canUpdate: () => user.hasRole("editor"),
  canDelete: () => user.hasRole("admin"),
})
NameTypeDefaultDescription
canReadboolean | (() => boolean)Covers both read and list — seeing a record and seeing the collection are the same right.
canCreateboolean | (() => boolean)The create button and the create view.
canUpdateboolean | (() => boolean)The edit button, and editing in place in a table.
canDeleteboolean | (() => boolean)The delete button and its confirmation.
canListboolean | (() => boolean)Carried on the resource for your own use — the action check itself reads canRead.

A denied action removes its button entirely rather than disabling it: ResourceViewButton returns nothing. permissionResource is exported, so a menu or a dashboard can ask the same question and stay consistent.

Undeclared means denied#

This is the one that surprises people

An undeclared permission evaluates to false. A resource with no flags renders its list and offers nothing else — no create, no edit, no delete — and nothing errors to tell you why.

// ⚠️ No permission declared at all → nothing is offered.
createViewResource("articles", { path: "/api/articles" })
// The list renders, but there is no create button, no edit and no delete.

Deny-by-default is the right way round for a package that renders write actions, but it does mean the flags are part of a working declaration rather than an optional extra. Every demo on this site sets all four.

Creation quotas#

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

import type { LimitInterface } from "react-resource-view"

createViewResource("projects", {
  path: "/api/projects",
  canCreate: true,
  limit: {
    // Synchronous: count what the context already holds.
    getLimit: (context) => ({
      current: context.data?.totalItems ?? 0,
      max: subscription.plan === "free" ? 3 : Infinity,
    }),
    // Rendered in place of the create button once current >= max.
    fallback: ({ limit }) => (
      <UpgradePrompt used={limit.current} allowed={limit.max} />
    ),
  },
})
limit: {
  // Or asynchronous: a quota only the API knows.
  getLimit: async () => {
    const { data } = await api.get("/quota/projects")
    return { current: data.used, max: data.allowed }
  },
}
NameTypeDefaultDescription
getLimitrequired(context) => LimitState | Promise<LimitState>Returns { current, max }. A promise is unwrapped in an effect and the last result kept.
fallbackFC<{ limit: LimitState }>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 limit 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.
  • useLimit is exported, should a component of yours need to ask the same question.

Authorising a whole scope#

Per-resource flags are about buttons. 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",
  authorization: () => {
    if (!isLogged()) throw new UnauthorizedError()   // 401
    if (!user.isAdmin) throw new ForbiddenError()    // 403
    return true
  },
}

UnauthorizedError (401) and ForbiddenError (403) are exported and carry their status. onUnauthorized on the resource configuration is where you send the reader to sign in.

This is not security#

Everything here is presentation

These flags 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.