DEV Community

Msotafa Mohamed
Msotafa Mohamed

Posted on

Permissions in React that explain themselves

Every app with roles ends up with the same support ticket: "I can't see the Refund button."

Then someone opens the code and starts reading. Is it the role? A missing permission? A feature flag? Is the permission list still loading? The UI just shows nothing, and nothing tells you why.

I built accessly to make that question take seconds. It's a small permissions layer for React and Next.js, and every check returns a decision with a reason.

A check that answers "why"

import { checkPermission, formatDecision } from "accessly/core";

const access = { user: { id: "u_1", roles: ["support"] }, permissions: ["tickets.view"] };
const rolePermissions = { admin: ["*"], support: ["tickets.*", "users.view"] };

const decision = checkPermission(access, { permission: "payments.refund" }, { rolePermissions });
console.log(formatDecision(decision));
Enter fullscreen mode Exit fullscreen mode
Allowed: false
Reason: missing_permission
Requested: payments.refund
Missing: payments.refund
Checked from: none
Enter fullscreen mode Exit fullscreen mode

And when a check passes, it tells you which rule let it through:

Allowed: true
Reason: allowed
Requested: tickets.close
Matched: tickets.*
Checked from: role
Enter fullscreen mode Exit fullscreen mode

The reason is one of allowed, missing_permission, missing_flag, unknown_permission, not_ready or invalid_request. That last pair matters more than it looks. While permissions are still loading, a check returns not_ready instead of a plain false, so you can show a spinner instead of flashing an "Access denied" page.

The React side

Wrap the app once with the current user's access:

"use client";
import { PermissionProvider } from "accessly";

export function Providers({ session, children }) {
  return (
    <PermissionProvider
      access={{
        user: { id: session.user.id, roles: session.user.roles },
        permissions: session.permissions,
        flags: session.flags,
      }}
      rolePermissions={{ admin: ["*"], editor: ["posts.*"] }}
    >
      {children}
    </PermissionProvider>
  );
}
Enter fullscreen mode Exit fullscreen mode

Then gate UI with components or hooks:

import { Can, usePermissions, useAccessDecision, formatDecision } from "accessly";

<Can permission="posts.delete" fallback={null}>
  <button>Delete</button>
</Can>

const { canEdit, canPublish } = usePermissions({
  canEdit: "posts.edit",
  canPublish: { all: ["posts.edit", "posts.publish"] },
});

const refund = useAccessDecision("payments.refund");
if (!refund.allowed) console.debug(formatDecision(refund));
Enter fullscreen mode Exit fullscreen mode

Permissions are dot-separated strings. Wildcards work per segment: users.* matches users.create but not users.profile.edit, and * matches everything. A check can be a single permission, { any: [...] }, { all: [...] } or { flag: "..." }.

New in 0.2: the same rules on the server

The main accessly entry is React. Until 0.2, that meant you couldn't reuse your rules in a Server Component or a Route Handler. Now there is accessly/core, with no React import at all:

// app/api/refunds/route.ts
import { createAccessChecker } from "accessly/core";

export async function POST(req: Request) {
  const session = await getSession(req);
  const access = createAccessChecker(session.access, { rolePermissions });

  if (!access.can("payments.refund")) {
    return new Response("Forbidden", { status: 403 });
  }
  // ...
}
Enter fullscreen mode Exit fullscreen mode

It also has filterNavigation, which takes your menu items and returns only the ones the user can open. That way the sidebar and the route guard use the same rules.

What it is not

accessly controls what your UI shows. It doesn't replace authorization on your API, so keep enforcing permissions on the server too. With accessly/core you can at least use the same rule definitions in both places. It also isn't a policy engine for conditions like "only the author can edit their own post". For that, use CASL or a dedicated policy engine.

Try it

npm install accessly
Enter fullscreen mode Exit fullscreen mode

It has zero dependencies and is about 4 kB gzip. The docs cover adapters for different backend shapes, ProtectedRoute, and debugging with inspectAccess. Issues and ideas are welcome on GitHub.

Top comments (0)