Groups.tsx 17 KB

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