|
@@ -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
|