Forráskód Böngészése

feat: add RBAC permissions editor to View modal

Add ViewPermissionsEditor component that allows managing collection
and field permissions directly from the View edit/create page. Uses
the existing RBAC system (collection:workspace:collection_name:action
and field:workspace:collection_name:field_name:action permissions).

Features:
- Lists all groups with expandable permission sections
- Collection permissions: read_all, read_own, write_all, write_own, delete_all, delete_own
- Field permissions: read, write for each field in the view schema
- Per-group save functionality
Fszontagh 6 hónapja
szülő
commit
8f017cf90a
2 módosított fájl, 408 hozzáadás és 0 törlés
  1. 395 0
      webui/src/components/ViewPermissionsEditor.tsx
  2. 13 0
      webui/src/pages/Views.tsx

+ 395 - 0
webui/src/components/ViewPermissionsEditor.tsx

@@ -0,0 +1,395 @@
+// View Permissions Editor - Manages collection and field RBAC permissions
+// Uses the existing permission system (collection:workspace:collection_name:action)
+
+import { useState, useEffect, useCallback } from 'react'
+import apiClient from '@/api/client'
+import type { Group } from '@/types'
+
+interface ViewPermissionsEditorProps {
+  workspaceId: string
+  collectionName: string
+  fields: string[]
+  disabled?: boolean
+}
+
+interface GroupPermissions {
+  group: Group
+  collectionPermissions: string[]
+  fieldPermissions: Record<string, string[]>
+}
+
+const COLLECTION_ACTIONS = [
+  { action: 'read_all', label: 'Read All', description: 'View all records' },
+  { action: 'read_own', label: 'Read Own', description: 'View own records only' },
+  { action: 'write_all', label: 'Write All', description: 'Create/edit all records' },
+  { action: 'write_own', label: 'Write Own', description: 'Create/edit own records only' },
+  { action: 'delete_all', label: 'Delete All', description: 'Delete any record' },
+  { action: 'delete_own', label: 'Delete Own', description: 'Delete own records only' },
+]
+
+const FIELD_ACTIONS = [
+  { action: 'read', label: 'Read', description: 'View this field' },
+  { action: 'write', label: 'Write', description: 'Edit this field' },
+]
+
+export function ViewPermissionsEditor({
+  workspaceId,
+  collectionName,
+  fields,
+  disabled = false,
+}: ViewPermissionsEditorProps) {
+  const [groupPermissions, setGroupPermissions] = useState<GroupPermissions[]>([])
+  const [isLoading, setIsLoading] = useState(true)
+  const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set())
+  const [savingGroup, setSavingGroup] = useState<string | null>(null)
+
+  // Fetch groups and their permissions
+  const fetchData = useCallback(async () => {
+    if (!workspaceId || !collectionName) {
+      setIsLoading(false)
+      return
+    }
+
+    setIsLoading(true)
+    try {
+      // Fetch groups
+      const groupsResponse = await apiClient.get<{ groups: Group[] }>(
+        `/workspaces/${workspaceId}/groups?include_system=true`
+      )
+      const fetchedGroups = groupsResponse.groups || []
+
+      // Build permissions for each group
+      const permissions: GroupPermissions[] = fetchedGroups.map((group) => {
+        const collectionPerms: string[] = []
+        const fieldPerms: Record<string, string[]> = {}
+
+        // Parse collection permissions from group.permissions
+        const collectionPrefix = `collection:${workspaceId}:${collectionName}:`
+        const fieldPrefix = `field:${workspaceId}:${collectionName}:`
+
+        for (const perm of group.permissions || []) {
+          if (perm.startsWith(collectionPrefix)) {
+            const action = perm.substring(collectionPrefix.length)
+            collectionPerms.push(action)
+          } else if (perm.startsWith(fieldPrefix)) {
+            const rest = perm.substring(fieldPrefix.length)
+            const parts = rest.split(':')
+            if (parts.length === 2) {
+              const [fieldName, action] = parts
+              if (!fieldPerms[fieldName]) {
+                fieldPerms[fieldName] = []
+              }
+              fieldPerms[fieldName].push(action)
+            }
+          }
+        }
+
+        return {
+          group,
+          collectionPermissions: collectionPerms,
+          fieldPermissions: fieldPerms,
+        }
+      })
+
+      setGroupPermissions(permissions)
+    } catch (err) {
+      console.error('Failed to fetch permissions:', err)
+    } finally {
+      setIsLoading(false)
+    }
+  }, [workspaceId, collectionName])
+
+  useEffect(() => {
+    fetchData()
+  }, [fetchData])
+
+  const toggleGroupExpanded = (groupId: string) => {
+    setExpandedGroups((prev) => {
+      const next = new Set(prev)
+      if (next.has(groupId)) {
+        next.delete(groupId)
+      } else {
+        next.add(groupId)
+      }
+      return next
+    })
+  }
+
+  const toggleCollectionPermission = (groupIndex: number, action: string) => {
+    setGroupPermissions((prev) => {
+      const next = [...prev]
+      const perms = [...next[groupIndex].collectionPermissions]
+      const idx = perms.indexOf(action)
+      if (idx >= 0) {
+        perms.splice(idx, 1)
+      } else {
+        perms.push(action)
+      }
+      next[groupIndex] = { ...next[groupIndex], collectionPermissions: perms }
+      return next
+    })
+  }
+
+  const toggleFieldPermission = (groupIndex: number, fieldName: string, action: string) => {
+    setGroupPermissions((prev) => {
+      const next = [...prev]
+      const fieldPerms = { ...next[groupIndex].fieldPermissions }
+      if (!fieldPerms[fieldName]) {
+        fieldPerms[fieldName] = []
+      }
+      const perms = [...fieldPerms[fieldName]]
+      const idx = perms.indexOf(action)
+      if (idx >= 0) {
+        perms.splice(idx, 1)
+      } else {
+        perms.push(action)
+      }
+      fieldPerms[fieldName] = perms
+      next[groupIndex] = { ...next[groupIndex], fieldPermissions: fieldPerms }
+      return next
+    })
+  }
+
+  const saveGroupPermissions = async (groupIndex: number) => {
+    const gp = groupPermissions[groupIndex]
+    setSavingGroup(gp.group.id)
+
+    try {
+      // Build the new permissions array
+      const collectionPrefix = `collection:${workspaceId}:${collectionName}:`
+      const fieldPrefix = `field:${workspaceId}:${collectionName}:`
+
+      // Start with existing permissions, removing old collection/field permissions for this collection
+      const existingPerms = (gp.group.permissions || []).filter(
+        (p) => !p.startsWith(collectionPrefix) && !p.startsWith(fieldPrefix)
+      )
+
+      // Add new collection permissions
+      const newCollectionPerms = gp.collectionPermissions.map((action) => `${collectionPrefix}${action}`)
+
+      // Add new field permissions
+      const newFieldPerms: string[] = []
+      for (const [fieldName, actions] of Object.entries(gp.fieldPermissions)) {
+        for (const action of actions) {
+          newFieldPerms.push(`${fieldPrefix}${fieldName}:${action}`)
+        }
+      }
+
+      const allPerms = [...existingPerms, ...newCollectionPerms, ...newFieldPerms]
+
+      // Update the group
+      await apiClient.request(`/workspaces/${workspaceId}/groups/${gp.group.id}`, {
+        method: 'PATCH',
+        body: { permissions: allPerms },
+      })
+
+      // Update local state with new group data
+      setGroupPermissions((prev) => {
+        const next = [...prev]
+        next[groupIndex] = {
+          ...next[groupIndex],
+          group: { ...next[groupIndex].group, permissions: allPerms },
+        }
+        return next
+      })
+    } catch (err) {
+      console.error('Failed to save permissions:', err)
+    } finally {
+      setSavingGroup(null)
+    }
+  }
+
+  if (isLoading) {
+    return (
+      <div className="flex items-center justify-center py-8 text-gray-500">
+        <svg className="mr-2 h-5 w-5 animate-spin" viewBox="0 0 24 24">
+          <circle
+            className="opacity-25"
+            cx="12"
+            cy="12"
+            r="10"
+            stroke="currentColor"
+            strokeWidth="4"
+            fill="none"
+          />
+          <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>
+        Loading permissions...
+      </div>
+    )
+  }
+
+  if (!collectionName) {
+    return (
+      <div className="py-4 text-center text-gray-500">
+        Select a collection to manage permissions
+      </div>
+    )
+  }
+
+  return (
+    <div className="space-y-3">
+      <p className="text-sm text-gray-600">
+        Manage which groups can access the <strong>{collectionName}</strong> collection and its fields.
+      </p>
+
+      {groupPermissions.length === 0 ? (
+        <div className="py-4 text-center text-gray-500">No groups found</div>
+      ) : (
+        <div className="divide-y rounded-lg border">
+          {groupPermissions.map((gp, groupIndex) => (
+            <div key={gp.group.id} className="bg-white">
+              {/* Group header */}
+              <button
+                type="button"
+                onClick={() => toggleGroupExpanded(gp.group.id)}
+                className="flex w-full items-center justify-between px-4 py-3 text-left hover:bg-gray-50"
+              >
+                <div className="flex items-center gap-2">
+                  <svg
+                    className={`h-4 w-4 text-gray-500 transition-transform ${
+                      expandedGroups.has(gp.group.id) ? '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>
+                  <span className="font-medium text-gray-900">{gp.group.name}</span>
+                  {gp.group.is_system && (
+                    <span className="rounded bg-purple-100 px-1.5 py-0.5 text-xs text-purple-700">
+                      System
+                    </span>
+                  )}
+                </div>
+                <span className="text-sm text-gray-500">
+                  {gp.collectionPermissions.length} collection,{' '}
+                  {Object.values(gp.fieldPermissions).flat().length} field permissions
+                </span>
+              </button>
+
+              {/* Expanded content */}
+              {expandedGroups.has(gp.group.id) && (
+                <div className="border-t bg-gray-50 px-4 py-3">
+                  {/* Collection permissions */}
+                  <div className="mb-4">
+                    <h4 className="mb-2 text-sm font-medium text-gray-700">Collection Permissions</h4>
+                    <div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
+                      {COLLECTION_ACTIONS.map((ca) => (
+                        <label
+                          key={ca.action}
+                          className="flex cursor-pointer items-center gap-2 rounded border bg-white px-3 py-2 hover:bg-gray-50"
+                        >
+                          <input
+                            type="checkbox"
+                            checked={gp.collectionPermissions.includes(ca.action)}
+                            onChange={() => toggleCollectionPermission(groupIndex, ca.action)}
+                            disabled={disabled || savingGroup === gp.group.id}
+                            className="h-4 w-4 rounded border-gray-300 text-indigo-600"
+                          />
+                          <div>
+                            <div className="text-sm font-medium text-gray-900">{ca.label}</div>
+                            <div className="text-xs text-gray-500">{ca.description}</div>
+                          </div>
+                        </label>
+                      ))}
+                    </div>
+                  </div>
+
+                  {/* Field permissions */}
+                  {fields.length > 0 && (
+                    <div className="mb-4">
+                      <h4 className="mb-2 text-sm font-medium text-gray-700">Field Permissions</h4>
+                      <div className="overflow-x-auto">
+                        <table className="min-w-full divide-y divide-gray-200 rounded border bg-white">
+                          <thead className="bg-gray-50">
+                            <tr>
+                              <th className="px-3 py-2 text-left text-xs font-medium uppercase text-gray-500">
+                                Field
+                              </th>
+                              {FIELD_ACTIONS.map((fa) => (
+                                <th
+                                  key={fa.action}
+                                  className="px-3 py-2 text-center text-xs font-medium uppercase text-gray-500"
+                                >
+                                  {fa.label}
+                                </th>
+                              ))}
+                            </tr>
+                          </thead>
+                          <tbody className="divide-y divide-gray-200">
+                            {fields.map((fieldName) => (
+                              <tr key={fieldName} className="hover:bg-gray-50">
+                                <td className="whitespace-nowrap px-3 py-2 font-mono text-sm text-gray-900">
+                                  {fieldName}
+                                </td>
+                                {FIELD_ACTIONS.map((fa) => (
+                                  <td key={fa.action} className="px-3 py-2 text-center">
+                                    <input
+                                      type="checkbox"
+                                      checked={(gp.fieldPermissions[fieldName] || []).includes(fa.action)}
+                                      onChange={() =>
+                                        toggleFieldPermission(groupIndex, fieldName, fa.action)
+                                      }
+                                      disabled={disabled || savingGroup === gp.group.id}
+                                      className="h-4 w-4 rounded border-gray-300 text-indigo-600"
+                                    />
+                                  </td>
+                                ))}
+                              </tr>
+                            ))}
+                          </tbody>
+                        </table>
+                      </div>
+                    </div>
+                  )}
+
+                  {/* Save button */}
+                  <div className="flex justify-end">
+                    <button
+                      type="button"
+                      onClick={() => saveGroupPermissions(groupIndex)}
+                      disabled={disabled || savingGroup === gp.group.id}
+                      className="inline-flex items-center rounded-md bg-indigo-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-indigo-700 disabled:opacity-50"
+                    >
+                      {savingGroup === gp.group.id ? (
+                        <>
+                          <svg className="mr-1.5 h-4 w-4 animate-spin" viewBox="0 0 24 24">
+                            <circle
+                              className="opacity-25"
+                              cx="12"
+                              cy="12"
+                              r="10"
+                              stroke="currentColor"
+                              strokeWidth="4"
+                              fill="none"
+                            />
+                            <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>
+                          Saving...
+                        </>
+                      ) : (
+                        'Save Permissions'
+                      )}
+                    </button>
+                  </div>
+                </div>
+              )}
+            </div>
+          ))}
+        </div>
+      )}
+    </div>
+  )
+}
+
+export default ViewPermissionsEditor

+ 13 - 0
webui/src/pages/Views.tsx

@@ -7,6 +7,7 @@ import apiClient from '@/api/client'
 import { useWorkspace } from '@/contexts/WorkspaceContext'
 import Button from '@/components/Button'
 import Input from '@/components/Input'
+import ViewPermissionsEditor from '@/components/ViewPermissionsEditor'
 
 // Field type definitions matching backend
 const FIELD_TYPES = [
@@ -609,6 +610,18 @@ function ViewModal({
                 </div>
               )}
             </div>
+
+            {/* Permissions Section - only show when editing or when collection is selected */}
+            {collectionName && (
+              <div className="mt-6 border-t pt-6">
+                <h3 className="mb-4 text-lg font-medium text-gray-900">Permissions</h3>
+                <ViewPermissionsEditor
+                  workspaceId={workspaceId}
+                  collectionName={collectionName}
+                  fields={sortedFields.map((f) => f.name)}
+                />
+              </div>
+            )}
           </div>
 
           <div className="sticky bottom-0 flex justify-end gap-3 border-t bg-gray-50 px-6 py-4">