Security

Role Editor

An RBAC role permission editor with grouped toggles and live grant counts per group and overall, built on the Paragon switch.

Install

npx shadcn@latest add @paragon/role-editor

Also installs: switch

role-editor.tsx

"use client";

import * as React from "react";
import { useReducedMotion } from "motion/react";
import { ShieldCheck } from "lucide-react";
import { Switch } from "@/registry/paragon/ui/switch";
import { cn } from "@/lib/utils";

export interface Permission {
  id: string;
  label: string;
  hint?: string;
  /** Whether this permission starts enabled. */
  default?: boolean;
}

export interface PermissionGroup {
  name: string;
  permissions: Permission[];
}

export interface RoleEditorProps extends React.ComponentProps<"div"> {
  /** Role being edited. */
  role?: string;
  groups?: PermissionGroup[];
  /** Disables enter animation. */
  static?: boolean;
  /** Fired when any toggle changes, with the full set of enabled ids. */
  onChangePermissions?: (enabled: string[]) => void;
}

const DEFAULT_GROUPS: PermissionGroup[] = [
  {
    name: "Billing",
    permissions: [
      { id: "billing.view", label: "View invoices", default: true },
      { id: "billing.manage", label: "Manage payment methods" },
      { id: "billing.refund", label: "Issue refunds", hint: "Up to $10,000" },
    ],
  },
  {
    name: "Members",
    permissions: [
      { id: "members.view", label: "View team members", default: true },
      { id: "members.invite", label: "Invite members", default: true },
      { id: "members.remove", label: "Remove members" },
    ],
  },
  {
    name: "Security",
    permissions: [
      { id: "security.audit", label: "Read audit log", default: true },
      { id: "security.sso", label: "Configure SSO" },
      { id: "security.keys", label: "Rotate API keys", hint: "Destructive" },
    ],
  },
];

/**
 * An RBAC role permission editor with grouped toggles. Enabled counts per group
 * and the header total update live in tabular-nums as switches flip; the
 * switch's own spring drives the only motion, so toggling stays interruptible.
 * The card fades in once on mount. State is initialized deterministically from
 * each permission's `default`.
 */
export function RoleEditor({
  role = "Finance Manager",
  groups = DEFAULT_GROUPS,
  static: isStatic = false,
  onChangePermissions,
  className,
  ...props
}: RoleEditorProps) {
  const reduced = useReducedMotion();
  const [enabled, setEnabled] = React.useState<Record<string, boolean>>(() => {
    const init: Record<string, boolean> = {};
    for (const group of groups) {
      for (const perm of group.permissions) {
        init[perm.id] = perm.default ?? false;
      }
    }
    return init;
  });

  const toggle = (id: string, value: boolean) => {
    setEnabled((prev) => {
      const next = { ...prev, [id]: value };
      onChangePermissions?.(Object.keys(next).filter((k) => next[k]));
      return next;
    });
  };

  const total = Object.values(enabled).filter(Boolean).length;
  const all = Object.keys(enabled).length;

  return (
    <div
      data-slot="role-editor"
      className={cn(
        "w-full max-w-md rounded-xl bg-card text-card-foreground shadow-border",
        className,
      )}
      style={
        !isStatic && !reduced
          ? ({
              animation: "role-editor-enter var(--duration-base) var(--ease-out)",
            } as React.CSSProperties)
          : undefined
      }
      {...props}
    >
      <style href="paragon-role-editor" precedence="paragon">{`
        @keyframes role-editor-enter {
          from { opacity: 0; transform: translateY(8px); filter: blur(4px); }
          to { opacity: 1; transform: translateY(0); filter: blur(0); }
        }
        @media (prefers-reduced-motion: reduce) {
          [data-slot="role-editor"] { animation: none !important; }
        }
      `}</style>

      <div className="flex items-center justify-between border-b p-4">
        <div className="flex items-center gap-2.5">
          <span className="flex size-8 items-center justify-center rounded-lg bg-secondary text-foreground">
            <ShieldCheck className="size-4" />
          </span>
          <div>
            <h3 className="text-sm font-semibold">{role}</h3>
            <p className="text-xs text-muted-foreground">Role permissions</p>
          </div>
        </div>
        <span className="text-xs font-medium text-muted-foreground tabular-nums">
          {total}/{all} granted
        </span>
      </div>

      <div className="divide-y">
        {groups.map((group) => {
          const groupCount = group.permissions.filter(
            (p) => enabled[p.id],
          ).length;
          return (
            <fieldset key={group.name} className="p-4">
              <legend className="mb-2 flex w-full items-center justify-between">
                <span className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
                  {group.name}
                </span>
                <span className="text-[11px] font-medium text-muted-foreground tabular-nums">
                  {groupCount}/{group.permissions.length}
                </span>
              </legend>
              <div className="space-y-2.5">
                {group.permissions.map((perm) => {
                  const on = enabled[perm.id];
                  return (
                    <label
                      key={perm.id}
                      className="flex cursor-pointer items-center justify-between gap-3"
                    >
                      <span className="min-w-0">
                        <span className="block text-[13px] font-medium">
                          {perm.label}
                        </span>
                        {perm.hint && (
                          <span
                            className={cn(
                              "block text-[11px]",
                              perm.hint === "Destructive"
                                ? "text-destructive"
                                : "text-muted-foreground",
                            )}
                          >
                            {perm.hint}
                          </span>
                        )}
                      </span>
                      <Switch
                        checked={on}
                        onCheckedChange={(v) => toggle(perm.id, v)}
                        aria-label={perm.label}
                      />
                    </label>
                  );
                })}
              </div>
            </fieldset>
          );
        })}
      </div>
    </div>
  );
}