Groups.tsx 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485
  1. // Groups management page (US-031) - Updated for RBAC
  2. import { useState, useEffect, useCallback } from 'react'
  3. import apiClient from '@/api/client'
  4. import { useWorkspace } from '@/contexts/WorkspaceContext'
  5. import Button from '@/components/Button'
  6. import Input from '@/components/Input'
  7. import PermissionEditor from '@/components/PermissionEditor'
  8. import type { Group } from '@/types'
  9. interface GroupFormData {
  10. name: string
  11. permissions: string[]
  12. parent_group_id?: string
  13. }
  14. function GroupModal({
  15. group,
  16. workspaceId,
  17. availableGroups,
  18. onClose,
  19. onSave,
  20. }: {
  21. group: Group | null
  22. workspaceId: string
  23. availableGroups: Group[]
  24. onClose: () => void
  25. onSave: () => void
  26. }) {
  27. const [formData, setFormData] = useState<GroupFormData>({
  28. name: group?.name || '',
  29. permissions: group?.permissions || [],
  30. parent_group_id: group?.parent_group_id || '',
  31. })
  32. const [isLoading, setIsLoading] = useState(false)
  33. const [error, setError] = useState<string | null>(null)
  34. // Filter out the current group from parent options (can't be parent of itself)
  35. const parentOptions = availableGroups.filter(g => g.id !== group?.id)
  36. const handleSubmit = async (e: React.FormEvent) => {
  37. e.preventDefault()
  38. setIsLoading(true)
  39. setError(null)
  40. try {
  41. const payload = {
  42. name: formData.name,
  43. permissions: formData.permissions,
  44. parent_group_id: formData.parent_group_id || undefined,
  45. }
  46. if (group) {
  47. await apiClient.patch(`/workspaces/${workspaceId}/groups/${group.id}`, payload)
  48. } else {
  49. await apiClient.post(`/workspaces/${workspaceId}/groups`, payload)
  50. }
  51. onSave()
  52. } catch (err) {
  53. setError(err instanceof Error ? err.message : 'Failed to save group')
  54. } finally {
  55. setIsLoading(false)
  56. }
  57. }
  58. return (
  59. <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
  60. <div className="w-full max-w-2xl max-h-[90vh] overflow-y-auto rounded-lg bg-white p-6 shadow-xl">
  61. <h2 className="text-xl font-semibold text-gray-900">
  62. {group ? 'Edit Group' : 'Create Group'}
  63. </h2>
  64. <form onSubmit={handleSubmit} className="mt-4 space-y-6">
  65. {error && (
  66. <div className="rounded-lg bg-red-50 px-4 py-3 text-sm text-red-700">{error}</div>
  67. )}
  68. <Input
  69. label="Group Name"
  70. name="name"
  71. value={formData.name}
  72. onChange={(e) => setFormData({ ...formData, name: e.target.value })}
  73. placeholder="Enter group name"
  74. required
  75. autoFocus
  76. />
  77. {/* Parent Group Selection */}
  78. <div>
  79. <label className="block text-sm font-medium text-gray-700 mb-1">
  80. Parent Group (optional)
  81. </label>
  82. <select
  83. value={formData.parent_group_id || ''}
  84. onChange={(e) => setFormData({ ...formData, parent_group_id: e.target.value })}
  85. className="block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
  86. >
  87. <option value="">None (Top-level group)</option>
  88. {parentOptions.map((g) => (
  89. <option key={g.id} value={g.id}>
  90. {g.name}
  91. {g.is_system && ' (System)'}
  92. </option>
  93. ))}
  94. </select>
  95. <p className="mt-1 text-xs text-gray-500">
  96. Child groups inherit permissions from their parent group.
  97. </p>
  98. </div>
  99. {/* Permission Editor */}
  100. <PermissionEditor
  101. permissions={formData.permissions}
  102. onChange={(permissions) => setFormData({ ...formData, permissions })}
  103. workspaceId={workspaceId}
  104. />
  105. <div className="flex justify-end gap-3 pt-4 border-t">
  106. <Button type="button" variant="secondary" onClick={onClose} disabled={isLoading}>
  107. Cancel
  108. </Button>
  109. <Button type="submit" isLoading={isLoading}>
  110. {group ? 'Save Changes' : 'Create Group'}
  111. </Button>
  112. </div>
  113. </form>
  114. </div>
  115. </div>
  116. )
  117. }
  118. function DeleteConfirmModal({
  119. group,
  120. workspaceId,
  121. onClose,
  122. onConfirm,
  123. }: {
  124. group: Group
  125. workspaceId: string
  126. onClose: () => void
  127. onConfirm: () => void
  128. }) {
  129. const [isLoading, setIsLoading] = useState(false)
  130. const [error, setError] = useState<string | null>(null)
  131. const handleDelete = async () => {
  132. setIsLoading(true)
  133. setError(null)
  134. try {
  135. await apiClient.delete(`/workspaces/${workspaceId}/groups/${group.id}`)
  136. onConfirm()
  137. } catch (err) {
  138. setError(err instanceof Error ? err.message : 'Failed to delete group')
  139. setIsLoading(false)
  140. }
  141. }
  142. return (
  143. <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
  144. <div className="w-full max-w-md rounded-lg bg-white p-6 shadow-xl">
  145. <h2 className="text-xl font-semibold text-gray-900">Delete Group</h2>
  146. <p className="mt-2 text-gray-600">
  147. Are you sure you want to delete <strong>{group.name}</strong>? Users assigned to this
  148. group will lose these permissions.
  149. </p>
  150. {error && (
  151. <div className="mt-4 rounded-lg bg-red-50 px-4 py-3 text-sm text-red-700">{error}</div>
  152. )}
  153. <div className="mt-6 flex justify-end gap-3">
  154. <Button type="button" variant="secondary" onClick={onClose} disabled={isLoading}>
  155. Cancel
  156. </Button>
  157. <Button type="button" variant="danger" onClick={handleDelete} isLoading={isLoading}>
  158. Delete Group
  159. </Button>
  160. </div>
  161. </div>
  162. </div>
  163. )
  164. }
  165. function Groups() {
  166. const { currentWorkspace } = useWorkspace()
  167. const [groups, setGroups] = useState<Group[]>([])
  168. const [filteredGroups, setFilteredGroups] = useState<Group[]>([])
  169. const [isLoading, setIsLoading] = useState(true)
  170. const [error, setError] = useState<string | null>(null)
  171. const [searchQuery, setSearchQuery] = useState('')
  172. const [showCreateModal, setShowCreateModal] = useState(false)
  173. const [editingGroup, setEditingGroup] = useState<Group | null>(null)
  174. const [deletingGroup, setDeletingGroup] = useState<Group | null>(null)
  175. const [showSystemGroups, setShowSystemGroups] = useState(false)
  176. const fetchGroups = useCallback(async () => {
  177. if (!currentWorkspace) {
  178. setGroups([])
  179. setIsLoading(false)
  180. return
  181. }
  182. setIsLoading(true)
  183. setError(null)
  184. try {
  185. let url = `/workspaces/${currentWorkspace.id}/groups`
  186. if (showSystemGroups) {
  187. url += '?include_system=true'
  188. }
  189. const response = await apiClient.get<{ groups: Group[] }>(url)
  190. setGroups(response.groups || [])
  191. } catch (err) {
  192. setError(err instanceof Error ? err.message : 'Failed to fetch groups')
  193. } finally {
  194. setIsLoading(false)
  195. }
  196. }, [currentWorkspace, showSystemGroups])
  197. useEffect(() => {
  198. fetchGroups()
  199. }, [fetchGroups])
  200. useEffect(() => {
  201. const query = searchQuery.toLowerCase()
  202. let filtered = groups.filter(
  203. (g) =>
  204. g.name.toLowerCase().includes(query) ||
  205. g.permissions.some((p) => p.toLowerCase().includes(query))
  206. )
  207. // Filter out system groups unless showing them
  208. if (!showSystemGroups) {
  209. filtered = filtered.filter((g) => !g.is_system)
  210. }
  211. setFilteredGroups(filtered)
  212. }, [groups, searchQuery, showSystemGroups])
  213. const handleSave = () => {
  214. setShowCreateModal(false)
  215. setEditingGroup(null)
  216. fetchGroups()
  217. }
  218. const handleDelete = () => {
  219. setDeletingGroup(null)
  220. fetchGroups()
  221. }
  222. const getParentGroupName = (parentId?: string): string | null => {
  223. if (!parentId) return null
  224. const parent = groups.find((g) => g.id === parentId)
  225. return parent?.name || null
  226. }
  227. // Check if any system groups exist
  228. const hasSystemGroups = groups.some((g) => g.is_system)
  229. if (!currentWorkspace) {
  230. return (
  231. <div className="space-y-6">
  232. <div>
  233. <h1 className="text-2xl font-bold text-gray-900">Groups</h1>
  234. <p className="mt-1 text-gray-600">Manage permission groups</p>
  235. </div>
  236. <div className="rounded-lg bg-yellow-50 p-6 text-center">
  237. <p className="text-yellow-800">Please select a workspace to manage groups.</p>
  238. </div>
  239. </div>
  240. )
  241. }
  242. return (
  243. <div className="space-y-6">
  244. <div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
  245. <div>
  246. <h1 className="text-2xl font-bold text-gray-900">Groups</h1>
  247. <p className="mt-1 text-gray-600">Manage permission groups in {currentWorkspace.name}</p>
  248. </div>
  249. <Button onClick={() => setShowCreateModal(true)}>
  250. <svg className="-ml-1 mr-2 h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
  251. <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
  252. </svg>
  253. Create Group
  254. </Button>
  255. </div>
  256. {/* Search and filters */}
  257. <div className="flex flex-col gap-4 sm:flex-row sm:items-center">
  258. <div className="max-w-md flex-1">
  259. <Input
  260. type="search"
  261. placeholder="Search groups..."
  262. value={searchQuery}
  263. onChange={(e) => setSearchQuery(e.target.value)}
  264. />
  265. </div>
  266. {hasSystemGroups && (
  267. <label className="inline-flex items-center gap-2 text-sm text-gray-600">
  268. <input
  269. type="checkbox"
  270. checked={showSystemGroups}
  271. onChange={(e) => setShowSystemGroups(e.target.checked)}
  272. className="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
  273. />
  274. Show system groups
  275. </label>
  276. )}
  277. </div>
  278. {/* Error state */}
  279. {error && <div className="rounded-lg bg-red-50 px-4 py-3 text-sm text-red-700">{error}</div>}
  280. {/* Loading state */}
  281. {isLoading && (
  282. <div className="flex items-center justify-center py-12">
  283. <svg
  284. className="h-8 w-8 animate-spin text-primary-600"
  285. xmlns="http://www.w3.org/2000/svg"
  286. fill="none"
  287. viewBox="0 0 24 24"
  288. >
  289. <circle
  290. className="opacity-25"
  291. cx="12"
  292. cy="12"
  293. r="10"
  294. stroke="currentColor"
  295. strokeWidth="4"
  296. />
  297. <path
  298. className="opacity-75"
  299. fill="currentColor"
  300. 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"
  301. />
  302. </svg>
  303. </div>
  304. )}
  305. {/* Group list */}
  306. {!isLoading && !error && (
  307. <div className="overflow-hidden rounded-lg bg-white shadow">
  308. {filteredGroups.length === 0 ? (
  309. <div className="px-6 py-12 text-center">
  310. <p className="text-gray-500">
  311. {searchQuery ? 'No groups match your search' : 'No groups found'}
  312. </p>
  313. </div>
  314. ) : (
  315. <table className="min-w-full divide-y divide-gray-200">
  316. <thead className="bg-gray-50">
  317. <tr>
  318. <th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
  319. Name
  320. </th>
  321. <th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
  322. Permissions
  323. </th>
  324. <th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
  325. Parent
  326. </th>
  327. <th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
  328. Created
  329. </th>
  330. <th className="px-6 py-3 text-right text-xs font-medium uppercase tracking-wider text-gray-500">
  331. Actions
  332. </th>
  333. </tr>
  334. </thead>
  335. <tbody className="divide-y divide-gray-200 bg-white">
  336. {filteredGroups.map((group) => (
  337. <tr key={group.id} className="hover:bg-gray-50">
  338. <td className="whitespace-nowrap px-6 py-4">
  339. <div className="flex items-center gap-2">
  340. <div className="font-medium text-gray-900">{group.name}</div>
  341. {group.is_system && (
  342. <span className="inline-flex items-center rounded-full bg-purple-100 px-2 py-0.5 text-xs font-medium text-purple-800">
  343. System
  344. </span>
  345. )}
  346. </div>
  347. </td>
  348. <td className="px-6 py-4">
  349. <div className="flex flex-wrap gap-1">
  350. {group.permissions.includes('*') ? (
  351. <span className="inline-flex rounded-full bg-red-100 px-2 py-0.5 text-xs font-medium text-red-800">
  352. Superadmin (*)
  353. </span>
  354. ) : (
  355. <>
  356. {group.permissions.slice(0, 3).map((perm) => (
  357. <span
  358. key={perm}
  359. className="inline-flex rounded-full bg-primary-100 px-2 py-0.5 text-xs font-medium text-primary-800"
  360. title={perm}
  361. >
  362. {perm.length > 25 ? `${perm.substring(0, 22)}...` : perm}
  363. </span>
  364. ))}
  365. {group.permissions.length > 3 && (
  366. <span
  367. className="inline-flex rounded-full bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-600"
  368. title={group.permissions.slice(3).join(', ')}
  369. >
  370. +{group.permissions.length - 3} more
  371. </span>
  372. )}
  373. </>
  374. )}
  375. {group.permissions.length === 0 && (
  376. <span className="text-sm text-gray-400">No permissions</span>
  377. )}
  378. </div>
  379. </td>
  380. <td className="whitespace-nowrap px-6 py-4 text-sm text-gray-500">
  381. {getParentGroupName(group.parent_group_id) || (
  382. <span className="text-gray-400">-</span>
  383. )}
  384. </td>
  385. <td className="whitespace-nowrap px-6 py-4 text-sm text-gray-500">
  386. {new Date(group.created_at).toLocaleDateString()}
  387. </td>
  388. <td className="whitespace-nowrap px-6 py-4 text-right">
  389. {group.is_system ? (
  390. <span className="text-sm text-gray-400" title="System groups cannot be modified">
  391. Protected
  392. </span>
  393. ) : (
  394. <div className="flex justify-end gap-2">
  395. <button
  396. onClick={() => setEditingGroup(group)}
  397. className="rounded px-3 py-1 text-sm text-primary-600 hover:bg-primary-50"
  398. >
  399. Edit
  400. </button>
  401. <button
  402. onClick={() => setDeletingGroup(group)}
  403. className="rounded px-3 py-1 text-sm text-red-600 hover:bg-red-50"
  404. >
  405. Delete
  406. </button>
  407. </div>
  408. )}
  409. </td>
  410. </tr>
  411. ))}
  412. </tbody>
  413. </table>
  414. )}
  415. </div>
  416. )}
  417. {/* Modals */}
  418. {showCreateModal && (
  419. <GroupModal
  420. group={null}
  421. workspaceId={currentWorkspace.id}
  422. availableGroups={groups.filter((g) => !g.is_system)}
  423. onClose={() => setShowCreateModal(false)}
  424. onSave={handleSave}
  425. />
  426. )}
  427. {editingGroup && !editingGroup.is_system && (
  428. <GroupModal
  429. group={editingGroup}
  430. workspaceId={currentWorkspace.id}
  431. availableGroups={groups.filter((g) => !g.is_system)}
  432. onClose={() => setEditingGroup(null)}
  433. onSave={handleSave}
  434. />
  435. )}
  436. {deletingGroup && !deletingGroup.is_system && (
  437. <DeleteConfirmModal
  438. group={deletingGroup}
  439. workspaceId={currentWorkspace.id}
  440. onClose={() => setDeletingGroup(null)}
  441. onConfirm={handleDelete}
  442. />
  443. )}
  444. </div>
  445. )
  446. }
  447. export default Groups