From fd564d3efd5900673cc18b968bc9835a1aca7d2e Mon Sep 17 00:00:00 2001 From: "DESKTOP-RTLN3BA\\$punk" Date: Mon, 26 Jan 2026 23:56:45 -0800 Subject: [PATCH] feat(rbac): add human-readable descriptions for permissions and enhance role creation UI --- surfsense_backend/app/routes/rbac_routes.py | 54 + surfsense_backend/app/schemas/rbac_schemas.py | 1 + .../dashboard/[search_space_id]/team/page.tsx | 934 ++++++++++++------ .../contracts/types/permissions.types.ts | 1 + 4 files changed, 689 insertions(+), 301 deletions(-) diff --git a/surfsense_backend/app/routes/rbac_routes.py b/surfsense_backend/app/routes/rbac_routes.py index 84e95f7ca..5070a2724 100644 --- a/surfsense_backend/app/routes/rbac_routes.py +++ b/surfsense_backend/app/routes/rbac_routes.py @@ -59,6 +59,58 @@ router = APIRouter() # ============ 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) async def list_all_permissions( @@ -71,12 +123,14 @@ async def list_all_permissions( for perm in Permission: # Extract category from permission value (e.g., "documents:read" -> "documents") category = perm.value.split(":")[0] if ":" in perm.value else "general" + description = PERMISSION_DESCRIPTIONS.get(perm.value, f"Permission for {perm.value}") permissions.append( PermissionInfo( value=perm.value, name=perm.name, category=category, + description=description, ) ) diff --git a/surfsense_backend/app/schemas/rbac_schemas.py b/surfsense_backend/app/schemas/rbac_schemas.py index a51f3bc28..031eef3d2 100644 --- a/surfsense_backend/app/schemas/rbac_schemas.py +++ b/surfsense_backend/app/schemas/rbac_schemas.py @@ -167,6 +167,7 @@ class PermissionInfo(BaseModel): value: str name: str category: str + description: str class PermissionsListResponse(BaseModel): diff --git a/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx index 91ee9de03..ccec9f623 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx @@ -122,6 +122,7 @@ import type { Role, UpdateRoleRequest, } from "@/contracts/types/roles.types"; +import type { PermissionInfo } from "@/contracts/types/permissions.types"; import { invitesApiService } from "@/lib/apis/invites-api.service"; import { rolesApiService } from "@/lib/apis/roles-api.service"; import { trackSearchSpaceInviteSent, trackSearchSpaceUsersViewed } from "@/lib/posthog/events"; @@ -471,13 +472,6 @@ export default function TeamManagementPage() { className="w-full md:w-auto" /> )} - {activeTab === "roles" && hasPermission("roles:create") && ( - - )} @@ -499,8 +493,10 @@ export default function TeamManagementPage() { loading={rolesLoading} onUpdateRole={handleUpdateRole} onDeleteRole={handleDeleteRole} + onCreateRole={handleCreateRole} canUpdate={hasPermission("roles:update")} canDelete={hasPermission("roles:delete")} + canCreate={hasPermission("roles:create")} /> @@ -893,21 +889,27 @@ function RolePermissionsDisplay({ permissions }: { permissions: string[] }) { function RolesTab({ roles, - groupedPermissions: _groupedPermissions, + groupedPermissions, loading, onUpdateRole: _onUpdateRole, onDeleteRole, + onCreateRole, canUpdate, canDelete, + canCreate, }: { roles: Role[]; - groupedPermissions: Record; + groupedPermissions: Record; loading: boolean; onUpdateRole: (roleId: number, data: { permissions?: string[] }) => Promise; onDeleteRole: (roleId: number) => Promise; + onCreateRole: (data: CreateRoleRequest["data"]) => Promise; canUpdate: boolean; canDelete: boolean; + canCreate: boolean; }) { + const [showCreateRole, setShowCreateRole] = useState(false); + if (loading) { return (
@@ -921,123 +923,149 @@ function RolesTab({ initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -10 }} - className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4" + className="space-y-6" > - {roles.map((role, index) => ( + {/* Create Role Button / Section */} + {canCreate && !showCreateRole && ( - - {role.is_system_role && ( -
- System Role -
- )} - -
-
-
- -
-
- {role.name} - {role.is_default && ( - - Default - - )} -
-
- {!role.is_system_role && ( - - - - - - {canUpdate && ( - { - // TODO: Implement edit role dialog/modal - }} - > - - Edit Role - - )} - {canDelete && ( - <> - - - - e.preventDefault()} - > - - Delete Role - - - - - Delete role? - - This will permanently delete the "{role.name}" role. Members with - this role will lose their permissions. - - - - Cancel - onDeleteRole(role.id)} - className="bg-destructive text-destructive-foreground hover:bg-destructive/90" - > - Delete - - - - - - )} - - - )} -
- {role.description && ( - {role.description} - )} -
- - - -
+
- ))} + )} + + {/* Create Role Form */} + {showCreateRole && ( + setShowCreateRole(false)} + /> + )} + + {/* Roles Grid */} +
+ {roles.map((role, index) => ( + + + {role.is_system_role && ( +
+ System Role +
+ )} + +
+
+
+ +
+
+ {role.name} + {role.is_default && ( + + Default + + )} +
+
+ {!role.is_system_role && ( + + + + + + {canUpdate && ( + { + // TODO: Implement edit role dialog/modal + }} + > + + Edit Role + + )} + {canDelete && ( + <> + + + + e.preventDefault()} + > + + Delete Role + + + + + Delete role? + + This will permanently delete the "{role.name}" role. Members + with this role will lose their permissions. + + + + Cancel + onDeleteRole(role.id)} + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + > + Delete + + + + + + )} + + + )} +
+ {role.description && ( + {role.description} + )} +
+ + + +
+
+ ))} +
); } @@ -1461,66 +1489,183 @@ function CreateInviteDialog({ ); } -// ============ Create Role Dialog ============ +// ============ Create Role Section ============ // Preset permission sets for quick role creation -// Editor: can create/read/update content, but cannot manage roles, remove members, or change settings -// Viewer: read-only access with ability to create comments -const PRESET_PERMISSIONS = { - editor: [ - "documents:create", - "documents:read", - "documents:update", - "chats:create", - "chats:read", - "chats:update", - "comments:create", - "comments:read", - "llm_configs:create", - "llm_configs:read", - "llm_configs:update", - "podcasts:create", - "podcasts:read", - "podcasts:update", - "connectors:create", - "connectors:read", - "connectors:update", - "logs:read", - "members:invite", - "members:view", - "roles:read", - "settings:view", - ], - viewer: [ - "documents:read", - "chats:read", - "comments:create", - "comments:read", - "llm_configs:read", - "podcasts:read", - "connectors:read", - "logs:read", - "members:view", - "roles:read", - "settings:view", - ], +const ROLE_PRESETS = { + editor: { + name: "Editor", + description: "Can create, read, and update content, but cannot delete or manage team settings", + permissions: [ + "documents:create", + "documents:read", + "documents:update", + "chats:create", + "chats:read", + "chats:update", + "comments:create", + "comments:read", + "llm_configs:create", + "llm_configs:read", + "llm_configs:update", + "podcasts:create", + "podcasts:read", + "podcasts:update", + "connectors:create", + "connectors:read", + "connectors:update", + "logs:read", + "members:invite", + "members:view", + "roles:read", + "settings:view", + ], + }, + viewer: { + name: "Viewer", + description: "Read-only access with ability to add comments", + permissions: [ + "documents:read", + "chats:read", + "comments:create", + "comments:read", + "llm_configs:read", + "podcasts:read", + "connectors:read", + "logs:read", + "members:view", + "roles:read", + "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 = { + 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, onCreateRole, - className, + onCancel, }: { - groupedPermissions: Record; + groupedPermissions: Record; onCreateRole: (data: CreateRoleRequest["data"]) => Promise; - className?: string; + onCancel: () => void; }) { - const [open, setOpen] = useState(false); const [creating, setCreating] = useState(false); const [name, setName] = useState(""); const [description, setDescription] = useState(""); const [selectedPermissions, setSelectedPermissions] = useState([]); const [isDefault, setIsDefault] = useState(false); + const [expandedCategories, setExpandedCategories] = useState([]); + + // 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 () => { if (!name.trim()) { @@ -1536,11 +1681,7 @@ function CreateRoleDialog({ permissions: selectedPermissions, is_default: isDefault, }); - setOpen(false); - setName(""); - setDescription(""); - setSelectedPermissions([]); - setIsDefault(false); + onCancel(); } catch (error) { console.error("Failed to create role:", error); } finally { @@ -1548,166 +1689,357 @@ function CreateRoleDialog({ } }; - const togglePermission = (perm: string) => { + const togglePermission = useCallback((perm: string) => { setSelectedPermissions((prev) => prev.includes(perm) ? prev.filter((p) => p !== perm) : [...prev, perm] ); - }; + }, []); - const toggleCategory = (category: string) => { - const categoryPerms = groupedPermissions[category]?.map((p) => p.value) || []; - const allSelected = categoryPerms.every((p) => selectedPermissions.includes(p)); + const toggleCategory = useCallback( + (category: string) => { + const categoryPerms = groupedPermissions[category]?.map((p) => p.value) || []; + const allSelected = categoryPerms.every((p) => selectedPermissions.includes(p)); - if (allSelected) { - setSelectedPermissions((prev) => prev.filter((p) => !categoryPerms.includes(p))); - } else { - setSelectedPermissions((prev) => [...new Set([...prev, ...categoryPerms])]); + if (allSelected) { + setSelectedPermissions((prev) => prev.filter((p) => !categoryPerms.includes(p))); + } else { + setSelectedPermissions((prev) => [...new Set([...prev, ...categoryPerms])]); + } + }, + [groupedPermissions, selectedPermissions] + ); + + const toggleCategoryExpanded = useCallback((category: string) => { + setExpandedCategories((prev) => + 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 applyPreset = (preset: "editor" | "viewer") => { - setSelectedPermissions(PRESET_PERMISSIONS[preset]); - toast.success(`Applied ${preset === "editor" ? "Editor" : "Viewer"} preset permissions`); - }; + 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 ( - - - - - - - Create Custom Role - - Define a new role with specific permissions for this search space. - - -
-
+ + + +
+
+
+ +
+
+ Create Custom Role + + Define permissions for a new role in this search space + +
+
+ +
+
+ + {/* Quick Start with Presets */} +
+ +
+ {Object.entries(ROLE_PRESETS).map(([key, preset]) => ( + + ))} +
+
+ + {/* Role Details */} +
setName(e.target.value)} />
-
+
+ + {/* Default Role Checkbox */} +
+ setIsDefault(checked === true)} + /> +
+

- New invites without a role will use this + New members without a specific role will be assigned this role

-
- -