Эх сурвалжийг харах

feat: enhanced user management with permissions and menu visibility

- Add GET /api/users/:id/memberships endpoint to fetch user's workspace memberships
- Enhance usePermissions hook with isSuperadmin, hasSystemPermission helpers
- Create PermissionSummary component for displaying permissions by category
- Create WorkspaceMembershipEditor for managing user workspace/group assignments
- Expand UserModal with tabbed interface for basic info and workspace access
- Add permission-based menu hiding in Sidebar
- Add put() method to API client
fszontagh 7 сар өмнө
parent
commit
2a73ebdbe0

+ 1 - 0
webserver/include/smartbotic/webserver/http_server.hpp

@@ -250,6 +250,7 @@ private:
     void HandleGetUser(const httplib::Request& req, httplib::Response& res);
     void HandleUpdateUser(const httplib::Request& req, httplib::Response& res);
     void HandleDeleteUser(const httplib::Request& req, httplib::Response& res);
+    void HandleGetUserMemberships(const httplib::Request& req, httplib::Response& res);
 
     // Workspace route handlers
     void HandleCreateWorkspace(const httplib::Request& req, httplib::Response& res);

+ 123 - 0
webserver/src/http_server.cpp

@@ -1229,6 +1229,12 @@ void HttpServer::SetupUserRoutes() {
         HandleListUsers(req, res);
     });
 
+    // GET /api/users/:id/memberships - Get a user's workspace memberships
+    // NOTE: This must be registered BEFORE the generic /api/users/:id route
+    httpServer_->Get(R"(/api/users/([a-zA-Z0-9\-]+)/memberships)", [this](const httplib::Request& req, httplib::Response& res) {
+        HandleGetUserMemberships(req, res);
+    });
+
     // GET /api/users/:id - Get a specific user
     httpServer_->Get(R"(/api/users/([a-zA-Z0-9\-]+))", [this](const httplib::Request& req, httplib::Response& res) {
         HandleGetUser(req, res);
@@ -1597,6 +1603,88 @@ void HttpServer::HandleDeleteUser(const httplib::Request& req, httplib::Response
     }
 }
 
+void HttpServer::HandleGetUserMemberships(const httplib::Request& req, httplib::Response& res) {
+    // Authenticate the request
+    auto auth_user = AuthenticateRequest(req);
+    if (!auth_user) {
+        res.status = 401;
+        res.set_content(R"({"error":"Unauthorized"})", "application/json");
+        return;
+    }
+
+    // Extract user ID from path
+    std::string user_id = req.matches[1].str();
+
+    // Users can read their own memberships, or need system:users:read permission for others
+    bool is_own_user = (auth_user->user_id == user_id);
+    bool has_permission = authorizationService_ && authorizationService_->HasPermission(*auth_user, permissions::kUsersRead);
+    if (!is_own_user && !has_permission) {
+        res.status = 403;
+        res.set_content(R"({"error":"Forbidden - requires system:users:read permission"})", "application/json");
+        return;
+    }
+
+    // Check if MembershipService is available
+    if (!membershipService_) {
+        res.status = 503;
+        res.set_content(R"({"error":"Membership service not available"})", "application/json");
+        return;
+    }
+
+    try {
+        auto result = membershipService_->ListUserMemberships(user_id);
+
+        if (!result.success) {
+            res.status = 500;
+            nlohmann::json error_response = {{"error", result.error}};
+            res.set_content(error_response.dump(), "application/json");
+            return;
+        }
+
+        // Build response with workspace and group names
+        nlohmann::json memberships_array = nlohmann::json::array();
+        for (const auto& membership : result.members) {
+            nlohmann::json membership_json = {
+                {"id", membership.id},
+                {"workspace_id", membership.workspace_id},
+                {"groups", membership.group_ids},
+                {"created_at", membership.created_at},
+                {"updated_at", membership.updated_at}
+            };
+
+            // Fetch workspace name
+            if (workspaceService_) {
+                auto ws_result = workspaceService_->GetWorkspace(membership.workspace_id);
+                if (ws_result.success && ws_result.workspace) {
+                    membership_json["workspace_name"] = ws_result.workspace->name;
+                }
+            }
+
+            // Fetch group names
+            nlohmann::json group_names = nlohmann::json::array();
+            if (groupService_) {
+                for (const auto& group_id : membership.group_ids) {
+                    auto group_result = groupService_->GetGroup(group_id);
+                    if (group_result.success && group_result.group) {
+                        group_names.push_back(group_result.group->name);
+                    }
+                }
+            }
+            membership_json["group_names"] = group_names;
+
+            memberships_array.push_back(membership_json);
+        }
+
+        nlohmann::json response = {{"memberships", memberships_array}};
+        res.set_content(response.dump(), "application/json");
+
+    } catch (const std::exception& e) {
+        spdlog::error("Get user memberships error: {}", e.what());
+        res.status = 500;
+        res.set_content(R"({"error":"Internal server error"})", "application/json");
+    }
+}
+
 // ============================================================================
 // Workspace Management Routes Implementation
 // ============================================================================
@@ -5075,11 +5163,34 @@ void HttpServer::HandleListPages(const httplib::Request& req, httplib::Response&
                 continue;  // Skip pages user cannot view
             }
 
+            // Build layout JSON with components
+            nlohmann::json layout_json = {
+                {"version", page.layout.version},
+                {"grid_columns", page.layout.grid_columns}
+            };
+            nlohmann::json components_json = nlohmann::json::array();
+            for (const auto& comp : page.layout.components) {
+                nlohmann::json comp_json = {
+                    {"id", comp.id},
+                    {"type", comp.type},
+                    {"position", {
+                        {"x", comp.position.x},
+                        {"y", comp.position.y},
+                        {"width", comp.position.width},
+                        {"height", comp.position.height}
+                    }},
+                    {"config", comp.config}
+                };
+                components_json.push_back(comp_json);
+            }
+            layout_json["components"] = components_json;
+
             nlohmann::json page_json = {
                 {"id", page.id},
                 {"workspace_id", page.workspace_id},
                 {"name", page.name},
                 {"slug", page.slug},
+                {"layout", layout_json},
                 {"created_at", page.created_at},
                 {"updated_at", page.updated_at},
                 {"created_by", page.created_by},
@@ -5858,6 +5969,12 @@ void HttpServer::HandleLlmCreateSession(const httplib::Request& req, httplib::Re
         response["created_at"] = session.created_at().seconds();
         response["updated_at"] = session.updated_at().seconds();
 
+        // Broadcast session creation event for real-time updates
+        if (!session.workspace_id().empty()) {
+            BroadcastDocumentEvent(session.workspace_id(), "__llm_sessions",
+                                   DocumentAction::Create, session.id(), response);
+        }
+
         res.set_content(response.dump(), "application/json");
 
     } catch (const std::exception& e) {
@@ -6090,6 +6207,12 @@ void HttpServer::HandleLlmDeleteSession(const httplib::Request& req, httplib::Re
             return;
         }
 
+        // Broadcast session deletion event for real-time updates
+        if (!session.workspace_id().empty()) {
+            BroadcastDocumentEvent(session.workspace_id(), "__llm_sessions",
+                                   DocumentAction::Delete, session_id, nlohmann::json::object());
+        }
+
         res.set_content(R"({"message":"Session deleted successfully"})", "application/json");
 
     } catch (const std::exception& e) {

+ 4 - 0
webui/src/api/client.ts

@@ -74,6 +74,10 @@ class ApiClient {
     return this.request<T>(endpoint, { method: 'PATCH', body, headers })
   }
 
+  put<T>(endpoint: string, body?: unknown, headers?: Record<string, string>): Promise<T> {
+    return this.request<T>(endpoint, { method: 'PUT', body, headers })
+  }
+
   delete<T>(endpoint: string, headers?: Record<string, string>): Promise<T> {
     return this.request<T>(endpoint, { method: 'DELETE', headers })
   }

+ 202 - 0
webui/src/components/PermissionSummary.tsx

@@ -0,0 +1,202 @@
+// Read-only component showing granted permissions by category
+
+import { useMemo } from 'react'
+import {
+  parsePermission,
+  type PermissionScope,
+} from '../types'
+
+interface PermissionSummaryProps {
+  permissions: string[]
+  compact?: boolean
+}
+
+interface PermissionsByCategory {
+  system: string[]
+  workspace: string[]
+  collection: string[]
+  field: string[]
+  page: string[]
+  other: string[]
+}
+
+const scopeLabels: Record<PermissionScope | 'other', string> = {
+  system: 'System',
+  workspace: 'Workspace',
+  collection: 'Collection',
+  field: 'Field',
+  page: 'Page',
+  other: 'Other',
+}
+
+const scopeColors: Record<PermissionScope | 'other', { bg: string; text: string; border: string }> = {
+  system: { bg: 'bg-purple-50', text: 'text-purple-800', border: 'border-purple-200' },
+  workspace: { bg: 'bg-blue-50', text: 'text-blue-800', border: 'border-blue-200' },
+  collection: { bg: 'bg-green-50', text: 'text-green-800', border: 'border-green-200' },
+  field: { bg: 'bg-yellow-50', text: 'text-yellow-800', border: 'border-yellow-200' },
+  page: { bg: 'bg-orange-50', text: 'text-orange-800', border: 'border-orange-200' },
+  other: { bg: 'bg-gray-50', text: 'text-gray-800', border: 'border-gray-200' },
+}
+
+const scopeIcons: Record<PermissionScope | 'other', React.ReactNode> = {
+  system: (
+    <svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+      <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
+      <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
+    </svg>
+  ),
+  workspace: (
+    <svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+      <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
+    </svg>
+  ),
+  collection: (
+    <svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+      <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
+    </svg>
+  ),
+  field: (
+    <svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+      <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
+    </svg>
+  ),
+  page: (
+    <svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+      <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
+    </svg>
+  ),
+  other: (
+    <svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+      <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
+    </svg>
+  ),
+}
+
+function getPermissionDescription(permission: string): string {
+  const parsed = parsePermission(permission)
+  if (!parsed) return permission
+
+  const { scope, resource, action } = parsed
+
+  switch (scope) {
+    case 'system':
+      return `${action.charAt(0).toUpperCase() + action.slice(1)} ${resource}`
+    case 'workspace':
+      return `${action.replace('_', ' ')} access`
+    case 'collection':
+      return `${action.replace('_', ' ')} on ${resource === '*' ? 'all collections' : resource}`
+    case 'page':
+      return `${action.replace('_', ' ')} pages`
+    case 'field':
+      return `${action} field ${parsed.qualifier || '*'} in ${resource}`
+    default:
+      return permission
+  }
+}
+
+export function PermissionSummary({ permissions, compact = false }: PermissionSummaryProps) {
+  const permissionsByCategory = useMemo((): PermissionsByCategory => {
+    const categories: PermissionsByCategory = {
+      system: [],
+      workspace: [],
+      collection: [],
+      field: [],
+      page: [],
+      other: [],
+    }
+
+    // Check for superadmin
+    if (permissions.includes('*')) {
+      return {
+        ...categories,
+        system: ['* (Full Access)'],
+      }
+    }
+
+    for (const permission of permissions) {
+      const parsed = parsePermission(permission)
+      if (parsed) {
+        const category = categories[parsed.scope]
+        if (category) {
+          category.push(permission)
+        } else {
+          categories.other.push(permission)
+        }
+      } else {
+        categories.other.push(permission)
+      }
+    }
+
+    return categories
+  }, [permissions])
+
+  const nonEmptyCategories = useMemo(() => {
+    return (Object.keys(permissionsByCategory) as Array<keyof PermissionsByCategory>).filter(
+      (key) => permissionsByCategory[key].length > 0
+    )
+  }, [permissionsByCategory])
+
+  if (permissions.length === 0) {
+    return (
+      <div className="text-sm text-gray-500 italic">
+        No permissions granted
+      </div>
+    )
+  }
+
+  if (compact) {
+    return (
+      <div className="flex flex-wrap gap-1">
+        {nonEmptyCategories.map((category) => {
+          const colors = scopeColors[category]
+          const count = permissionsByCategory[category].length
+          return (
+            <span
+              key={category}
+              className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${colors.bg} ${colors.text}`}
+              title={permissionsByCategory[category].join(', ')}
+            >
+              {scopeLabels[category]}: {count}
+            </span>
+          )
+        })}
+      </div>
+    )
+  }
+
+  return (
+    <div className="space-y-3">
+      {nonEmptyCategories.map((category) => {
+        const colors = scopeColors[category]
+        const perms = permissionsByCategory[category]
+
+        return (
+          <div key={category} className={`rounded-lg border ${colors.border} ${colors.bg} p-3`}>
+            <div className={`flex items-center gap-2 text-sm font-medium ${colors.text} mb-2`}>
+              {scopeIcons[category]}
+              <span>{scopeLabels[category]} Permissions</span>
+              <span className="ml-auto text-xs opacity-75">({perms.length})</span>
+            </div>
+            <div className="flex flex-wrap gap-1">
+              {perms.map((perm) => (
+                <span
+                  key={perm}
+                  className="inline-flex items-center px-2 py-0.5 rounded bg-white/50 text-xs"
+                  title={perm}
+                >
+                  {perm === '* (Full Access)' ? (
+                    <span className="font-semibold text-red-600">Full Access (Superadmin)</span>
+                  ) : (
+                    <span title={perm}>{getPermissionDescription(perm)}</span>
+                  )}
+                </span>
+              ))}
+            </div>
+          </div>
+        )
+      })}
+    </div>
+  )
+}
+
+export default PermissionSummary

+ 335 - 0
webui/src/components/WorkspaceMembershipEditor.tsx

@@ -0,0 +1,335 @@
+// Component for editing user's workspace memberships with group assignments
+
+import { useState, useEffect, useCallback, useMemo } from 'react'
+import apiClient from '@/api/client'
+import Button from '@/components/Button'
+import { GroupMultiSelect } from '@/components/GroupMultiSelect'
+import { PermissionSummary } from '@/components/PermissionSummary'
+import type { Workspace, Group } from '@/types'
+
+interface UserMembership {
+  id: string
+  workspace_id: string
+  workspace_name?: string
+  groups: string[]
+  group_names?: string[]
+}
+
+interface WorkspaceMembershipEditorProps {
+  memberships: UserMembership[]
+  onChange: (memberships: UserMembership[]) => void
+  disabled?: boolean
+}
+
+export function WorkspaceMembershipEditor({
+  memberships,
+  onChange,
+  disabled = false,
+}: WorkspaceMembershipEditorProps) {
+  const [availableWorkspaces, setAvailableWorkspaces] = useState<Workspace[]>([])
+  const [workspaceGroups, setWorkspaceGroups] = useState<Record<string, Group[]>>({})
+  const [isLoading, setIsLoading] = useState(true)
+  const [showAddWorkspace, setShowAddWorkspace] = useState(false)
+  const [selectedWorkspaceToAdd, setSelectedWorkspaceToAdd] = useState<string>('')
+  const [expandedWorkspace, setExpandedWorkspace] = useState<string | null>(null)
+
+  // Fetch available workspaces
+  useEffect(() => {
+    const fetchWorkspaces = async () => {
+      try {
+        const response = await apiClient.get<{ workspaces: Workspace[] }>('/workspaces')
+        setAvailableWorkspaces(response.workspaces || [])
+      } catch (error) {
+        console.error('Failed to fetch workspaces:', error)
+      } finally {
+        setIsLoading(false)
+      }
+    }
+    fetchWorkspaces()
+  }, [])
+
+  // Fetch groups for a workspace
+  const fetchGroupsForWorkspace = useCallback(async (workspaceId: string) => {
+    if (workspaceGroups[workspaceId]) return workspaceGroups[workspaceId]
+
+    try {
+      const response = await apiClient.get<{ groups: Group[] }>(`/workspaces/${workspaceId}/groups`)
+      const groups = response.groups || []
+      setWorkspaceGroups(prev => ({ ...prev, [workspaceId]: groups }))
+      return groups
+    } catch (error) {
+      console.error('Failed to fetch groups:', error)
+      return []
+    }
+  }, [workspaceGroups])
+
+  // Prefetch groups for all memberships
+  useEffect(() => {
+    memberships.forEach(m => {
+      if (!workspaceGroups[m.workspace_id]) {
+        fetchGroupsForWorkspace(m.workspace_id)
+      }
+    })
+  }, [memberships, fetchGroupsForWorkspace, workspaceGroups])
+
+  // Get workspaces not yet assigned
+  const unassignedWorkspaces = useMemo(() => {
+    const assignedIds = new Set(memberships.map(m => m.workspace_id))
+    return availableWorkspaces.filter(w => !assignedIds.has(w.id))
+  }, [availableWorkspaces, memberships])
+
+  // Get aggregated permissions for a membership
+  const getAggregatedPermissions = useCallback((membership: UserMembership): string[] => {
+    const groups = workspaceGroups[membership.workspace_id] || []
+    const permissions: string[] = []
+
+    for (const groupId of membership.groups) {
+      const group = groups.find(g => g.id === groupId)
+      if (group) {
+        permissions.push(...group.permissions)
+      }
+    }
+
+    return [...new Set(permissions)]
+  }, [workspaceGroups])
+
+  const handleAddWorkspace = useCallback(async () => {
+    if (!selectedWorkspaceToAdd) return
+
+    const workspace = availableWorkspaces.find(w => w.id === selectedWorkspaceToAdd)
+    if (!workspace) return
+
+    const newMembership: UserMembership = {
+      id: '', // Will be assigned by backend
+      workspace_id: workspace.id,
+      workspace_name: workspace.name,
+      groups: [],
+      group_names: [],
+    }
+
+    onChange([...memberships, newMembership])
+    setSelectedWorkspaceToAdd('')
+    setShowAddWorkspace(false)
+    setExpandedWorkspace(workspace.id)
+
+    // Prefetch groups
+    fetchGroupsForWorkspace(workspace.id)
+  }, [selectedWorkspaceToAdd, availableWorkspaces, memberships, onChange, fetchGroupsForWorkspace])
+
+  const handleRemoveWorkspace = useCallback((workspaceId: string) => {
+    onChange(memberships.filter(m => m.workspace_id !== workspaceId))
+    if (expandedWorkspace === workspaceId) {
+      setExpandedWorkspace(null)
+    }
+  }, [memberships, onChange, expandedWorkspace])
+
+  const handleGroupsChange = useCallback((workspaceId: string, groups: string[]) => {
+    const wsGroups = workspaceGroups[workspaceId] || []
+    const groupNames = groups
+      .map(gid => wsGroups.find(g => g.id === gid)?.name)
+      .filter(Boolean) as string[]
+
+    onChange(memberships.map(m =>
+      m.workspace_id === workspaceId
+        ? { ...m, groups, group_names: groupNames }
+        : m
+    ))
+  }, [memberships, onChange, workspaceGroups])
+
+  if (isLoading) {
+    return (
+      <div className="flex items-center justify-center py-4">
+        <svg
+          className="h-5 w-5 animate-spin text-primary-600"
+          xmlns="http://www.w3.org/2000/svg"
+          fill="none"
+          viewBox="0 0 24 24"
+        >
+          <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
+          <path
+            className="opacity-75"
+            fill="currentColor"
+            d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
+          />
+        </svg>
+      </div>
+    )
+  }
+
+  return (
+    <div className="space-y-4">
+      {/* Header */}
+      <div className="flex items-center justify-between">
+        <label className="block text-sm font-medium text-gray-700">
+          Workspace Access
+        </label>
+        {!disabled && unassignedWorkspaces.length > 0 && !showAddWorkspace && (
+          <Button
+            type="button"
+            variant="secondary"
+            size="sm"
+            onClick={() => setShowAddWorkspace(true)}
+          >
+            + Add Workspace
+          </Button>
+        )}
+      </div>
+
+      {/* Add Workspace Form */}
+      {showAddWorkspace && !disabled && (
+        <div className="rounded-lg border border-gray-200 bg-gray-50 p-4">
+          <div className="flex items-end gap-3">
+            <div className="flex-1">
+              <label className="block text-xs font-medium text-gray-500 mb-1">
+                Select Workspace
+              </label>
+              <select
+                value={selectedWorkspaceToAdd}
+                onChange={(e) => setSelectedWorkspaceToAdd(e.target.value)}
+                className="block w-full rounded-md border-gray-300 shadow-sm focus:border-primary-500 focus:ring-primary-500 text-sm"
+              >
+                <option value="">Choose a workspace...</option>
+                {unassignedWorkspaces.map(ws => (
+                  <option key={ws.id} value={ws.id}>{ws.name}</option>
+                ))}
+              </select>
+            </div>
+            <Button
+              type="button"
+              size="sm"
+              onClick={handleAddWorkspace}
+              disabled={!selectedWorkspaceToAdd}
+            >
+              Add
+            </Button>
+            <Button
+              type="button"
+              variant="secondary"
+              size="sm"
+              onClick={() => {
+                setShowAddWorkspace(false)
+                setSelectedWorkspaceToAdd('')
+              }}
+            >
+              Cancel
+            </Button>
+          </div>
+        </div>
+      )}
+
+      {/* Membership List */}
+      {memberships.length === 0 ? (
+        <div className="rounded-lg border border-gray-200 bg-gray-50 px-4 py-8 text-center">
+          <svg
+            className="mx-auto h-12 w-12 text-gray-400"
+            fill="none"
+            viewBox="0 0 24 24"
+            stroke="currentColor"
+          >
+            <path
+              strokeLinecap="round"
+              strokeLinejoin="round"
+              strokeWidth={1.5}
+              d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"
+            />
+          </svg>
+          <p className="mt-2 text-sm text-gray-500">No workspace access</p>
+          <p className="text-xs text-gray-400">Add workspaces to grant the user access</p>
+        </div>
+      ) : (
+        <div className="space-y-3">
+          {memberships.map((membership) => {
+            const workspace = availableWorkspaces.find(w => w.id === membership.workspace_id)
+            const workspaceName = membership.workspace_name || workspace?.name || membership.workspace_id
+            const isExpanded = expandedWorkspace === membership.workspace_id
+            const permissions = getAggregatedPermissions(membership)
+
+            return (
+              <div
+                key={membership.workspace_id}
+                className="rounded-lg border border-gray-200 bg-white overflow-hidden"
+              >
+                {/* Workspace Header */}
+                <div
+                  className="flex items-center justify-between px-4 py-3 bg-gray-50 cursor-pointer"
+                  onClick={() => setExpandedWorkspace(isExpanded ? null : membership.workspace_id)}
+                >
+                  <div className="flex items-center gap-3">
+                    <svg
+                      className={`h-4 w-4 text-gray-400 transition-transform ${isExpanded ? 'rotate-90' : ''}`}
+                      fill="none"
+                      viewBox="0 0 24 24"
+                      stroke="currentColor"
+                    >
+                      <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
+                    </svg>
+                    <div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary-100 text-primary-700">
+                      <svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+                        <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
+                      </svg>
+                    </div>
+                    <div>
+                      <div className="font-medium text-gray-900">{workspaceName}</div>
+                      <div className="text-xs text-gray-500">
+                        {membership.groups.length} group{membership.groups.length !== 1 ? 's' : ''}
+                        {membership.group_names && membership.group_names.length > 0 && (
+                          <span className="ml-1">
+                            ({membership.group_names.slice(0, 3).join(', ')}
+                            {membership.group_names.length > 3 && '...'})
+                          </span>
+                        )}
+                      </div>
+                    </div>
+                  </div>
+                  {!disabled && (
+                    <button
+                      type="button"
+                      onClick={(e) => {
+                        e.stopPropagation()
+                        handleRemoveWorkspace(membership.workspace_id)
+                      }}
+                      className="rounded p-1 text-gray-400 hover:bg-red-50 hover:text-red-600"
+                      title="Remove from workspace"
+                    >
+                      <svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+                        <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
+                      </svg>
+                    </button>
+                  )}
+                </div>
+
+                {/* Expanded Content */}
+                {isExpanded && (
+                  <div className="border-t border-gray-200 p-4 space-y-4">
+                    {/* Group Selection */}
+                    <div>
+                      <label className="block text-sm font-medium text-gray-700 mb-2">
+                        Groups
+                      </label>
+                      <GroupMultiSelect
+                        workspaceId={membership.workspace_id}
+                        selectedGroups={membership.groups}
+                        onChange={(groups) => handleGroupsChange(membership.workspace_id, groups)}
+                        disabled={disabled}
+                      />
+                    </div>
+
+                    {/* Permission Summary */}
+                    <div>
+                      <label className="block text-sm font-medium text-gray-700 mb-2">
+                        Effective Permissions
+                      </label>
+                      <PermissionSummary permissions={permissions} />
+                    </div>
+                  </div>
+                )}
+              </div>
+            )
+          })}
+        </div>
+      )}
+    </div>
+  )
+}
+
+export default WorkspaceMembershipEditor

+ 37 - 4
webui/src/components/layout/Sidebar.tsx

@@ -1,9 +1,10 @@
 // Sidebar navigation component
 
+import { useMemo } from 'react'
 import { NavLink } from 'react-router-dom'
 import { useWorkspace } from '@/contexts/WorkspaceContext'
-import { useAuth } from '@/contexts/AuthContext'
 import { useSidebarPages } from '@/hooks/useSidebarPages'
+import { usePermissions } from '@/hooks/usePermissions'
 import type { Workspace } from '@/types'
 
 interface SidebarProps {
@@ -15,6 +16,21 @@ interface NavItem {
   name: string
   path: string
   icon: React.ReactNode
+  requiredPermissions?: string[]
+  alwaysShow?: boolean
+}
+
+// Define required permissions for each menu item
+// Items without requiredPermissions are shown to everyone
+// Items with requiredPermissions require at least one of the listed permissions
+const menuPermissions: Record<string, string[]> = {
+  '/users': ['system:users:read'],
+  '/groups': ['system:groups:read'],
+  '/workspaces': ['system:workspaces:read'],
+  '/collections': ['system:collections:read'],
+  '/views': ['system:views:read'],
+  '/pages': ['system:pages:read', 'page:*:read_all', 'page:*:read_own'],
+  '/api-keys': ['system:api_keys:read'],
 }
 
 const navigation: NavItem[] = [
@@ -249,11 +265,28 @@ function WorkspaceSelector() {
 }
 
 function Sidebar({ isOpen, onClose }: SidebarProps) {
-  const { user } = useAuth()
   const { currentWorkspace } = useWorkspace()
   const { pages: sidebarPages, isLoading: pagesLoading } = useSidebarPages({
     workspaceId: currentWorkspace?.id,
   })
+  const { hasAnyPermission, isSuperadmin } = usePermissions({
+    workspaceId: currentWorkspace?.id,
+  })
+
+  // Filter navigation items based on user permissions
+  const filteredNavigation = useMemo(() => {
+    return navigation.filter((item) => {
+      // Superadmins see everything
+      if (isSuperadmin) return true
+
+      // Check if item has required permissions
+      const requiredPermissions = menuPermissions[item.path]
+      if (!requiredPermissions) return true // No restrictions
+
+      // Check if user has any of the required permissions
+      return hasAnyPermission(requiredPermissions)
+    })
+  }, [isSuperadmin, hasAnyPermission])
 
   return (
     <>
@@ -293,7 +326,7 @@ function Sidebar({ isOpen, onClose }: SidebarProps) {
         {/* Navigation */}
         <nav className="flex-1 overflow-y-auto px-3 py-4">
           <ul className="space-y-1">
-            {navigation.map((item) => (
+            {filteredNavigation.map((item) => (
               <li key={item.path}>
                 <NavLink
                   to={item.path}
@@ -312,7 +345,7 @@ function Sidebar({ isOpen, onClose }: SidebarProps) {
               </li>
             ))}
             {/* LLM Providers - only visible for superadmins */}
-            {user?.is_superadmin && (
+            {isSuperadmin && (
               <li>
                 <NavLink
                   to="/llm-providers"

+ 29 - 2
webui/src/hooks/usePermissions.ts

@@ -1,9 +1,9 @@
 // Hook to fetch and check user permissions for a workspace
 
-import { useState, useEffect, useCallback } from 'react'
+import { useState, useEffect, useCallback, useMemo } from 'react'
 import apiClient from '@/api/client'
 import { useAuth } from '@/contexts/AuthContext'
-import { matchesPermission, buildPagePermission, buildCollectionPermission } from '@/types'
+import { matchesPermission, buildPagePermission, buildCollectionPermission, buildSystemPermission, SystemResource, SystemAction } from '@/types'
 
 interface Group {
   id: string
@@ -27,8 +27,11 @@ interface UsePermissionsResult {
   isLoading: boolean
   error: string | null
   userId: string | null
+  isSuperadmin: boolean
   hasPermission: (permission: string) => boolean
   hasAnyPermission: (permissions: string[]) => boolean
+  hasSystemPermission: (resource: SystemResource, action: SystemAction) => boolean
+  hasAnySystemPermission: (permissions: Array<{ resource: SystemResource; action: SystemAction }>) => boolean
   canReadCollection: (collectionName: string) => boolean
   canWriteCollection: (collectionName: string) => boolean
   canCreateInCollection: (collectionName: string) => boolean
@@ -132,6 +135,27 @@ export function usePermissions({
     [hasPermission]
   )
 
+  // System-level permission helpers (work without workspace context)
+  const hasSystemPermission = useCallback(
+    (resource: SystemResource, action: SystemAction): boolean => {
+      const permission = buildSystemPermission(resource, action)
+      return hasPermission(permission)
+    },
+    [hasPermission]
+  )
+
+  const hasAnySystemPermission = useCallback(
+    (permissionList: Array<{ resource: SystemResource; action: SystemAction }>): boolean => {
+      return permissionList.some(({ resource, action }) => hasSystemPermission(resource, action))
+    },
+    [hasSystemPermission]
+  )
+
+  // Memoized superadmin check
+  const isSuperadmin = useMemo(() => {
+    return user?.is_superadmin === true || permissions.includes('*')
+  }, [user, permissions])
+
   // Collection permission helpers
   const canReadCollection = useCallback(
     (collectionName: string): boolean => {
@@ -256,8 +280,11 @@ export function usePermissions({
     isLoading,
     error,
     userId: user?.id ?? null,
+    isSuperadmin,
     hasPermission,
     hasAnyPermission,
+    hasSystemPermission,
+    hasAnySystemPermission,
     canReadCollection,
     canWriteCollection,
     canCreateInCollection,

+ 260 - 46
webui/src/pages/Users.tsx

@@ -4,6 +4,7 @@ import { useState, useEffect, useCallback } from 'react'
 import apiClient from '@/api/client'
 import Button from '@/components/Button'
 import Input from '@/components/Input'
+import { WorkspaceMembershipEditor } from '@/components/WorkspaceMembershipEditor'
 import type { User } from '@/types'
 import { formatDateTime } from '@/utils/format'
 
@@ -13,6 +14,14 @@ interface UserFormData {
   password: string
 }
 
+interface UserMembership {
+  id: string
+  workspace_id: string
+  workspace_name?: string
+  groups: string[]
+  group_names?: string[]
+}
+
 function UserModal({
   user,
   onClose,
@@ -27,8 +36,46 @@ function UserModal({
     name: user?.name || '',
     password: '',
   })
+  const [memberships, setMemberships] = useState<UserMembership[]>([])
+  const [originalMemberships, setOriginalMemberships] = useState<UserMembership[]>([])
   const [isLoading, setIsLoading] = useState(false)
+  const [isFetchingMemberships, setIsFetchingMemberships] = useState(false)
   const [error, setError] = useState<string | null>(null)
+  const [membershipError, setMembershipError] = useState<string | null>(null)
+  const [activeTab, setActiveTab] = useState<'basic' | 'access'>('basic')
+
+  // Fetch user memberships when editing
+  useEffect(() => {
+    if (!user) return
+
+    let cancelled = false
+    const fetchMemberships = async () => {
+      setIsFetchingMemberships(true)
+      setMembershipError(null)
+      try {
+        const response = await apiClient.get<{ memberships: UserMembership[] }>(
+          `/users/${user.id}/memberships`
+        )
+        if (cancelled) return
+        const fetchedMemberships = response.memberships || []
+        setMemberships(fetchedMemberships)
+        setOriginalMemberships(fetchedMemberships)
+      } catch (err) {
+        if (cancelled) return
+        console.error('Failed to fetch memberships:', err)
+        setMembershipError(err instanceof Error ? err.message : 'Failed to fetch memberships')
+      } finally {
+        if (!cancelled) {
+          setIsFetchingMemberships(false)
+        }
+      }
+    }
+    fetchMemberships()
+
+    return () => {
+      cancelled = true
+    }
+  }, [user?.id])
 
   const handleSubmit = async (e: React.FormEvent) => {
     e.preventDefault()
@@ -36,6 +83,8 @@ function UserModal({
     setError(null)
 
     try {
+      let userId = user?.id
+
       if (user) {
         // Update user - only send non-empty password
         const updateData: Partial<UserFormData> = {
@@ -47,8 +96,15 @@ function UserModal({
         await apiClient.patch(`/users/${user.id}`, updateData)
       } else {
         // Create user
-        await apiClient.post('/users', formData)
+        const createResponse = await apiClient.post<{ id: string }>('/users', formData)
+        userId = createResponse.id
+      }
+
+      // Save membership changes
+      if (userId) {
+        await saveMembershipChanges(userId)
       }
+
       onSave()
     } catch (err) {
       setError(err instanceof Error ? err.message : 'Failed to save user')
@@ -57,50 +113,191 @@ function UserModal({
     }
   }
 
+  const saveMembershipChanges = async (userId: string) => {
+    const originalMap = new Map(originalMemberships.map((m) => [m.workspace_id, m]))
+    const currentMap = new Map(memberships.map((m) => [m.workspace_id, m]))
+
+    // Find added workspaces
+    const added = memberships.filter((m) => !originalMap.has(m.workspace_id))
+
+    // Find removed workspaces
+    const removed = originalMemberships.filter((m) => !currentMap.has(m.workspace_id))
+
+    // Find modified workspaces (group changes)
+    const modified = memberships.filter((m) => {
+      const original = originalMap.get(m.workspace_id)
+      if (!original) return false
+      const originalGroups = new Set(original.groups)
+      const currentGroups = new Set(m.groups)
+      if (originalGroups.size !== currentGroups.size) return true
+      for (const g of originalGroups) {
+        if (!currentGroups.has(g)) return true
+      }
+      return false
+    })
+
+    // Add new memberships
+    for (const membership of added) {
+      await apiClient.post(`/workspaces/${membership.workspace_id}/members`, {
+        user_id: userId,
+        group_ids: membership.groups,
+      })
+    }
+
+    // Remove memberships
+    for (const membership of removed) {
+      await apiClient.delete(`/workspaces/${membership.workspace_id}/members/${userId}`)
+    }
+
+    // Update modified memberships
+    for (const membership of modified) {
+      await apiClient.put(`/workspaces/${membership.workspace_id}/members/${userId}`, {
+        group_ids: membership.groups,
+      })
+    }
+  }
+
   return (
-    <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
-      <div className="w-full max-w-md rounded-lg bg-white p-6 shadow-xl">
-        <h2 className="text-xl font-semibold text-gray-900">
-          {user ? 'Edit User' : 'Create User'}
-        </h2>
+    <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
+      <div className="flex max-h-[90vh] w-full max-w-2xl flex-col rounded-lg bg-white shadow-xl">
+        {/* Header */}
+        <div className="flex items-center justify-between border-b px-6 py-4">
+          <h2 className="text-xl font-semibold text-gray-900">
+            {user ? 'Edit User' : 'Create User'}
+          </h2>
+          <button
+            type="button"
+            onClick={onClose}
+            className="rounded-lg p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-500"
+          >
+            <svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+              <path
+                strokeLinecap="round"
+                strokeLinejoin="round"
+                strokeWidth={2}
+                d="M6 18L18 6M6 6l12 12"
+              />
+            </svg>
+          </button>
+        </div>
 
-        <form onSubmit={handleSubmit} className="mt-4 space-y-4">
-          {error && (
-            <div className="rounded-lg bg-red-50 px-4 py-3 text-sm text-red-700">{error}</div>
-          )}
+        {/* Tabs */}
+        <div className="border-b px-6">
+          <nav className="-mb-px flex space-x-8">
+            <button
+              type="button"
+              onClick={() => setActiveTab('basic')}
+              className={`whitespace-nowrap border-b-2 py-4 px-1 text-sm font-medium ${
+                activeTab === 'basic'
+                  ? 'border-primary-500 text-primary-600'
+                  : 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700'
+              }`}
+            >
+              Basic Info
+            </button>
+            <button
+              type="button"
+              onClick={() => setActiveTab('access')}
+              className={`whitespace-nowrap border-b-2 py-4 px-1 text-sm font-medium ${
+                activeTab === 'access'
+                  ? 'border-primary-500 text-primary-600'
+                  : 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700'
+              }`}
+            >
+              Workspace Access
+              {memberships.length > 0 && (
+                <span className="ml-2 rounded-full bg-gray-100 px-2 py-0.5 text-xs text-gray-600">
+                  {memberships.length}
+                </span>
+              )}
+            </button>
+          </nav>
+        </div>
+
+        {/* Content */}
+        <form onSubmit={handleSubmit} className="flex flex-1 flex-col overflow-hidden">
+          <div className="flex-1 overflow-y-auto px-6 py-4">
+            {error && (
+              <div className="mb-4 rounded-lg bg-red-50 px-4 py-3 text-sm text-red-700">{error}</div>
+            )}
+
+            {activeTab === 'basic' && (
+              <div className="space-y-4">
+                <Input
+                  label="Email"
+                  type="email"
+                  name="email"
+                  value={formData.email}
+                  onChange={(e) => setFormData({ ...formData, email: e.target.value })}
+                  placeholder="user@example.com"
+                  required
+                  disabled={!!user}
+                  autoFocus={!user}
+                />
+
+                <Input
+                  label="Name"
+                  name="name"
+                  value={formData.name}
+                  onChange={(e) => setFormData({ ...formData, name: e.target.value })}
+                  placeholder="Full name"
+                  autoFocus={!!user}
+                />
 
-          <Input
-            label="Email"
-            type="email"
-            name="email"
-            value={formData.email}
-            onChange={(e) => setFormData({ ...formData, email: e.target.value })}
-            placeholder="user@example.com"
-            required
-            disabled={!!user}
-            autoFocus={!user}
-          />
-
-          <Input
-            label="Name"
-            name="name"
-            value={formData.name}
-            onChange={(e) => setFormData({ ...formData, name: e.target.value })}
-            placeholder="Full name"
-            autoFocus={!!user}
-          />
-
-          <Input
-            label={user ? 'New Password (leave empty to keep current)' : 'Password'}
-            type="password"
-            name="password"
-            value={formData.password}
-            onChange={(e) => setFormData({ ...formData, password: e.target.value })}
-            placeholder={user ? 'Leave empty to keep current password' : 'Enter password'}
-            required={!user}
-          />
-
-          <div className="flex justify-end gap-3 pt-4">
+                <Input
+                  label={user ? 'New Password (leave empty to keep current)' : 'Password'}
+                  type="password"
+                  name="password"
+                  value={formData.password}
+                  onChange={(e) => setFormData({ ...formData, password: e.target.value })}
+                  placeholder={user ? 'Leave empty to keep current password' : 'Enter password'}
+                  required={!user}
+                />
+              </div>
+            )}
+
+            {activeTab === 'access' && (
+              <div>
+                {membershipError && (
+                  <div className="mb-4 rounded-lg bg-red-50 px-4 py-3 text-sm text-red-700">
+                    {membershipError}
+                  </div>
+                )}
+                {isFetchingMemberships ? (
+                  <div className="flex items-center justify-center py-8">
+                    <svg
+                      className="h-6 w-6 animate-spin text-primary-600"
+                      fill="none"
+                      viewBox="0 0 24 24"
+                    >
+                      <circle
+                        className="opacity-25"
+                        cx="12"
+                        cy="12"
+                        r="10"
+                        stroke="currentColor"
+                        strokeWidth="4"
+                      />
+                      <path
+                        className="opacity-75"
+                        fill="currentColor"
+                        d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
+                      />
+                    </svg>
+                  </div>
+                ) : (
+                  <WorkspaceMembershipEditor
+                    memberships={memberships}
+                    onChange={setMemberships}
+                    disabled={isLoading}
+                  />
+                )}
+              </div>
+            )}
+          </div>
+
+          {/* Footer */}
+          <div className="flex justify-end gap-3 border-t px-6 py-4">
             <Button type="button" variant="secondary" onClick={onClose} disabled={isLoading}>
               Cancel
             </Button>
@@ -174,6 +371,7 @@ function Users() {
   const [showCreateModal, setShowCreateModal] = useState(false)
   const [editingUser, setEditingUser] = useState<User | null>(null)
   const [deletingUser, setDeletingUser] = useState<User | null>(null)
+  const [modalKey, setModalKey] = useState(0)
 
   const fetchUsers = useCallback(async () => {
     setIsLoading(true)
@@ -223,7 +421,10 @@ function Users() {
           <h1 className="text-2xl font-bold text-gray-900">Users</h1>
           <p className="mt-1 text-gray-600">Manage all users in the system</p>
         </div>
-        <Button onClick={() => setShowCreateModal(true)}>
+        <Button onClick={() => {
+          setShowCreateModal(true)
+          setModalKey((k) => k + 1)
+        }}>
           <svg className="-ml-1 mr-2 h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
             <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
           </svg>
@@ -334,7 +535,10 @@ function Users() {
                     <td className="whitespace-nowrap px-6 py-4 text-right">
                       <div className="flex justify-end gap-2">
                         <button
-                          onClick={() => setEditingUser(user)}
+                          onClick={() => {
+                            setEditingUser(user)
+                            setModalKey((k) => k + 1)
+                          }}
                           className="rounded px-3 py-1 text-sm text-primary-600 hover:bg-primary-50"
                         >
                           Edit
@@ -357,10 +561,20 @@ function Users() {
 
       {/* Modals */}
       {showCreateModal && (
-        <UserModal user={null} onClose={() => setShowCreateModal(false)} onSave={handleSave} />
+        <UserModal
+          key={`create-${modalKey}`}
+          user={null}
+          onClose={() => setShowCreateModal(false)}
+          onSave={handleSave}
+        />
       )}
       {editingUser && (
-        <UserModal user={editingUser} onClose={() => setEditingUser(null)} onSave={handleSave} />
+        <UserModal
+          key={`edit-${editingUser.id}-${modalKey}`}
+          user={editingUser}
+          onClose={() => setEditingUser(null)}
+          onSave={handleSave}
+        />
       )}
       {deletingUser && (
         <DeleteConfirmModal