refactor: enhance roles manager component with expanded role details and improved permissions display

This commit is contained in:
Anish Sarkar 2026-04-13 21:45:53 +05:30
parent 25644e1c0b
commit 635cdde0eb

View file

@ -4,6 +4,7 @@ import { useQuery } from "@tanstack/react-query";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { import {
Bot, Bot,
ChevronDown,
Edit2, Edit2,
FileText, FileText,
Globe, Globe,
@ -47,7 +48,6 @@ import {
DialogDescription, DialogDescription,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { import {
DropdownMenu, DropdownMenu,
@ -58,7 +58,6 @@ import {
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
import type { PermissionInfo } from "@/contracts/types/permissions.types"; import type { PermissionInfo } from "@/contracts/types/permissions.types";
import type { import type {
@ -319,100 +318,6 @@ export function RolesManager({ searchSpaceId }: { searchSpaceId: number }) {
); );
} }
// ============ Role Permissions Display ============
function RolePermissionsDialog({
permissions,
roleName,
children,
}: {
permissions: string[];
roleName: string;
children: React.ReactNode;
}) {
const isFullAccess = permissions.includes("*");
const grouped: Record<string, string[]> = {};
if (!isFullAccess) {
for (const perm of permissions) {
const [category, action] = perm.split(":");
if (!grouped[category]) grouped[category] = [];
grouped[category].push(action);
}
}
const sortedCategories = Object.keys(grouped).sort((a, b) => {
const orderA = CATEGORY_CONFIG[a]?.order ?? 99;
const orderB = CATEGORY_CONFIG[b]?.order ?? 99;
return orderA - orderB;
});
const categoryCount = sortedCategories.length;
return (
<Dialog>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent className="w-[92vw] max-w-md p-0 gap-0">
<DialogHeader className="p-4 md:p-5">
<DialogTitle className="text-base">{roleName} Permissions</DialogTitle>
<DialogDescription className="text-xs">
{isFullAccess
? "This role has unrestricted access to all resources"
: `${permissions.length} permissions across ${categoryCount} categories`}
</DialogDescription>
</DialogHeader>
{isFullAccess ? (
<div className="flex items-center gap-3 px-4 md:px-5 py-6">
<div className="h-9 w-9 rounded-lg bg-muted/60 flex items-center justify-center shrink-0">
<Shield className="h-4 w-4 text-muted-foreground" />
</div>
<div>
<p className="text-sm font-medium">Full access</p>
<p className="text-xs text-muted-foreground">
All permissions granted across every category
</p>
</div>
</div>
) : (
<ScrollArea className="max-h-[55vh]">
<div className="divide-y divide-border/50">
{sortedCategories.map((category) => {
const actions = grouped[category];
const config = CATEGORY_CONFIG[category] || {
label: category,
icon: FileText,
};
const IconComponent = config.icon;
return (
<div
key={category}
className="flex items-center justify-between gap-3 px-4 md:px-5 py-2.5"
>
<div className="flex items-center gap-2 shrink-0">
<IconComponent className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-sm text-muted-foreground">{config.label}</span>
</div>
<div className="flex flex-wrap justify-end gap-1">
{actions.map((action) => (
<span
key={action}
className="px-1.5 py-0.5 rounded bg-muted text-muted-foreground text-[11px] font-medium"
>
{ACTION_LABELS[action] || action.replace(/_/g, " ")}
</span>
))}
</div>
</div>
);
})}
</div>
</ScrollArea>
)}
</DialogContent>
</Dialog>
);
}
function PermissionsBadge({ permissions }: { permissions: string[] }) { function PermissionsBadge({ permissions }: { permissions: string[] }) {
if (permissions.includes("*")) { if (permissions.includes("*")) {
return ( return (
@ -463,6 +368,7 @@ function RolesContent({
}) { }) {
const [showCreateRole, setShowCreateRole] = useState(false); const [showCreateRole, setShowCreateRole] = useState(false);
const [editingRoleId, setEditingRoleId] = useState<number | null>(null); const [editingRoleId, setEditingRoleId] = useState<number | null>(null);
const [expandedRoleId, setExpandedRoleId] = useState<number | null>(null);
if (loading) { if (loading) {
return ( return (
@ -508,12 +414,32 @@ function RolesContent({
)} )}
<div className="space-y-3"> <div className="space-y-3">
{roles.map((role) => ( {roles.map((role) => {
<div key={role.id}> const isExpanded = expandedRoleId === role.id;
<div className="w-full text-left relative flex items-center gap-4 rounded-lg border border-border/60 p-4 transition-colors hover:bg-muted/30"> const isFullAccess = role.permissions.includes("*");
<div className="flex-1 min-w-0">
<RolePermissionsDialog permissions={role.permissions} roleName={role.name}> const grouped: Record<string, string[]> = {};
<button type="button" className="w-full text-left cursor-pointer"> if (!isFullAccess) {
for (const perm of role.permissions) {
const [category, action] = perm.split(":");
if (!grouped[category]) grouped[category] = [];
grouped[category].push(action);
}
}
const sortedCategories = Object.keys(grouped).sort((a, b) => {
const orderA = CATEGORY_CONFIG[a]?.order ?? 99;
const orderB = CATEGORY_CONFIG[b]?.order ?? 99;
return orderA - orderB;
});
return (
<div key={role.id} className="rounded-lg border border-border/60 overflow-hidden">
<div className="flex items-center gap-4 p-4 transition-colors hover:bg-muted/30">
<button
type="button"
className="flex-1 min-w-0 text-left cursor-pointer"
onClick={() => setExpandedRoleId(isExpanded ? null : role.id)}
>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="font-medium text-sm">{role.name}</span> <span className="font-medium text-sm">{role.name}</span>
{role.is_system_role && ( {role.is_system_role && (
@ -533,8 +459,6 @@ function RolesContent({
</p> </p>
)} )}
</button> </button>
</RolePermissionsDialog>
</div>
<div className="shrink-0"> <div className="shrink-0">
<PermissionsBadge permissions={role.permissions} /> <PermissionsBadge permissions={role.permissions} />
@ -590,12 +514,73 @@ function RolesContent({
</DropdownMenu> </DropdownMenu>
</div> </div>
)} )}
<button
type="button"
className="shrink-0 p-1 cursor-pointer"
onClick={() => setExpandedRoleId(isExpanded ? null : role.id)}
>
<ChevronDown
className={cn(
"h-4 w-4 text-muted-foreground transition-transform duration-200",
isExpanded && "rotate-180"
)}
/>
</button>
</div> </div>
{isExpanded && (
<div className="border-t border-border/40 px-4 py-3">
{isFullAccess ? (
<div className="flex items-center gap-3 py-2">
<Shield className="h-4 w-4 text-muted-foreground shrink-0" />
<p className="text-sm text-muted-foreground">
Full access all permissions granted across every category
</p>
</div> </div>
) : (
<div className="divide-y divide-border/30">
{sortedCategories.map((category) => {
const actions = grouped[category];
const config = CATEGORY_CONFIG[category] || {
label: category,
icon: FileText,
};
const IconComponent = config.icon;
return (
<div
key={category}
className="flex items-center justify-between gap-3 py-2.5"
>
<div className="flex items-center gap-2 shrink-0">
<IconComponent className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-sm text-muted-foreground">
{config.label}
</span>
</div>
<div className="flex flex-wrap justify-end gap-1">
{actions.map((action) => (
<span
key={action}
className="px-1.5 py-0.5 rounded bg-muted text-muted-foreground text-[11px] font-medium"
>
{ACTION_LABELS[action] || action.replace(/_/g, " ")}
</span>
))} ))}
</div> </div>
</div> </div>
); );
})}
</div>
)}
</div>
)}
</div>
);
})}
</div>
</div>
);
} }
// ============ Permissions Editor (shared by Create and Edit) ============ // ============ Permissions Editor (shared by Create and Edit) ============
@ -676,25 +661,29 @@ function PermissionsEditor({
return ( return (
<div key={category} className="rounded-lg border border-border/60 overflow-hidden"> <div key={category} className="rounded-lg border border-border/60 overflow-hidden">
<div className="flex items-center justify-between px-3 py-2.5 hover:bg-muted/40 transition-colors">
<button <button
type="button" type="button"
className="w-full flex items-center justify-between px-3 py-2.5 cursor-pointer hover:bg-muted/40 transition-colors" className="flex-1 flex items-center gap-2.5 cursor-pointer"
onClick={() => toggleCategoryExpanded(category)} onClick={() => toggleCategoryExpanded(category)}
> >
<div className="flex items-center gap-2.5">
<IconComponent className="h-4 w-4 text-muted-foreground shrink-0" /> <IconComponent className="h-4 w-4 text-muted-foreground shrink-0" />
<span className="font-medium text-sm">{config.label}</span> <span className="font-medium text-sm">{config.label}</span>
<span className="text-[11px] text-muted-foreground tabular-nums"> <span className="text-[11px] text-muted-foreground tabular-nums">
{stats.selected}/{stats.total} {stats.selected}/{stats.total}
</span> </span>
</div> </button>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Checkbox <Checkbox
checked={stats.allSelected} checked={stats.allSelected}
onCheckedChange={() => onToggleCategory(category)} onCheckedChange={() => onToggleCategory(category)}
onClick={(e) => e.stopPropagation()}
aria-label={`Select all ${config.label} permissions`} aria-label={`Select all ${config.label} permissions`}
/> />
<button
type="button"
className="cursor-pointer"
onClick={() => toggleCategoryExpanded(category)}
>
<div <div
className={cn("transition-transform duration-200", isExpanded && "rotate-180")} className={cn("transition-transform duration-200", isExpanded && "rotate-180")}
> >
@ -714,8 +703,9 @@ function PermissionsEditor({
/> />
</svg> </svg>
</div> </div>
</div>
</button> </button>
</div>
</div>
{isExpanded && ( {isExpanded && (
<div className="border-t border-border/60"> <div className="border-t border-border/60">
@ -726,28 +716,29 @@ function PermissionsEditor({
const isSelected = selectedPermissions.includes(perm.value); const isSelected = selectedPermissions.includes(perm.value);
return ( return (
<button <div
key={perm.value} key={perm.value}
type="button"
className={cn( className={cn(
"w-full flex items-center justify-between gap-3 px-2.5 py-2 rounded-md cursor-pointer transition-colors", "flex items-center justify-between gap-3 px-2.5 py-2 rounded-md transition-colors",
isSelected ? "bg-muted/60 hover:bg-muted/80" : "hover:bg-muted/40" isSelected ? "bg-muted/60 hover:bg-muted/80" : "hover:bg-muted/40"
)} )}
>
<button
type="button"
className="flex-1 min-w-0 text-left cursor-pointer"
onClick={() => onTogglePermission(perm.value)} onClick={() => onTogglePermission(perm.value)}
> >
<div className="flex-1 min-w-0 text-left">
<span className="text-sm font-medium">{actionLabel}</span> <span className="text-sm font-medium">{actionLabel}</span>
<p className="text-xs text-muted-foreground truncate"> <p className="text-xs text-muted-foreground truncate">
{perm.description} {perm.description}
</p> </p>
</div> </button>
<Checkbox <Checkbox
checked={isSelected} checked={isSelected}
onCheckedChange={() => onTogglePermission(perm.value)} onCheckedChange={() => onTogglePermission(perm.value)}
onClick={(e) => e.stopPropagation()}
className="shrink-0" className="shrink-0"
/> />
</button> </div>
); );
})} })}
</div> </div>