Collections.tsx 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596
  1. // Collections management page (US-032) - Updated for RBAC field permissions
  2. import { useState, useEffect, useCallback } from 'react'
  3. import { useNavigate } from 'react-router-dom'
  4. import apiClient from '@/api/client'
  5. import { useWorkspace } from '@/contexts/WorkspaceContext'
  6. import { useAuth } from '@/contexts/AuthContext'
  7. import Button from '@/components/Button'
  8. import Input from '@/components/Input'
  9. import FieldPermissionEditor from '@/components/FieldPermissionEditor'
  10. import type { Collection, Group, FieldPermission } from '@/types'
  11. interface CollectionFormData {
  12. name: string
  13. schema: string
  14. field_permissions: FieldPermission[]
  15. }
  16. function CollectionModal({
  17. collection,
  18. workspaceId,
  19. availableGroups,
  20. onClose,
  21. onSave,
  22. }: {
  23. collection: Collection | null
  24. workspaceId: string
  25. availableGroups: Group[]
  26. onClose: () => void
  27. onSave: () => void
  28. }) {
  29. const [formData, setFormData] = useState<CollectionFormData>({
  30. name: collection?.name || '',
  31. schema: collection?.settings?.schema
  32. ? typeof collection.settings.schema === 'string'
  33. ? collection.settings.schema
  34. : JSON.stringify(collection.settings.schema, null, 2)
  35. : '{}',
  36. field_permissions: collection?.settings?.field_permissions || [],
  37. })
  38. const [isLoading, setIsLoading] = useState(false)
  39. const [error, setError] = useState<string | null>(null)
  40. const [schemaError, setSchemaError] = useState<string | null>(null)
  41. const [activeTab, setActiveTab] = useState<'schema' | 'permissions'>('schema')
  42. // Extract field names from schema for the field permission editor
  43. const schemaFields = (() => {
  44. try {
  45. const parsed = JSON.parse(formData.schema)
  46. if (parsed.properties && typeof parsed.properties === 'object') {
  47. return Object.keys(parsed.properties)
  48. }
  49. return []
  50. } catch {
  51. return []
  52. }
  53. })()
  54. const validateSchema = (schemaStr: string): boolean => {
  55. try {
  56. JSON.parse(schemaStr)
  57. setSchemaError(null)
  58. return true
  59. } catch {
  60. setSchemaError('Invalid JSON schema')
  61. return false
  62. }
  63. }
  64. const handleSchemaChange = (value: string) => {
  65. setFormData({ ...formData, schema: value })
  66. validateSchema(value)
  67. }
  68. const handleSubmit = async (e: React.FormEvent) => {
  69. e.preventDefault()
  70. if (!validateSchema(formData.schema)) {
  71. return
  72. }
  73. setIsLoading(true)
  74. setError(null)
  75. try {
  76. const payload = {
  77. name: formData.name,
  78. settings: {
  79. schema: JSON.parse(formData.schema),
  80. field_permissions: formData.field_permissions,
  81. },
  82. }
  83. if (collection) {
  84. await apiClient.request(`/workspaces/${workspaceId}/collections/${collection.name}`, {
  85. method: 'PUT',
  86. body: payload,
  87. })
  88. } else {
  89. await apiClient.post(`/workspaces/${workspaceId}/collections`, payload)
  90. }
  91. onSave()
  92. } catch (err) {
  93. setError(err instanceof Error ? err.message : 'Failed to save collection')
  94. } finally {
  95. setIsLoading(false)
  96. }
  97. }
  98. return (
  99. <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
  100. <div className="w-full max-w-3xl max-h-[90vh] overflow-y-auto rounded-lg bg-white p-6 shadow-xl">
  101. <h2 className="text-xl font-semibold text-gray-900">
  102. {collection ? 'Edit Collection' : 'Create Collection'}
  103. </h2>
  104. <form onSubmit={handleSubmit} className="mt-4 space-y-4">
  105. {error && (
  106. <div className="rounded-lg bg-red-50 px-4 py-3 text-sm text-red-700">{error}</div>
  107. )}
  108. <Input
  109. label="Collection Name"
  110. name="name"
  111. value={formData.name}
  112. onChange={(e) => setFormData({ ...formData, name: e.target.value })}
  113. placeholder="my_collection"
  114. required
  115. disabled={!!collection}
  116. autoFocus={!collection}
  117. />
  118. {/* Tabs */}
  119. <div className="border-b border-gray-200">
  120. <nav className="-mb-px flex space-x-8">
  121. <button
  122. type="button"
  123. onClick={() => setActiveTab('schema')}
  124. className={`py-2 px-1 border-b-2 font-medium text-sm ${
  125. activeTab === 'schema'
  126. ? 'border-indigo-500 text-indigo-600'
  127. : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
  128. }`}
  129. >
  130. Schema
  131. </button>
  132. <button
  133. type="button"
  134. onClick={() => setActiveTab('permissions')}
  135. className={`py-2 px-1 border-b-2 font-medium text-sm ${
  136. activeTab === 'permissions'
  137. ? 'border-indigo-500 text-indigo-600'
  138. : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
  139. }`}
  140. >
  141. Field Permissions
  142. </button>
  143. </nav>
  144. </div>
  145. {/* Tab Content */}
  146. {activeTab === 'schema' && (
  147. <div>
  148. <label className="mb-1.5 block text-sm font-medium text-gray-700">
  149. Schema (JSON)
  150. </label>
  151. <textarea
  152. value={formData.schema}
  153. onChange={(e) => handleSchemaChange(e.target.value)}
  154. placeholder='{ "type": "object", "properties": { ... } }'
  155. rows={12}
  156. className={`w-full rounded-lg border px-3 py-2 font-mono text-sm transition focus:outline-none focus:ring-2 ${
  157. schemaError
  158. ? 'border-red-500 focus:border-red-500 focus:ring-red-200'
  159. : 'border-gray-300 focus:border-primary-500 focus:ring-primary-200'
  160. }`}
  161. />
  162. {schemaError && <p className="mt-1 text-sm text-red-600">{schemaError}</p>}
  163. <p className="mt-1 text-xs text-gray-500">
  164. Define the JSON Schema for document validation. Fields defined in the schema can be
  165. used for field-level permissions.
  166. </p>
  167. </div>
  168. )}
  169. {activeTab === 'permissions' && (
  170. <div className="space-y-4">
  171. <p className="text-sm text-gray-600">
  172. Configure which groups can read or write specific fields. Leave empty to allow all
  173. groups access.
  174. </p>
  175. <FieldPermissionEditor
  176. fieldPermissions={formData.field_permissions}
  177. onChange={(field_permissions) =>
  178. setFormData({ ...formData, field_permissions })
  179. }
  180. availableGroups={availableGroups}
  181. fields={schemaFields}
  182. />
  183. </div>
  184. )}
  185. <div className="flex justify-end gap-3 pt-4 border-t">
  186. <Button type="button" variant="secondary" onClick={onClose} disabled={isLoading}>
  187. Cancel
  188. </Button>
  189. <Button type="submit" isLoading={isLoading} disabled={!!schemaError}>
  190. {collection ? 'Save Changes' : 'Create Collection'}
  191. </Button>
  192. </div>
  193. </form>
  194. </div>
  195. </div>
  196. )
  197. }
  198. function DeleteConfirmModal({
  199. collection,
  200. workspaceId,
  201. onClose,
  202. onConfirm,
  203. }: {
  204. collection: Collection
  205. workspaceId: string
  206. onClose: () => void
  207. onConfirm: () => void
  208. }) {
  209. const [isLoading, setIsLoading] = useState(false)
  210. const [error, setError] = useState<string | null>(null)
  211. const [confirmName, setConfirmName] = useState('')
  212. const handleDelete = async () => {
  213. if (confirmName !== collection.name) {
  214. setError('Collection name does not match')
  215. return
  216. }
  217. setIsLoading(true)
  218. setError(null)
  219. try {
  220. await apiClient.delete(`/workspaces/${workspaceId}/collections/${collection.name}`)
  221. onConfirm()
  222. } catch (err) {
  223. setError(err instanceof Error ? err.message : 'Failed to delete collection')
  224. setIsLoading(false)
  225. }
  226. }
  227. return (
  228. <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
  229. <div className="w-full max-w-md rounded-lg bg-white p-6 shadow-xl">
  230. <h2 className="text-xl font-semibold text-gray-900">Delete Collection</h2>
  231. <p className="mt-2 text-gray-600">
  232. This will permanently delete the collection <strong>{collection.name}</strong> and all its
  233. documents. This action cannot be undone.
  234. </p>
  235. <div className="mt-4">
  236. <label className="mb-1.5 block text-sm font-medium text-gray-700">
  237. Type <strong>{collection.name}</strong> to confirm
  238. </label>
  239. <Input
  240. value={confirmName}
  241. onChange={(e) => setConfirmName(e.target.value)}
  242. placeholder={collection.name}
  243. />
  244. </div>
  245. {error && (
  246. <div className="mt-4 rounded-lg bg-red-50 px-4 py-3 text-sm text-red-700">{error}</div>
  247. )}
  248. <div className="mt-6 flex justify-end gap-3">
  249. <Button type="button" variant="secondary" onClick={onClose} disabled={isLoading}>
  250. Cancel
  251. </Button>
  252. <Button
  253. type="button"
  254. variant="danger"
  255. onClick={handleDelete}
  256. isLoading={isLoading}
  257. disabled={confirmName !== collection.name}
  258. >
  259. Delete Collection
  260. </Button>
  261. </div>
  262. </div>
  263. </div>
  264. )
  265. }
  266. function Collections() {
  267. const navigate = useNavigate()
  268. const { currentWorkspace } = useWorkspace()
  269. const { user } = useAuth()
  270. const [collections, setCollections] = useState<Collection[]>([])
  271. const [groups, setGroups] = useState<Group[]>([])
  272. const [filteredCollections, setFilteredCollections] = useState<Collection[]>([])
  273. const [isLoading, setIsLoading] = useState(true)
  274. const [error, setError] = useState<string | null>(null)
  275. const [searchQuery, setSearchQuery] = useState('')
  276. const [showCreateModal, setShowCreateModal] = useState(false)
  277. const [editingCollection, setEditingCollection] = useState<Collection | null>(null)
  278. const [deletingCollection, setDeletingCollection] = useState<Collection | null>(null)
  279. const isSuperadmin = user?.is_superadmin === true
  280. const fetchCollections = useCallback(async () => {
  281. if (!currentWorkspace) {
  282. setCollections([])
  283. setIsLoading(false)
  284. return
  285. }
  286. setIsLoading(true)
  287. setError(null)
  288. try {
  289. let url = `/workspaces/${currentWorkspace.id}/collections`
  290. // Always include system collections for superadmins
  291. if (isSuperadmin) {
  292. url += '?include_system=true'
  293. }
  294. const response = await apiClient.get<{ collections: Collection[] }>(url)
  295. setCollections(response.collections || [])
  296. } catch (err) {
  297. setError(err instanceof Error ? err.message : 'Failed to fetch collections')
  298. } finally {
  299. setIsLoading(false)
  300. }
  301. }, [currentWorkspace, isSuperadmin])
  302. const fetchGroups = useCallback(async () => {
  303. if (!currentWorkspace) {
  304. setGroups([])
  305. return
  306. }
  307. try {
  308. const response = await apiClient.get<{ groups: Group[] }>(
  309. `/workspaces/${currentWorkspace.id}/groups`
  310. )
  311. setGroups(response.groups || [])
  312. } catch {
  313. // Silently fail - groups are optional for field permissions
  314. }
  315. }, [currentWorkspace])
  316. useEffect(() => {
  317. fetchCollections()
  318. fetchGroups()
  319. }, [fetchCollections, fetchGroups])
  320. useEffect(() => {
  321. const query = searchQuery.toLowerCase()
  322. setFilteredCollections(collections.filter((c) => c.name.toLowerCase().includes(query)))
  323. }, [collections, searchQuery])
  324. const handleSave = () => {
  325. setShowCreateModal(false)
  326. setEditingCollection(null)
  327. fetchCollections()
  328. }
  329. const handleDelete = () => {
  330. setDeletingCollection(null)
  331. fetchCollections()
  332. }
  333. const getFieldPermissionCount = (collection: Collection): number => {
  334. return collection.settings?.field_permissions?.length || 0
  335. }
  336. if (!currentWorkspace) {
  337. return (
  338. <div className="space-y-6">
  339. <div>
  340. <h1 className="text-2xl font-bold text-gray-900">Collections</h1>
  341. <p className="mt-1 text-gray-600">Manage your data collections</p>
  342. </div>
  343. <div className="rounded-lg bg-yellow-50 p-6 text-center">
  344. <p className="text-yellow-800">Please select a workspace to manage collections.</p>
  345. </div>
  346. </div>
  347. )
  348. }
  349. return (
  350. <div className="space-y-6">
  351. <div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
  352. <div>
  353. <h1 className="text-2xl font-bold text-gray-900">Collections</h1>
  354. <p className="mt-1 text-gray-600">Manage collections in {currentWorkspace.name}</p>
  355. </div>
  356. <Button onClick={() => setShowCreateModal(true)}>
  357. <svg className="-ml-1 mr-2 h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
  358. <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
  359. </svg>
  360. Create Collection
  361. </Button>
  362. </div>
  363. {/* Search */}
  364. <div className="max-w-md">
  365. <Input
  366. type="search"
  367. placeholder="Search collections..."
  368. value={searchQuery}
  369. onChange={(e) => setSearchQuery(e.target.value)}
  370. />
  371. </div>
  372. {/* Error state */}
  373. {error && <div className="rounded-lg bg-red-50 px-4 py-3 text-sm text-red-700">{error}</div>}
  374. {/* Loading state */}
  375. {isLoading && (
  376. <div className="flex items-center justify-center py-12">
  377. <svg
  378. className="h-8 w-8 animate-spin text-primary-600"
  379. xmlns="http://www.w3.org/2000/svg"
  380. fill="none"
  381. viewBox="0 0 24 24"
  382. >
  383. <circle
  384. className="opacity-25"
  385. cx="12"
  386. cy="12"
  387. r="10"
  388. stroke="currentColor"
  389. strokeWidth="4"
  390. />
  391. <path
  392. className="opacity-75"
  393. fill="currentColor"
  394. 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"
  395. />
  396. </svg>
  397. </div>
  398. )}
  399. {/* Collection list */}
  400. {!isLoading && !error && (
  401. <div className="overflow-hidden rounded-lg bg-white shadow">
  402. {filteredCollections.length === 0 ? (
  403. <div className="px-6 py-12 text-center">
  404. <p className="text-gray-500">
  405. {searchQuery ? 'No collections match your search' : 'No collections found'}
  406. </p>
  407. </div>
  408. ) : (
  409. <table className="min-w-full divide-y divide-gray-200">
  410. <thead className="bg-gray-50">
  411. <tr>
  412. <th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
  413. Name
  414. </th>
  415. <th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
  416. Type
  417. </th>
  418. <th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
  419. Documents
  420. </th>
  421. <th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
  422. Field Rules
  423. </th>
  424. <th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
  425. Created
  426. </th>
  427. <th className="px-6 py-3 text-right text-xs font-medium uppercase tracking-wider text-gray-500">
  428. Actions
  429. </th>
  430. </tr>
  431. </thead>
  432. <tbody className="divide-y divide-gray-200 bg-white">
  433. {filteredCollections.map((collection) => (
  434. <tr key={collection.name} className="hover:bg-gray-50">
  435. <td className="whitespace-nowrap px-6 py-4">
  436. <div className="flex items-center gap-2">
  437. <svg
  438. className="h-5 w-5 text-gray-400"
  439. fill="none"
  440. viewBox="0 0 24 24"
  441. stroke="currentColor"
  442. >
  443. <path
  444. strokeLinecap="round"
  445. strokeLinejoin="round"
  446. strokeWidth={2}
  447. 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"
  448. />
  449. </svg>
  450. <span className="font-medium text-gray-900">{collection.name}</span>
  451. </div>
  452. </td>
  453. <td className="whitespace-nowrap px-6 py-4">
  454. {collection.is_system ? (
  455. <span className="inline-flex rounded-full bg-purple-100 px-2 py-0.5 text-xs font-medium text-purple-800">
  456. System
  457. </span>
  458. ) : (
  459. <span className="inline-flex rounded-full bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-600">
  460. User
  461. </span>
  462. )}
  463. </td>
  464. <td className="whitespace-nowrap px-6 py-4 text-sm text-gray-500">
  465. {collection.document_count ?? '-'}
  466. </td>
  467. <td className="whitespace-nowrap px-6 py-4 text-sm text-gray-500">
  468. {getFieldPermissionCount(collection) > 0 ? (
  469. <span className="inline-flex items-center rounded-full bg-yellow-100 px-2 py-0.5 text-xs font-medium text-yellow-800">
  470. {getFieldPermissionCount(collection)} rules
  471. </span>
  472. ) : (
  473. <span className="text-gray-400">-</span>
  474. )}
  475. </td>
  476. <td className="whitespace-nowrap px-6 py-4 text-sm text-gray-500">
  477. {new Date(collection.created_at).toLocaleDateString()}
  478. </td>
  479. <td className="whitespace-nowrap px-6 py-4 text-right">
  480. <div className="flex justify-end gap-2">
  481. <button
  482. onClick={() => navigate(`/collections/${collection.name}`)}
  483. className="rounded px-3 py-1 text-sm text-gray-600 hover:bg-gray-100"
  484. title="View documents"
  485. >
  486. Documents
  487. </button>
  488. <button
  489. onClick={() => setEditingCollection(collection)}
  490. className="rounded px-3 py-1 text-sm text-primary-600 hover:bg-primary-50"
  491. disabled={collection.is_system}
  492. title={
  493. collection.is_system
  494. ? 'Cannot edit system collections'
  495. : 'Edit'
  496. }
  497. >
  498. Edit
  499. </button>
  500. <button
  501. onClick={() => setDeletingCollection(collection)}
  502. className="rounded px-3 py-1 text-sm text-red-600 hover:bg-red-50 disabled:cursor-not-allowed disabled:opacity-50"
  503. disabled={collection.is_system}
  504. title={
  505. collection.is_system
  506. ? 'Cannot delete system collections'
  507. : 'Delete'
  508. }
  509. >
  510. Delete
  511. </button>
  512. </div>
  513. </td>
  514. </tr>
  515. ))}
  516. </tbody>
  517. </table>
  518. )}
  519. </div>
  520. )}
  521. {/* Modals */}
  522. {showCreateModal && (
  523. <CollectionModal
  524. collection={null}
  525. workspaceId={currentWorkspace.id}
  526. availableGroups={groups}
  527. onClose={() => setShowCreateModal(false)}
  528. onSave={handleSave}
  529. />
  530. )}
  531. {editingCollection && !editingCollection.settings?.is_system && (
  532. <CollectionModal
  533. collection={editingCollection}
  534. workspaceId={currentWorkspace.id}
  535. availableGroups={groups}
  536. onClose={() => setEditingCollection(null)}
  537. onSave={handleSave}
  538. />
  539. )}
  540. {deletingCollection && !deletingCollection.settings?.is_system && (
  541. <DeleteConfirmModal
  542. collection={deletingCollection}
  543. workspaceId={currentWorkspace.id}
  544. onClose={() => setDeletingCollection(null)}
  545. onConfirm={handleDelete}
  546. />
  547. )}
  548. </div>
  549. )
  550. }
  551. export default Collections