An RBAC role permission editor with grouped toggles and live grant counts per group and overall, built on the Paragon switch.
npx shadcn@latest add @paragon/role-editorAlso installs: switch
"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>
);
}