| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596 |
- // Collections management page (US-032) - Updated for RBAC field permissions
- import { useState, useEffect, useCallback } from 'react'
- import { useNavigate } from 'react-router-dom'
- import apiClient from '@/api/client'
- import { useWorkspace } from '@/contexts/WorkspaceContext'
- import { useAuth } from '@/contexts/AuthContext'
- import Button from '@/components/Button'
- import Input from '@/components/Input'
- import FieldPermissionEditor from '@/components/FieldPermissionEditor'
- import type { Collection, Group, FieldPermission } from '@/types'
- interface CollectionFormData {
- name: string
- schema: string
- field_permissions: FieldPermission[]
- }
- function CollectionModal({
- collection,
- workspaceId,
- availableGroups,
- onClose,
- onSave,
- }: {
- collection: Collection | null
- workspaceId: string
- availableGroups: Group[]
- onClose: () => void
- onSave: () => void
- }) {
- const [formData, setFormData] = useState<CollectionFormData>({
- name: collection?.name || '',
- schema: collection?.settings?.schema
- ? typeof collection.settings.schema === 'string'
- ? collection.settings.schema
- : JSON.stringify(collection.settings.schema, null, 2)
- : '{}',
- field_permissions: collection?.settings?.field_permissions || [],
- })
- const [isLoading, setIsLoading] = useState(false)
- const [error, setError] = useState<string | null>(null)
- const [schemaError, setSchemaError] = useState<string | null>(null)
- const [activeTab, setActiveTab] = useState<'schema' | 'permissions'>('schema')
- // Extract field names from schema for the field permission editor
- const schemaFields = (() => {
- try {
- const parsed = JSON.parse(formData.schema)
- if (parsed.properties && typeof parsed.properties === 'object') {
- return Object.keys(parsed.properties)
- }
- return []
- } catch {
- return []
- }
- })()
- const validateSchema = (schemaStr: string): boolean => {
- try {
- JSON.parse(schemaStr)
- setSchemaError(null)
- return true
- } catch {
- setSchemaError('Invalid JSON schema')
- return false
- }
- }
- const handleSchemaChange = (value: string) => {
- setFormData({ ...formData, schema: value })
- validateSchema(value)
- }
- const handleSubmit = async (e: React.FormEvent) => {
- e.preventDefault()
- if (!validateSchema(formData.schema)) {
- return
- }
- setIsLoading(true)
- setError(null)
- try {
- const payload = {
- name: formData.name,
- settings: {
- schema: JSON.parse(formData.schema),
- field_permissions: formData.field_permissions,
- },
- }
- if (collection) {
- await apiClient.request(`/workspaces/${workspaceId}/collections/${collection.name}`, {
- method: 'PUT',
- body: payload,
- })
- } else {
- await apiClient.post(`/workspaces/${workspaceId}/collections`, payload)
- }
- onSave()
- } catch (err) {
- setError(err instanceof Error ? err.message : 'Failed to save collection')
- } finally {
- setIsLoading(false)
- }
- }
- return (
- <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
- <div className="w-full max-w-3xl max-h-[90vh] overflow-y-auto rounded-lg bg-white p-6 shadow-xl">
- <h2 className="text-xl font-semibold text-gray-900">
- {collection ? 'Edit Collection' : 'Create Collection'}
- </h2>
- <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>
- )}
- <Input
- label="Collection Name"
- name="name"
- value={formData.name}
- onChange={(e) => setFormData({ ...formData, name: e.target.value })}
- placeholder="my_collection"
- required
- disabled={!!collection}
- autoFocus={!collection}
- />
- {/* Tabs */}
- <div className="border-b border-gray-200">
- <nav className="-mb-px flex space-x-8">
- <button
- type="button"
- onClick={() => setActiveTab('schema')}
- className={`py-2 px-1 border-b-2 font-medium text-sm ${
- activeTab === 'schema'
- ? 'border-indigo-500 text-indigo-600'
- : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
- }`}
- >
- Schema
- </button>
- <button
- type="button"
- onClick={() => setActiveTab('permissions')}
- className={`py-2 px-1 border-b-2 font-medium text-sm ${
- activeTab === 'permissions'
- ? 'border-indigo-500 text-indigo-600'
- : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
- }`}
- >
- Field Permissions
- </button>
- </nav>
- </div>
- {/* Tab Content */}
- {activeTab === 'schema' && (
- <div>
- <label className="mb-1.5 block text-sm font-medium text-gray-700">
- Schema (JSON)
- </label>
- <textarea
- value={formData.schema}
- onChange={(e) => handleSchemaChange(e.target.value)}
- placeholder='{ "type": "object", "properties": { ... } }'
- rows={12}
- className={`w-full rounded-lg border px-3 py-2 font-mono text-sm transition focus:outline-none focus:ring-2 ${
- schemaError
- ? 'border-red-500 focus:border-red-500 focus:ring-red-200'
- : 'border-gray-300 focus:border-primary-500 focus:ring-primary-200'
- }`}
- />
- {schemaError && <p className="mt-1 text-sm text-red-600">{schemaError}</p>}
- <p className="mt-1 text-xs text-gray-500">
- Define the JSON Schema for document validation. Fields defined in the schema can be
- used for field-level permissions.
- </p>
- </div>
- )}
- {activeTab === 'permissions' && (
- <div className="space-y-4">
- <p className="text-sm text-gray-600">
- Configure which groups can read or write specific fields. Leave empty to allow all
- groups access.
- </p>
- <FieldPermissionEditor
- fieldPermissions={formData.field_permissions}
- onChange={(field_permissions) =>
- setFormData({ ...formData, field_permissions })
- }
- availableGroups={availableGroups}
- fields={schemaFields}
- />
- </div>
- )}
- <div className="flex justify-end gap-3 pt-4 border-t">
- <Button type="button" variant="secondary" onClick={onClose} disabled={isLoading}>
- Cancel
- </Button>
- <Button type="submit" isLoading={isLoading} disabled={!!schemaError}>
- {collection ? 'Save Changes' : 'Create Collection'}
- </Button>
- </div>
- </form>
- </div>
- </div>
- )
- }
- function DeleteConfirmModal({
- collection,
- workspaceId,
- onClose,
- onConfirm,
- }: {
- collection: Collection
- workspaceId: string
- onClose: () => void
- onConfirm: () => void
- }) {
- const [isLoading, setIsLoading] = useState(false)
- const [error, setError] = useState<string | null>(null)
- const [confirmName, setConfirmName] = useState('')
- const handleDelete = async () => {
- if (confirmName !== collection.name) {
- setError('Collection name does not match')
- return
- }
- setIsLoading(true)
- setError(null)
- try {
- await apiClient.delete(`/workspaces/${workspaceId}/collections/${collection.name}`)
- onConfirm()
- } catch (err) {
- setError(err instanceof Error ? err.message : 'Failed to delete collection')
- setIsLoading(false)
- }
- }
- 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">Delete Collection</h2>
- <p className="mt-2 text-gray-600">
- This will permanently delete the collection <strong>{collection.name}</strong> and all its
- documents. This action cannot be undone.
- </p>
- <div className="mt-4">
- <label className="mb-1.5 block text-sm font-medium text-gray-700">
- Type <strong>{collection.name}</strong> to confirm
- </label>
- <Input
- value={confirmName}
- onChange={(e) => setConfirmName(e.target.value)}
- placeholder={collection.name}
- />
- </div>
- {error && (
- <div className="mt-4 rounded-lg bg-red-50 px-4 py-3 text-sm text-red-700">{error}</div>
- )}
- <div className="mt-6 flex justify-end gap-3">
- <Button type="button" variant="secondary" onClick={onClose} disabled={isLoading}>
- Cancel
- </Button>
- <Button
- type="button"
- variant="danger"
- onClick={handleDelete}
- isLoading={isLoading}
- disabled={confirmName !== collection.name}
- >
- Delete Collection
- </Button>
- </div>
- </div>
- </div>
- )
- }
- function Collections() {
- const navigate = useNavigate()
- const { currentWorkspace } = useWorkspace()
- const { user } = useAuth()
- const [collections, setCollections] = useState<Collection[]>([])
- const [groups, setGroups] = useState<Group[]>([])
- const [filteredCollections, setFilteredCollections] = useState<Collection[]>([])
- const [isLoading, setIsLoading] = useState(true)
- const [error, setError] = useState<string | null>(null)
- const [searchQuery, setSearchQuery] = useState('')
- const [showCreateModal, setShowCreateModal] = useState(false)
- const [editingCollection, setEditingCollection] = useState<Collection | null>(null)
- const [deletingCollection, setDeletingCollection] = useState<Collection | null>(null)
- const isSuperadmin = user?.is_superadmin === true
- const fetchCollections = useCallback(async () => {
- if (!currentWorkspace) {
- setCollections([])
- setIsLoading(false)
- return
- }
- setIsLoading(true)
- setError(null)
- try {
- let url = `/workspaces/${currentWorkspace.id}/collections`
- // Always include system collections for superadmins
- if (isSuperadmin) {
- url += '?include_system=true'
- }
- const response = await apiClient.get<{ collections: Collection[] }>(url)
- setCollections(response.collections || [])
- } catch (err) {
- setError(err instanceof Error ? err.message : 'Failed to fetch collections')
- } finally {
- setIsLoading(false)
- }
- }, [currentWorkspace, isSuperadmin])
- const fetchGroups = useCallback(async () => {
- if (!currentWorkspace) {
- setGroups([])
- return
- }
- try {
- const response = await apiClient.get<{ groups: Group[] }>(
- `/workspaces/${currentWorkspace.id}/groups`
- )
- setGroups(response.groups || [])
- } catch {
- // Silently fail - groups are optional for field permissions
- }
- }, [currentWorkspace])
- useEffect(() => {
- fetchCollections()
- fetchGroups()
- }, [fetchCollections, fetchGroups])
- useEffect(() => {
- const query = searchQuery.toLowerCase()
- setFilteredCollections(collections.filter((c) => c.name.toLowerCase().includes(query)))
- }, [collections, searchQuery])
- const handleSave = () => {
- setShowCreateModal(false)
- setEditingCollection(null)
- fetchCollections()
- }
- const handleDelete = () => {
- setDeletingCollection(null)
- fetchCollections()
- }
- const getFieldPermissionCount = (collection: Collection): number => {
- return collection.settings?.field_permissions?.length || 0
- }
- if (!currentWorkspace) {
- return (
- <div className="space-y-6">
- <div>
- <h1 className="text-2xl font-bold text-gray-900">Collections</h1>
- <p className="mt-1 text-gray-600">Manage your data collections</p>
- </div>
- <div className="rounded-lg bg-yellow-50 p-6 text-center">
- <p className="text-yellow-800">Please select a workspace to manage collections.</p>
- </div>
- </div>
- )
- }
- return (
- <div className="space-y-6">
- <div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
- <div>
- <h1 className="text-2xl font-bold text-gray-900">Collections</h1>
- <p className="mt-1 text-gray-600">Manage collections in {currentWorkspace.name}</p>
- </div>
- <Button onClick={() => setShowCreateModal(true)}>
- <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>
- Create Collection
- </Button>
- </div>
- {/* Search */}
- <div className="max-w-md">
- <Input
- type="search"
- placeholder="Search collections..."
- value={searchQuery}
- onChange={(e) => setSearchQuery(e.target.value)}
- />
- </div>
- {/* Error state */}
- {error && <div className="rounded-lg bg-red-50 px-4 py-3 text-sm text-red-700">{error}</div>}
- {/* Loading state */}
- {isLoading && (
- <div className="flex items-center justify-center py-12">
- <svg
- className="h-8 w-8 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>
- )}
- {/* Collection list */}
- {!isLoading && !error && (
- <div className="overflow-hidden rounded-lg bg-white shadow">
- {filteredCollections.length === 0 ? (
- <div className="px-6 py-12 text-center">
- <p className="text-gray-500">
- {searchQuery ? 'No collections match your search' : 'No collections found'}
- </p>
- </div>
- ) : (
- <table className="min-w-full divide-y divide-gray-200">
- <thead className="bg-gray-50">
- <tr>
- <th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
- Name
- </th>
- <th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
- Type
- </th>
- <th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
- Documents
- </th>
- <th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
- Field Rules
- </th>
- <th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
- Created
- </th>
- <th className="px-6 py-3 text-right text-xs font-medium uppercase tracking-wider text-gray-500">
- Actions
- </th>
- </tr>
- </thead>
- <tbody className="divide-y divide-gray-200 bg-white">
- {filteredCollections.map((collection) => (
- <tr key={collection.name} className="hover:bg-gray-50">
- <td className="whitespace-nowrap px-6 py-4">
- <div className="flex items-center gap-2">
- <svg
- className="h-5 w-5 text-gray-400"
- 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>
- <span className="font-medium text-gray-900">{collection.name}</span>
- </div>
- </td>
- <td className="whitespace-nowrap px-6 py-4">
- {collection.is_system ? (
- <span className="inline-flex rounded-full bg-purple-100 px-2 py-0.5 text-xs font-medium text-purple-800">
- System
- </span>
- ) : (
- <span className="inline-flex rounded-full bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-600">
- User
- </span>
- )}
- </td>
- <td className="whitespace-nowrap px-6 py-4 text-sm text-gray-500">
- {collection.document_count ?? '-'}
- </td>
- <td className="whitespace-nowrap px-6 py-4 text-sm text-gray-500">
- {getFieldPermissionCount(collection) > 0 ? (
- <span className="inline-flex items-center rounded-full bg-yellow-100 px-2 py-0.5 text-xs font-medium text-yellow-800">
- {getFieldPermissionCount(collection)} rules
- </span>
- ) : (
- <span className="text-gray-400">-</span>
- )}
- </td>
- <td className="whitespace-nowrap px-6 py-4 text-sm text-gray-500">
- {new Date(collection.created_at).toLocaleDateString()}
- </td>
- <td className="whitespace-nowrap px-6 py-4 text-right">
- <div className="flex justify-end gap-2">
- <button
- onClick={() => navigate(`/collections/${collection.name}`)}
- className="rounded px-3 py-1 text-sm text-gray-600 hover:bg-gray-100"
- title="View documents"
- >
- Documents
- </button>
- <button
- onClick={() => setEditingCollection(collection)}
- className="rounded px-3 py-1 text-sm text-primary-600 hover:bg-primary-50"
- disabled={collection.is_system}
- title={
- collection.is_system
- ? 'Cannot edit system collections'
- : 'Edit'
- }
- >
- Edit
- </button>
- <button
- onClick={() => setDeletingCollection(collection)}
- className="rounded px-3 py-1 text-sm text-red-600 hover:bg-red-50 disabled:cursor-not-allowed disabled:opacity-50"
- disabled={collection.is_system}
- title={
- collection.is_system
- ? 'Cannot delete system collections'
- : 'Delete'
- }
- >
- Delete
- </button>
- </div>
- </td>
- </tr>
- ))}
- </tbody>
- </table>
- )}
- </div>
- )}
- {/* Modals */}
- {showCreateModal && (
- <CollectionModal
- collection={null}
- workspaceId={currentWorkspace.id}
- availableGroups={groups}
- onClose={() => setShowCreateModal(false)}
- onSave={handleSave}
- />
- )}
- {editingCollection && !editingCollection.settings?.is_system && (
- <CollectionModal
- collection={editingCollection}
- workspaceId={currentWorkspace.id}
- availableGroups={groups}
- onClose={() => setEditingCollection(null)}
- onSave={handleSave}
- />
- )}
- {deletingCollection && !deletingCollection.settings?.is_system && (
- <DeleteConfirmModal
- collection={deletingCollection}
- workspaceId={currentWorkspace.id}
- onClose={() => setDeletingCollection(null)}
- onConfirm={handleDelete}
- />
- )}
- </div>
- )
- }
- export default Collections
|