mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-21 18:55:16 +02:00
feat(rbac): add human-readable descriptions for permissions and enhance role creation UI
This commit is contained in:
parent
55d8594937
commit
fd564d3efd
4 changed files with 689 additions and 301 deletions
|
|
@ -59,6 +59,58 @@ router = APIRouter()
|
||||||
|
|
||||||
# ============ Permissions Endpoints ============
|
# ============ Permissions Endpoints ============
|
||||||
|
|
||||||
|
# Human-readable descriptions for each permission
|
||||||
|
PERMISSION_DESCRIPTIONS = {
|
||||||
|
# Documents
|
||||||
|
"documents:create": "Add new documents, files, and content to the search space",
|
||||||
|
"documents:read": "View and search documents in the search space",
|
||||||
|
"documents:update": "Edit existing documents and their metadata",
|
||||||
|
"documents:delete": "Remove documents from the search space",
|
||||||
|
# Chats
|
||||||
|
"chats:create": "Start new AI chat conversations",
|
||||||
|
"chats:read": "View chat history and conversations",
|
||||||
|
"chats:update": "Edit chat titles and settings",
|
||||||
|
"chats:delete": "Delete chat conversations",
|
||||||
|
# Comments
|
||||||
|
"comments:create": "Add comments and annotations to documents",
|
||||||
|
"comments:read": "View comments on documents",
|
||||||
|
"comments:delete": "Remove comments from documents",
|
||||||
|
# LLM Configs
|
||||||
|
"llm_configs:create": "Add new AI model configurations",
|
||||||
|
"llm_configs:read": "View AI model settings and configurations",
|
||||||
|
"llm_configs:update": "Modify AI model configurations",
|
||||||
|
"llm_configs:delete": "Remove AI model configurations",
|
||||||
|
# Podcasts
|
||||||
|
"podcasts:create": "Generate new AI podcasts from content",
|
||||||
|
"podcasts:read": "Listen to and view generated podcasts",
|
||||||
|
"podcasts:update": "Edit podcast settings and metadata",
|
||||||
|
"podcasts:delete": "Remove generated podcasts",
|
||||||
|
# Connectors
|
||||||
|
"connectors:create": "Set up new data source integrations",
|
||||||
|
"connectors:read": "View configured data sources and their status",
|
||||||
|
"connectors:update": "Modify data source configurations",
|
||||||
|
"connectors:delete": "Remove data source integrations",
|
||||||
|
# Logs
|
||||||
|
"logs:read": "View activity logs and audit trail",
|
||||||
|
"logs:delete": "Clear activity logs",
|
||||||
|
# Members
|
||||||
|
"members:invite": "Send invitations to new team members",
|
||||||
|
"members:view": "View the list of team members",
|
||||||
|
"members:remove": "Remove members from the search space",
|
||||||
|
"members:manage_roles": "Assign and change member roles",
|
||||||
|
# Roles
|
||||||
|
"roles:create": "Create new custom roles",
|
||||||
|
"roles:read": "View available roles and their permissions",
|
||||||
|
"roles:update": "Modify role permissions",
|
||||||
|
"roles:delete": "Remove custom roles",
|
||||||
|
# Settings
|
||||||
|
"settings:view": "View search space settings",
|
||||||
|
"settings:update": "Modify search space settings",
|
||||||
|
"settings:delete": "Delete the entire search space",
|
||||||
|
# Full access
|
||||||
|
"*": "Full access to all features and settings",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/permissions", response_model=PermissionsListResponse)
|
@router.get("/permissions", response_model=PermissionsListResponse)
|
||||||
async def list_all_permissions(
|
async def list_all_permissions(
|
||||||
|
|
@ -71,12 +123,14 @@ async def list_all_permissions(
|
||||||
for perm in Permission:
|
for perm in Permission:
|
||||||
# Extract category from permission value (e.g., "documents:read" -> "documents")
|
# Extract category from permission value (e.g., "documents:read" -> "documents")
|
||||||
category = perm.value.split(":")[0] if ":" in perm.value else "general"
|
category = perm.value.split(":")[0] if ":" in perm.value else "general"
|
||||||
|
description = PERMISSION_DESCRIPTIONS.get(perm.value, f"Permission for {perm.value}")
|
||||||
|
|
||||||
permissions.append(
|
permissions.append(
|
||||||
PermissionInfo(
|
PermissionInfo(
|
||||||
value=perm.value,
|
value=perm.value,
|
||||||
name=perm.name,
|
name=perm.name,
|
||||||
category=category,
|
category=category,
|
||||||
|
description=description,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -167,6 +167,7 @@ class PermissionInfo(BaseModel):
|
||||||
value: str
|
value: str
|
||||||
name: str
|
name: str
|
||||||
category: str
|
category: str
|
||||||
|
description: str
|
||||||
|
|
||||||
|
|
||||||
class PermissionsListResponse(BaseModel):
|
class PermissionsListResponse(BaseModel):
|
||||||
|
|
|
||||||
|
|
@ -122,6 +122,7 @@ import type {
|
||||||
Role,
|
Role,
|
||||||
UpdateRoleRequest,
|
UpdateRoleRequest,
|
||||||
} from "@/contracts/types/roles.types";
|
} from "@/contracts/types/roles.types";
|
||||||
|
import type { PermissionInfo } from "@/contracts/types/permissions.types";
|
||||||
import { invitesApiService } from "@/lib/apis/invites-api.service";
|
import { invitesApiService } from "@/lib/apis/invites-api.service";
|
||||||
import { rolesApiService } from "@/lib/apis/roles-api.service";
|
import { rolesApiService } from "@/lib/apis/roles-api.service";
|
||||||
import { trackSearchSpaceInviteSent, trackSearchSpaceUsersViewed } from "@/lib/posthog/events";
|
import { trackSearchSpaceInviteSent, trackSearchSpaceUsersViewed } from "@/lib/posthog/events";
|
||||||
|
|
@ -471,13 +472,6 @@ export default function TeamManagementPage() {
|
||||||
className="w-full md:w-auto"
|
className="w-full md:w-auto"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{activeTab === "roles" && hasPermission("roles:create") && (
|
|
||||||
<CreateRoleDialog
|
|
||||||
groupedPermissions={groupedPermissions}
|
|
||||||
onCreateRole={handleCreateRole}
|
|
||||||
className="w-full md:w-auto"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<TabsContent value="members" className="space-y-4">
|
<TabsContent value="members" className="space-y-4">
|
||||||
|
|
@ -499,8 +493,10 @@ export default function TeamManagementPage() {
|
||||||
loading={rolesLoading}
|
loading={rolesLoading}
|
||||||
onUpdateRole={handleUpdateRole}
|
onUpdateRole={handleUpdateRole}
|
||||||
onDeleteRole={handleDeleteRole}
|
onDeleteRole={handleDeleteRole}
|
||||||
|
onCreateRole={handleCreateRole}
|
||||||
canUpdate={hasPermission("roles:update")}
|
canUpdate={hasPermission("roles:update")}
|
||||||
canDelete={hasPermission("roles:delete")}
|
canDelete={hasPermission("roles:delete")}
|
||||||
|
canCreate={hasPermission("roles:create")}
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
|
@ -893,21 +889,27 @@ function RolePermissionsDisplay({ permissions }: { permissions: string[] }) {
|
||||||
|
|
||||||
function RolesTab({
|
function RolesTab({
|
||||||
roles,
|
roles,
|
||||||
groupedPermissions: _groupedPermissions,
|
groupedPermissions,
|
||||||
loading,
|
loading,
|
||||||
onUpdateRole: _onUpdateRole,
|
onUpdateRole: _onUpdateRole,
|
||||||
onDeleteRole,
|
onDeleteRole,
|
||||||
|
onCreateRole,
|
||||||
canUpdate,
|
canUpdate,
|
||||||
canDelete,
|
canDelete,
|
||||||
|
canCreate,
|
||||||
}: {
|
}: {
|
||||||
roles: Role[];
|
roles: Role[];
|
||||||
groupedPermissions: Record<string, { value: string; name: string; category: string }[]>;
|
groupedPermissions: Record<string, PermissionWithDescription[]>;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
onUpdateRole: (roleId: number, data: { permissions?: string[] }) => Promise<Role>;
|
onUpdateRole: (roleId: number, data: { permissions?: string[] }) => Promise<Role>;
|
||||||
onDeleteRole: (roleId: number) => Promise<boolean>;
|
onDeleteRole: (roleId: number) => Promise<boolean>;
|
||||||
|
onCreateRole: (data: CreateRoleRequest["data"]) => Promise<Role>;
|
||||||
canUpdate: boolean;
|
canUpdate: boolean;
|
||||||
canDelete: boolean;
|
canDelete: boolean;
|
||||||
|
canCreate: boolean;
|
||||||
}) {
|
}) {
|
||||||
|
const [showCreateRole, setShowCreateRole] = useState(false);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center py-12">
|
<div className="flex items-center justify-center py-12">
|
||||||
|
|
@ -921,8 +923,33 @@ function RolesTab({
|
||||||
initial={{ opacity: 0, y: 10 }}
|
initial={{ opacity: 0, y: 10 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
exit={{ opacity: 0, y: -10 }}
|
exit={{ opacity: 0, y: -10 }}
|
||||||
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"
|
className="space-y-6"
|
||||||
>
|
>
|
||||||
|
{/* Create Role Button / Section */}
|
||||||
|
{canCreate && !showCreateRole && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
className="flex justify-end"
|
||||||
|
>
|
||||||
|
<Button onClick={() => setShowCreateRole(true)} className="gap-2">
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
Create Custom Role
|
||||||
|
</Button>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Create Role Form */}
|
||||||
|
{showCreateRole && (
|
||||||
|
<CreateRoleSection
|
||||||
|
groupedPermissions={groupedPermissions}
|
||||||
|
onCreateRole={onCreateRole}
|
||||||
|
onCancel={() => setShowCreateRole(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Roles Grid */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
{roles.map((role, index) => (
|
{roles.map((role, index) => (
|
||||||
<motion.div
|
<motion.div
|
||||||
key={role.id}
|
key={role.id}
|
||||||
|
|
@ -1007,8 +1034,8 @@ function RolesTab({
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>Delete role?</AlertDialogTitle>
|
<AlertDialogTitle>Delete role?</AlertDialogTitle>
|
||||||
<AlertDialogDescription>
|
<AlertDialogDescription>
|
||||||
This will permanently delete the "{role.name}" role. Members with
|
This will permanently delete the "{role.name}" role. Members
|
||||||
this role will lose their permissions.
|
with this role will lose their permissions.
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
|
|
@ -1038,6 +1065,7 @@ function RolesTab({
|
||||||
</Card>
|
</Card>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
))}
|
))}
|
||||||
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -1461,13 +1489,14 @@ function CreateInviteDialog({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============ Create Role Dialog ============
|
// ============ Create Role Section ============
|
||||||
|
|
||||||
// Preset permission sets for quick role creation
|
// Preset permission sets for quick role creation
|
||||||
// Editor: can create/read/update content, but cannot manage roles, remove members, or change settings
|
const ROLE_PRESETS = {
|
||||||
// Viewer: read-only access with ability to create comments
|
editor: {
|
||||||
const PRESET_PERMISSIONS = {
|
name: "Editor",
|
||||||
editor: [
|
description: "Can create, read, and update content, but cannot delete or manage team settings",
|
||||||
|
permissions: [
|
||||||
"documents:create",
|
"documents:create",
|
||||||
"documents:read",
|
"documents:read",
|
||||||
"documents:update",
|
"documents:update",
|
||||||
|
|
@ -1491,7 +1520,11 @@ const PRESET_PERMISSIONS = {
|
||||||
"roles:read",
|
"roles:read",
|
||||||
"settings:view",
|
"settings:view",
|
||||||
],
|
],
|
||||||
viewer: [
|
},
|
||||||
|
viewer: {
|
||||||
|
name: "Viewer",
|
||||||
|
description: "Read-only access with ability to add comments",
|
||||||
|
permissions: [
|
||||||
"documents:read",
|
"documents:read",
|
||||||
"chats:read",
|
"chats:read",
|
||||||
"comments:create",
|
"comments:create",
|
||||||
|
|
@ -1504,23 +1537,135 @@ const PRESET_PERMISSIONS = {
|
||||||
"roles:read",
|
"roles:read",
|
||||||
"settings:view",
|
"settings:view",
|
||||||
],
|
],
|
||||||
|
},
|
||||||
|
contributor: {
|
||||||
|
name: "Contributor",
|
||||||
|
description: "Can add and manage their own content",
|
||||||
|
permissions: [
|
||||||
|
"documents:create",
|
||||||
|
"documents:read",
|
||||||
|
"documents:update",
|
||||||
|
"chats:create",
|
||||||
|
"chats:read",
|
||||||
|
"comments:create",
|
||||||
|
"comments:read",
|
||||||
|
"llm_configs:read",
|
||||||
|
"podcasts:read",
|
||||||
|
"connectors:read",
|
||||||
|
"logs:read",
|
||||||
|
"members:view",
|
||||||
|
"roles:read",
|
||||||
|
"settings:view",
|
||||||
|
],
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
function CreateRoleDialog({
|
// Category display configuration with icons and descriptions
|
||||||
|
const CATEGORY_DISPLAY: Record<
|
||||||
|
string,
|
||||||
|
{ label: string; icon: LucideIcon; description: string; order: number }
|
||||||
|
> = {
|
||||||
|
documents: {
|
||||||
|
label: "Documents",
|
||||||
|
icon: FileText,
|
||||||
|
description: "Manage files, notes, and content",
|
||||||
|
order: 1,
|
||||||
|
},
|
||||||
|
chats: {
|
||||||
|
label: "AI Chats",
|
||||||
|
icon: MessageSquare,
|
||||||
|
description: "Create and manage AI conversations",
|
||||||
|
order: 2,
|
||||||
|
},
|
||||||
|
comments: {
|
||||||
|
label: "Comments",
|
||||||
|
icon: MessageCircle,
|
||||||
|
description: "Add annotations to documents",
|
||||||
|
order: 3,
|
||||||
|
},
|
||||||
|
llm_configs: {
|
||||||
|
label: "AI Models",
|
||||||
|
icon: Bot,
|
||||||
|
description: "Configure AI model settings",
|
||||||
|
order: 4,
|
||||||
|
},
|
||||||
|
podcasts: {
|
||||||
|
label: "Podcasts",
|
||||||
|
icon: Mic,
|
||||||
|
description: "Generate AI podcasts from content",
|
||||||
|
order: 5,
|
||||||
|
},
|
||||||
|
connectors: {
|
||||||
|
label: "Integrations",
|
||||||
|
icon: Plug,
|
||||||
|
description: "Connect external data sources",
|
||||||
|
order: 6,
|
||||||
|
},
|
||||||
|
logs: {
|
||||||
|
label: "Activity Logs",
|
||||||
|
icon: Logs,
|
||||||
|
description: "View and manage audit trail",
|
||||||
|
order: 7,
|
||||||
|
},
|
||||||
|
members: {
|
||||||
|
label: "Team Members",
|
||||||
|
icon: Users,
|
||||||
|
description: "Manage team membership",
|
||||||
|
order: 8,
|
||||||
|
},
|
||||||
|
roles: {
|
||||||
|
label: "Roles",
|
||||||
|
icon: Shield,
|
||||||
|
description: "Configure role permissions",
|
||||||
|
order: 9,
|
||||||
|
},
|
||||||
|
settings: {
|
||||||
|
label: "Settings",
|
||||||
|
icon: Settings,
|
||||||
|
description: "Manage search space settings",
|
||||||
|
order: 10,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Action display labels
|
||||||
|
const ACTION_DISPLAY: Record<string, { label: string; color: string }> = {
|
||||||
|
create: { label: "Create", color: "text-emerald-600 bg-emerald-500/10" },
|
||||||
|
read: { label: "View", color: "text-blue-600 bg-blue-500/10" },
|
||||||
|
update: { label: "Edit", color: "text-amber-600 bg-amber-500/10" },
|
||||||
|
delete: { label: "Delete", color: "text-red-600 bg-red-500/10" },
|
||||||
|
invite: { label: "Invite", color: "text-violet-600 bg-violet-500/10" },
|
||||||
|
view: { label: "View", color: "text-blue-600 bg-blue-500/10" },
|
||||||
|
remove: { label: "Remove", color: "text-red-600 bg-red-500/10" },
|
||||||
|
manage_roles: { label: "Manage Roles", color: "text-violet-600 bg-violet-500/10" },
|
||||||
|
};
|
||||||
|
|
||||||
|
// Use the imported PermissionInfo type which now includes description
|
||||||
|
type PermissionWithDescription = PermissionInfo;
|
||||||
|
|
||||||
|
function CreateRoleSection({
|
||||||
groupedPermissions,
|
groupedPermissions,
|
||||||
onCreateRole,
|
onCreateRole,
|
||||||
className,
|
onCancel,
|
||||||
}: {
|
}: {
|
||||||
groupedPermissions: Record<string, { value: string; name: string; category: string }[]>;
|
groupedPermissions: Record<string, PermissionWithDescription[]>;
|
||||||
onCreateRole: (data: CreateRoleRequest["data"]) => Promise<Role>;
|
onCreateRole: (data: CreateRoleRequest["data"]) => Promise<Role>;
|
||||||
className?: string;
|
onCancel: () => void;
|
||||||
}) {
|
}) {
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const [creating, setCreating] = useState(false);
|
const [creating, setCreating] = useState(false);
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [description, setDescription] = useState("");
|
const [description, setDescription] = useState("");
|
||||||
const [selectedPermissions, setSelectedPermissions] = useState<string[]>([]);
|
const [selectedPermissions, setSelectedPermissions] = useState<string[]>([]);
|
||||||
const [isDefault, setIsDefault] = useState(false);
|
const [isDefault, setIsDefault] = useState(false);
|
||||||
|
const [expandedCategories, setExpandedCategories] = useState<string[]>([]);
|
||||||
|
|
||||||
|
// Sort categories by order
|
||||||
|
const sortedCategories = useMemo(() => {
|
||||||
|
return Object.keys(groupedPermissions).sort((a, b) => {
|
||||||
|
const orderA = CATEGORY_DISPLAY[a]?.order ?? 99;
|
||||||
|
const orderB = CATEGORY_DISPLAY[b]?.order ?? 99;
|
||||||
|
return orderA - orderB;
|
||||||
|
});
|
||||||
|
}, [groupedPermissions]);
|
||||||
|
|
||||||
const handleCreate = async () => {
|
const handleCreate = async () => {
|
||||||
if (!name.trim()) {
|
if (!name.trim()) {
|
||||||
|
|
@ -1536,11 +1681,7 @@ function CreateRoleDialog({
|
||||||
permissions: selectedPermissions,
|
permissions: selectedPermissions,
|
||||||
is_default: isDefault,
|
is_default: isDefault,
|
||||||
});
|
});
|
||||||
setOpen(false);
|
onCancel();
|
||||||
setName("");
|
|
||||||
setDescription("");
|
|
||||||
setSelectedPermissions([]);
|
|
||||||
setIsDefault(false);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to create role:", error);
|
console.error("Failed to create role:", error);
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -1548,13 +1689,14 @@ function CreateRoleDialog({
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const togglePermission = (perm: string) => {
|
const togglePermission = useCallback((perm: string) => {
|
||||||
setSelectedPermissions((prev) =>
|
setSelectedPermissions((prev) =>
|
||||||
prev.includes(perm) ? prev.filter((p) => p !== perm) : [...prev, perm]
|
prev.includes(perm) ? prev.filter((p) => p !== perm) : [...prev, perm]
|
||||||
);
|
);
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const toggleCategory = (category: string) => {
|
const toggleCategory = useCallback(
|
||||||
|
(category: string) => {
|
||||||
const categoryPerms = groupedPermissions[category]?.map((p) => p.value) || [];
|
const categoryPerms = groupedPermissions[category]?.map((p) => p.value) || [];
|
||||||
const allSelected = categoryPerms.every((p) => selectedPermissions.includes(p));
|
const allSelected = categoryPerms.every((p) => selectedPermissions.includes(p));
|
||||||
|
|
||||||
|
|
@ -1563,151 +1705,341 @@ function CreateRoleDialog({
|
||||||
} else {
|
} else {
|
||||||
setSelectedPermissions((prev) => [...new Set([...prev, ...categoryPerms])]);
|
setSelectedPermissions((prev) => [...new Set([...prev, ...categoryPerms])]);
|
||||||
}
|
}
|
||||||
};
|
},
|
||||||
|
[groupedPermissions, selectedPermissions]
|
||||||
|
);
|
||||||
|
|
||||||
const applyPreset = (preset: "editor" | "viewer") => {
|
const toggleCategoryExpanded = useCallback((category: string) => {
|
||||||
setSelectedPermissions(PRESET_PERMISSIONS[preset]);
|
setExpandedCategories((prev) =>
|
||||||
toast.success(`Applied ${preset === "editor" ? "Editor" : "Viewer"} preset permissions`);
|
prev.includes(category) ? prev.filter((c) => c !== category) : [...prev, category]
|
||||||
};
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const applyPreset = useCallback((presetKey: keyof typeof ROLE_PRESETS) => {
|
||||||
|
const preset = ROLE_PRESETS[presetKey];
|
||||||
|
setSelectedPermissions(preset.permissions);
|
||||||
|
if (!name.trim()) {
|
||||||
|
setName(preset.name);
|
||||||
|
setDescription(preset.description);
|
||||||
|
}
|
||||||
|
toast.success(`Applied ${preset.name} preset`);
|
||||||
|
}, [name]);
|
||||||
|
|
||||||
|
const getCategoryStats = useCallback(
|
||||||
|
(category: string) => {
|
||||||
|
const perms = groupedPermissions[category] || [];
|
||||||
|
const selected = perms.filter((p) => selectedPermissions.includes(p.value)).length;
|
||||||
|
return { selected, total: perms.length, allSelected: selected === perms.length };
|
||||||
|
},
|
||||||
|
[groupedPermissions, selectedPermissions]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<motion.div
|
||||||
<DialogTrigger asChild>
|
initial={{ opacity: 0, y: -10 }}
|
||||||
<Button className={cn("gap-2", className)}>
|
animate={{ opacity: 1, y: 0 }}
|
||||||
<Plus className="h-4 w-4" />
|
exit={{ opacity: 0, y: -10 }}
|
||||||
Create Role
|
className="mb-6"
|
||||||
|
>
|
||||||
|
<Card className="border-primary/20 bg-gradient-to-br from-primary/5 via-background to-background">
|
||||||
|
<CardHeader className="pb-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="h-10 w-10 rounded-xl bg-primary/10 flex items-center justify-center">
|
||||||
|
<Plus className="h-5 w-5 text-primary" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-lg">Create Custom Role</CardTitle>
|
||||||
|
<CardDescription className="text-sm">
|
||||||
|
Define permissions for a new role in this search space
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button variant="ghost" size="icon" onClick={onCancel}>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</DialogTrigger>
|
</div>
|
||||||
<DialogContent className="w-[92vw] max-w-[92vw] sm:max-w-xl p-4 md:p-6">
|
</CardHeader>
|
||||||
<DialogHeader>
|
<CardContent className="space-y-6">
|
||||||
<DialogTitle>Create Custom Role</DialogTitle>
|
{/* Quick Start with Presets */}
|
||||||
<DialogDescription className="text-xs md:text-sm">
|
<div className="space-y-3">
|
||||||
Define a new role with specific permissions for this search space.
|
<Label className="text-sm font-medium">Quick Start with a Template</Label>
|
||||||
</DialogDescription>
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||||
</DialogHeader>
|
{Object.entries(ROLE_PRESETS).map(([key, preset]) => (
|
||||||
<div className="space-y-3 py-2 md:py-4">
|
<button
|
||||||
<div className="flex flex-col md:grid md:grid-cols-2 gap-3 md:gap-4">
|
key={key}
|
||||||
|
type="button"
|
||||||
|
onClick={() => applyPreset(key as keyof typeof ROLE_PRESETS)}
|
||||||
|
className={cn(
|
||||||
|
"p-4 rounded-lg border-2 text-left transition-all hover:border-primary/50 hover:bg-primary/5",
|
||||||
|
selectedPermissions.length > 0 &&
|
||||||
|
preset.permissions.every((p) => selectedPermissions.includes(p))
|
||||||
|
? "border-primary bg-primary/10"
|
||||||
|
: "border-border"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<ShieldCheck
|
||||||
|
className={cn(
|
||||||
|
"h-4 w-4",
|
||||||
|
key === "editor" && "text-blue-600",
|
||||||
|
key === "viewer" && "text-gray-600",
|
||||||
|
key === "contributor" && "text-emerald-600"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<span className="font-medium text-sm">{preset.name}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">{preset.description}</p>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Role Details */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="role-name">Role Name *</Label>
|
<Label htmlFor="role-name">Role Name *</Label>
|
||||||
<Input
|
<Input
|
||||||
id="role-name"
|
id="role-name"
|
||||||
placeholder="e.g., Contributor"
|
placeholder="e.g., Content Manager"
|
||||||
value={name}
|
value={name}
|
||||||
onChange={(e) => setName(e.target.value)}
|
onChange={(e) => setName(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label className="flex items-center gap-2">
|
<Label htmlFor="role-description">Description</Label>
|
||||||
<Checkbox checked={isDefault} onCheckedChange={(v) => setIsDefault(!!v)} />
|
<Input
|
||||||
|
id="role-description"
|
||||||
|
placeholder="Brief description of this role"
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Default Role Checkbox */}
|
||||||
|
<div className="flex items-center gap-3 p-3 rounded-lg bg-muted/50">
|
||||||
|
<Checkbox
|
||||||
|
id="is-default"
|
||||||
|
checked={isDefault}
|
||||||
|
onCheckedChange={(checked) => setIsDefault(checked === true)}
|
||||||
|
/>
|
||||||
|
<div className="flex-1">
|
||||||
|
<Label htmlFor="is-default" className="cursor-pointer font-medium">
|
||||||
Set as default role
|
Set as default role
|
||||||
</Label>
|
</Label>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
New invites without a role will use this
|
New members without a specific role will be assigned this role
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="role-description">Description</Label>
|
{/* Permissions Section */}
|
||||||
<Textarea
|
<div className="space-y-3">
|
||||||
id="role-description"
|
|
||||||
placeholder="Describe what this role can do..."
|
|
||||||
value={description}
|
|
||||||
onChange={(e) => setDescription(e.target.value)}
|
|
||||||
rows={2}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Label>Permissions ({selectedPermissions.length} selected)</Label>
|
<Label className="text-sm font-medium">
|
||||||
<div className="flex gap-2">
|
Permissions ({selectedPermissions.length} selected)
|
||||||
|
</Label>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="h-7 text-xs gap-1"
|
className="text-xs h-7"
|
||||||
onClick={() => applyPreset("editor")}
|
onClick={() =>
|
||||||
|
setExpandedCategories(
|
||||||
|
expandedCategories.length === sortedCategories.length ? [] : sortedCategories
|
||||||
|
)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<ShieldCheck className="h-3 w-3 text-blue-600" />
|
{expandedCategories.length === sortedCategories.length
|
||||||
Editor Preset
|
? "Collapse All"
|
||||||
</Button>
|
: "Expand All"}
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="h-7 text-xs gap-1"
|
|
||||||
onClick={() => applyPreset("viewer")}
|
|
||||||
>
|
|
||||||
<ShieldCheck className="h-3 w-3 text-gray-600" />
|
|
||||||
Viewer Preset
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
<div className="space-y-2">
|
||||||
Use presets to quickly apply Editor (create/read/update) or Viewer (read-only)
|
{sortedCategories.map((category) => {
|
||||||
permissions
|
const config = CATEGORY_DISPLAY[category] || {
|
||||||
</p>
|
label: category,
|
||||||
<ScrollArea className="h-64 rounded-lg border p-4">
|
icon: FileText,
|
||||||
<div className="space-y-4">
|
description: "",
|
||||||
{Object.entries(groupedPermissions).map(([category, perms]) => {
|
};
|
||||||
const categorySelected = perms.filter((p) =>
|
const IconComponent = config.icon;
|
||||||
selectedPermissions.includes(p.value)
|
const stats = getCategoryStats(category);
|
||||||
).length;
|
const isExpanded = expandedCategories.includes(category);
|
||||||
const allSelected = categorySelected === perms.length;
|
const perms = groupedPermissions[category] || [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={category} className="space-y-2">
|
<div
|
||||||
<button
|
key={category}
|
||||||
type="button"
|
className="rounded-lg border bg-card overflow-hidden"
|
||||||
className="flex items-center gap-2 cursor-pointer hover:bg-muted/50 p-1 rounded w-full text-left"
|
|
||||||
onClick={() => toggleCategory(category)}
|
|
||||||
>
|
>
|
||||||
|
{/* Category Header */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex items-center justify-between p-3 cursor-pointer hover:bg-muted/50 transition-colors",
|
||||||
|
stats.allSelected && "bg-primary/5"
|
||||||
|
)}
|
||||||
|
onClick={() => toggleCategoryExpanded(category)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
e.preventDefault();
|
||||||
|
toggleCategoryExpanded(category);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
tabIndex={0}
|
||||||
|
role="button"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"h-8 w-8 rounded-lg flex items-center justify-center",
|
||||||
|
stats.selected > 0 ? "bg-primary/10" : "bg-muted"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<IconComponent
|
||||||
|
className={cn(
|
||||||
|
"h-4 w-4",
|
||||||
|
stats.selected > 0 ? "text-primary" : "text-muted-foreground"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-medium text-sm">{config.label}</span>
|
||||||
|
<Badge
|
||||||
|
variant={stats.selected > 0 ? "default" : "secondary"}
|
||||||
|
className="text-xs h-5"
|
||||||
|
>
|
||||||
|
{stats.selected}/{stats.total}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground hidden md:block">
|
||||||
|
{config.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={allSelected}
|
checked={stats.allSelected}
|
||||||
onCheckedChange={() => toggleCategory(category)}
|
onCheckedChange={() => toggleCategory(category)}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
aria-label={`Select all ${config.label} permissions`}
|
||||||
/>
|
/>
|
||||||
<span className="text-sm font-medium capitalize">
|
<motion.div
|
||||||
{category} ({categorySelected}/{perms.length})
|
animate={{ rotate: isExpanded ? 180 : 0 }}
|
||||||
</span>
|
transition={{ duration: 0.2 }}
|
||||||
</button>
|
|
||||||
<div className="grid grid-cols-2 gap-2 ml-6">
|
|
||||||
{perms.map((perm) => (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
key={perm.value}
|
|
||||||
className="flex items-center gap-2 cursor-pointer text-left"
|
|
||||||
onClick={() => togglePermission(perm.value)}
|
|
||||||
>
|
>
|
||||||
<Checkbox
|
<svg
|
||||||
checked={selectedPermissions.includes(perm.value)}
|
className="h-4 w-4 text-muted-foreground"
|
||||||
onCheckedChange={() => togglePermission(perm.value)}
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M19 9l-7 7-7-7"
|
||||||
/>
|
/>
|
||||||
<span className="text-xs">{perm.value.split(":")[1]}</span>
|
</svg>
|
||||||
</button>
|
</motion.div>
|
||||||
))}
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Permissions List */}
|
||||||
|
{isExpanded && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ height: 0, opacity: 0 }}
|
||||||
|
animate={{ height: "auto", opacity: 1 }}
|
||||||
|
exit={{ height: 0, opacity: 0 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
className="border-t"
|
||||||
|
>
|
||||||
|
<div className="p-3 space-y-1">
|
||||||
|
{perms.map((perm) => {
|
||||||
|
const action = perm.value.split(":")[1];
|
||||||
|
const actionConfig = ACTION_DISPLAY[action] || {
|
||||||
|
label: action,
|
||||||
|
color: "text-gray-600 bg-gray-500/10",
|
||||||
|
};
|
||||||
|
const isSelected = selectedPermissions.includes(perm.value);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={perm.value}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center justify-between p-2 rounded-md cursor-pointer transition-colors",
|
||||||
|
isSelected
|
||||||
|
? "bg-primary/10 hover:bg-primary/15"
|
||||||
|
: "hover:bg-muted/50"
|
||||||
|
)}
|
||||||
|
onClick={() => togglePermission(perm.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
e.preventDefault();
|
||||||
|
togglePermission(perm.value);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
tabIndex={0}
|
||||||
|
role="checkbox"
|
||||||
|
aria-checked={isSelected}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||||
|
<Checkbox
|
||||||
|
checked={isSelected}
|
||||||
|
onCheckedChange={() => togglePermission(perm.value)}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"text-xs font-medium px-2 py-0.5 rounded",
|
||||||
|
actionConfig.color
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{actionConfig.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5 truncate">
|
||||||
|
{perm.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
|
||||||
<Button variant="outline" onClick={() => setOpen(false)}>
|
{/* Actions */}
|
||||||
|
<div className="flex items-center justify-end gap-3 pt-4 border-t">
|
||||||
|
<Button variant="outline" onClick={onCancel}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleCreate} disabled={creating || !name.trim()}>
|
<Button onClick={handleCreate} disabled={creating || !name.trim()}>
|
||||||
{creating ? (
|
{creating ? (
|
||||||
<>
|
<>
|
||||||
<Spinner size="sm" className="mr-2" />
|
<Spinner size="sm" className="mr-2" />
|
||||||
Creating
|
Creating...
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
"Create Role"
|
<>
|
||||||
|
<Check className="h-4 w-4 mr-2" />
|
||||||
|
Create Role
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</div>
|
||||||
</DialogContent>
|
</CardContent>
|
||||||
</Dialog>
|
</Card>
|
||||||
|
</motion.div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ export const permissionInfo = z.object({
|
||||||
value: z.string(),
|
value: z.string(),
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
category: z.string(),
|
category: z.string(),
|
||||||
|
description: z.string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue