GroupsPage.tsx 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498
  1. import { useState, useEffect } from 'react';
  2. import { useNavigate } from 'react-router-dom';
  3. import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
  4. import { useTranslation } from 'react-i18next';
  5. import {
  6. X,
  7. Plus,
  8. Edit2,
  9. Trash2,
  10. Save,
  11. Loader2,
  12. Shield,
  13. ArrowLeft,
  14. Users,
  15. Check,
  16. ChevronDown,
  17. ChevronRight,
  18. } from 'lucide-react';
  19. import { api } from '../api/client';
  20. import type { Group, GroupCreate, GroupUpdate, Permission, PermissionCategory } from '../api/client';
  21. import { useAuth } from '../contexts/AuthContext';
  22. import { useToast } from '../contexts/ToastContext';
  23. import { Button } from '../components/Button';
  24. import { Card, CardContent, CardHeader } from '../components/Card';
  25. import { ConfirmModal } from '../components/ConfirmModal';
  26. export function GroupsPage() {
  27. const navigate = useNavigate();
  28. const { t } = useTranslation();
  29. const { hasPermission } = useAuth();
  30. const { showToast } = useToast();
  31. const queryClient = useQueryClient();
  32. const [showCreateModal, setShowCreateModal] = useState(false);
  33. const [editingGroup, setEditingGroup] = useState<Group | null>(null);
  34. const [deleteGroupId, setDeleteGroupId] = useState<number | null>(null);
  35. const [formData, setFormData] = useState<{
  36. name: string;
  37. description: string;
  38. permissions: Permission[];
  39. }>({
  40. name: '',
  41. description: '',
  42. permissions: [],
  43. });
  44. const [expandedCategories, setExpandedCategories] = useState<Set<string>>(new Set());
  45. // Close modal on Escape key
  46. useEffect(() => {
  47. const handleKeyDown = (e: KeyboardEvent) => {
  48. if (e.key === 'Escape' && (showCreateModal || editingGroup)) {
  49. setShowCreateModal(false);
  50. setEditingGroup(null);
  51. resetForm();
  52. }
  53. };
  54. window.addEventListener('keydown', handleKeyDown);
  55. return () => window.removeEventListener('keydown', handleKeyDown);
  56. }, [showCreateModal, editingGroup]);
  57. const { data: groups = [], isLoading: groupsLoading } = useQuery({
  58. queryKey: ['groups'],
  59. queryFn: () => api.getGroups(),
  60. enabled: hasPermission('groups:read'),
  61. });
  62. const { data: permissionsData } = useQuery({
  63. queryKey: ['permissions'],
  64. queryFn: () => api.getPermissions(),
  65. enabled: hasPermission('groups:read'),
  66. });
  67. const createMutation = useMutation({
  68. mutationFn: (data: GroupCreate) => api.createGroup(data),
  69. onSuccess: () => {
  70. queryClient.invalidateQueries({ queryKey: ['groups'] });
  71. setShowCreateModal(false);
  72. resetForm();
  73. showToast(t('groups.toast.created'));
  74. },
  75. onError: (error: Error) => {
  76. showToast(error.message, 'error');
  77. },
  78. });
  79. const updateMutation = useMutation({
  80. mutationFn: ({ id, data }: { id: number; data: GroupUpdate }) => api.updateGroup(id, data),
  81. onSuccess: () => {
  82. queryClient.invalidateQueries({ queryKey: ['groups'] });
  83. setEditingGroup(null);
  84. resetForm();
  85. showToast(t('groups.toast.updated'));
  86. },
  87. onError: (error: Error) => {
  88. showToast(error.message, 'error');
  89. },
  90. });
  91. const deleteMutation = useMutation({
  92. mutationFn: (id: number) => api.deleteGroup(id),
  93. onSuccess: () => {
  94. queryClient.invalidateQueries({ queryKey: ['groups'] });
  95. showToast(t('groups.toast.deleted'));
  96. },
  97. onError: (error: Error) => {
  98. showToast(error.message, 'error');
  99. },
  100. });
  101. const resetForm = () => {
  102. setFormData({ name: '', description: '', permissions: [] });
  103. setExpandedCategories(new Set());
  104. };
  105. const handleCreate = () => {
  106. if (!formData.name.trim()) {
  107. showToast(t('groups.toast.enterGroupName'), 'error');
  108. return;
  109. }
  110. createMutation.mutate({
  111. name: formData.name,
  112. description: formData.description || undefined,
  113. permissions: formData.permissions,
  114. });
  115. };
  116. const handleUpdate = () => {
  117. if (!editingGroup) return;
  118. if (!formData.name.trim()) {
  119. showToast(t('groups.toast.enterGroupName'), 'error');
  120. return;
  121. }
  122. updateMutation.mutate({
  123. id: editingGroup.id,
  124. data: {
  125. name: formData.name !== editingGroup.name ? formData.name : undefined,
  126. description: formData.description,
  127. permissions: formData.permissions,
  128. },
  129. });
  130. };
  131. const handleDelete = (id: number) => {
  132. setDeleteGroupId(id);
  133. };
  134. const startEdit = (group: Group) => {
  135. setEditingGroup(group);
  136. setFormData({
  137. name: group.name,
  138. description: group.description || '',
  139. permissions: group.permissions,
  140. });
  141. // Expand categories that have selected permissions
  142. const cats = new Set<string>();
  143. permissionsData?.categories.forEach((cat) => {
  144. if (cat.permissions.some((p) => group.permissions.includes(p.value))) {
  145. cats.add(cat.name);
  146. }
  147. });
  148. setExpandedCategories(cats);
  149. };
  150. const toggleCategory = (categoryName: string) => {
  151. setExpandedCategories((prev) => {
  152. const next = new Set(prev);
  153. if (next.has(categoryName)) {
  154. next.delete(categoryName);
  155. } else {
  156. next.add(categoryName);
  157. }
  158. return next;
  159. });
  160. };
  161. const togglePermission = (permission: Permission) => {
  162. setFormData((prev) => {
  163. const permissions = prev.permissions.includes(permission)
  164. ? prev.permissions.filter((p) => p !== permission)
  165. : [...prev.permissions, permission];
  166. return { ...prev, permissions };
  167. });
  168. };
  169. const toggleCategoryPermissions = (category: PermissionCategory, checked: boolean) => {
  170. setFormData((prev) => {
  171. const categoryPerms = category.permissions.map((p) => p.value);
  172. const otherPerms = prev.permissions.filter((p) => !categoryPerms.includes(p));
  173. const permissions = checked ? [...otherPerms, ...categoryPerms] : otherPerms;
  174. return { ...prev, permissions };
  175. });
  176. };
  177. const isCategoryFullySelected = (category: PermissionCategory) => {
  178. return category.permissions.every((p) => formData.permissions.includes(p.value));
  179. };
  180. const isCategoryPartiallySelected = (category: PermissionCategory) => {
  181. const selected = category.permissions.filter((p) => formData.permissions.includes(p.value));
  182. return selected.length > 0 && selected.length < category.permissions.length;
  183. };
  184. // Permission check
  185. if (!hasPermission('groups:read')) {
  186. return (
  187. <div className="p-6">
  188. <Card>
  189. <CardContent className="py-6">
  190. <div className="flex items-center gap-3 text-red-400">
  191. <Shield className="w-5 h-5" />
  192. <p className="text-white">{t('groups.noPermission')}</p>
  193. </div>
  194. </CardContent>
  195. </Card>
  196. </div>
  197. );
  198. }
  199. const renderPermissionEditor = () => (
  200. <div className="space-y-2 max-h-96 overflow-y-auto">
  201. {permissionsData?.categories.map((category) => (
  202. <div key={category.name} className="border border-bambu-dark-tertiary rounded-lg overflow-hidden">
  203. <div
  204. className="flex items-center justify-between px-4 py-2 bg-bambu-dark-secondary cursor-pointer hover:bg-bambu-dark-tertiary transition-colors"
  205. onClick={() => toggleCategory(category.name)}
  206. >
  207. <div className="flex items-center gap-3">
  208. <button
  209. type="button"
  210. onClick={(e) => {
  211. e.stopPropagation();
  212. toggleCategoryPermissions(category, !isCategoryFullySelected(category));
  213. }}
  214. className={`w-5 h-5 rounded border flex items-center justify-center transition-colors ${
  215. isCategoryFullySelected(category)
  216. ? 'bg-bambu-green border-bambu-green'
  217. : isCategoryPartiallySelected(category)
  218. ? 'bg-bambu-green/50 border-bambu-green'
  219. : 'border-bambu-gray hover:border-white'
  220. }`}
  221. >
  222. {(isCategoryFullySelected(category) || isCategoryPartiallySelected(category)) && (
  223. <Check className="w-3 h-3 text-white" />
  224. )}
  225. </button>
  226. <span className="text-white font-medium">{category.name}</span>
  227. <span className="text-xs text-bambu-gray">
  228. ({category.permissions.filter((p) => formData.permissions.includes(p.value)).length}/
  229. {category.permissions.length})
  230. </span>
  231. </div>
  232. {expandedCategories.has(category.name) ? (
  233. <ChevronDown className="w-4 h-4 text-bambu-gray" />
  234. ) : (
  235. <ChevronRight className="w-4 h-4 text-bambu-gray" />
  236. )}
  237. </div>
  238. {expandedCategories.has(category.name) && (
  239. <div className="p-3 bg-bambu-dark space-y-2">
  240. {category.permissions.map((perm) => (
  241. <label
  242. key={perm.value}
  243. className="flex items-center gap-3 px-2 py-1.5 rounded hover:bg-bambu-dark-secondary cursor-pointer"
  244. >
  245. <input
  246. type="checkbox"
  247. checked={formData.permissions.includes(perm.value)}
  248. onChange={() => togglePermission(perm.value)}
  249. className="w-4 h-4 rounded border-bambu-gray text-bambu-green focus:ring-bambu-green focus:ring-offset-0 bg-bambu-dark-secondary"
  250. />
  251. <span className="text-sm text-bambu-gray">{perm.label}</span>
  252. </label>
  253. ))}
  254. </div>
  255. )}
  256. </div>
  257. ))}
  258. </div>
  259. );
  260. return (
  261. <div className="p-6">
  262. <div className="flex justify-between items-center mb-6">
  263. <div className="flex items-center gap-4">
  264. <button
  265. onClick={() => navigate('/settings?tab=users')}
  266. className="p-2 rounded-lg bg-bambu-dark-secondary hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-white transition-colors"
  267. title={t('groups.backToSettings')}
  268. >
  269. <ArrowLeft className="w-5 h-5" />
  270. </button>
  271. <div>
  272. <h1 className="text-2xl font-bold text-white flex items-center gap-2">
  273. <Shield className="w-6 h-6 text-bambu-green" />
  274. {t('groups.title')}
  275. </h1>
  276. <p className="text-sm text-bambu-gray mt-1">
  277. {t('groups.subtitle')}
  278. </p>
  279. </div>
  280. </div>
  281. {hasPermission('groups:create') && (
  282. <Button
  283. onClick={() => {
  284. setShowCreateModal(true);
  285. resetForm();
  286. }}
  287. >
  288. <Plus className="w-4 h-4" />
  289. {t('groups.createGroup')}
  290. </Button>
  291. )}
  292. </div>
  293. {groupsLoading ? (
  294. <div className="flex items-center justify-center py-12">
  295. <Loader2 className="w-8 h-8 text-bambu-green animate-spin" />
  296. </div>
  297. ) : (
  298. <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
  299. {groups.map((group) => (
  300. <Card key={group.id}>
  301. <CardHeader>
  302. <div className="flex items-center justify-between">
  303. <div className="flex items-center gap-2">
  304. <Shield
  305. className={`w-5 h-5 ${
  306. group.name === 'Administrators'
  307. ? 'text-purple-400'
  308. : group.name === 'Operators'
  309. ? 'text-blue-400'
  310. : group.name === 'Viewers'
  311. ? 'text-green-400'
  312. : 'text-bambu-gray'
  313. }`}
  314. />
  315. <h3 className="text-lg font-semibold text-white">{group.name}</h3>
  316. {group.is_system && (
  317. <span className="px-2 py-0.5 rounded text-xs bg-yellow-500/20 text-yellow-400">
  318. {t('groups.system')}
  319. </span>
  320. )}
  321. </div>
  322. </div>
  323. </CardHeader>
  324. <CardContent>
  325. <p className="text-sm text-bambu-gray mb-4">{group.description || t('groups.noDescription')}</p>
  326. <div className="flex items-center justify-between">
  327. <div className="flex items-center gap-2 text-sm text-bambu-gray">
  328. <Users className="w-4 h-4" />
  329. <span>{t('groups.usersCount', { count: group.user_count })}</span>
  330. </div>
  331. <div className="text-xs text-bambu-gray">
  332. {t('groups.permissionsCount', { count: group.permissions.length })}
  333. </div>
  334. </div>
  335. <div className="flex gap-2 mt-4 pt-4 border-t border-bambu-dark-tertiary">
  336. {hasPermission('groups:update') && (
  337. <Button size="sm" variant="ghost" onClick={() => startEdit(group)}>
  338. <Edit2 className="w-4 h-4" />
  339. {t('groups.edit')}
  340. </Button>
  341. )}
  342. {hasPermission('groups:delete') && !group.is_system && (
  343. <Button size="sm" variant="ghost" onClick={() => handleDelete(group.id)}>
  344. <Trash2 className="w-4 h-4" />
  345. {t('groups.delete')}
  346. </Button>
  347. )}
  348. </div>
  349. </CardContent>
  350. </Card>
  351. ))}
  352. </div>
  353. )}
  354. {/* Create/Edit Group Modal */}
  355. {(showCreateModal || editingGroup) && (
  356. <div
  357. className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4"
  358. onClick={() => {
  359. setShowCreateModal(false);
  360. setEditingGroup(null);
  361. resetForm();
  362. }}
  363. >
  364. <Card
  365. className="w-full max-w-2xl max-h-[90vh] overflow-y-auto"
  366. onClick={(e: React.MouseEvent) => e.stopPropagation()}
  367. >
  368. <CardHeader>
  369. <div className="flex items-center justify-between">
  370. <div className="flex items-center gap-2">
  371. <Shield className="w-5 h-5 text-bambu-green" />
  372. <h2 className="text-lg font-semibold text-white">
  373. {editingGroup ? t('groups.modal.editGroup') : t('groups.modal.createGroup')}
  374. </h2>
  375. </div>
  376. <Button
  377. variant="ghost"
  378. size="sm"
  379. onClick={() => {
  380. setShowCreateModal(false);
  381. setEditingGroup(null);
  382. resetForm();
  383. }}
  384. >
  385. <X className="w-5 h-5" />
  386. </Button>
  387. </div>
  388. </CardHeader>
  389. <CardContent>
  390. <div className="space-y-4">
  391. <div>
  392. <label className="block text-sm font-medium text-white mb-2">
  393. {t('groups.form.groupName')}
  394. </label>
  395. <input
  396. type="text"
  397. value={formData.name}
  398. onChange={(e) => setFormData({ ...formData, name: e.target.value })}
  399. disabled={editingGroup?.is_system}
  400. className="w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors disabled:opacity-50"
  401. placeholder={t('groups.form.groupNamePlaceholder')}
  402. />
  403. {editingGroup?.is_system && (
  404. <p className="text-xs text-yellow-400 mt-1">{t('groups.form.systemGroupWarning')}</p>
  405. )}
  406. </div>
  407. <div>
  408. <label className="block text-sm font-medium text-white mb-2">
  409. {t('groups.form.description')}
  410. </label>
  411. <textarea
  412. value={formData.description}
  413. onChange={(e) => setFormData({ ...formData, description: e.target.value })}
  414. rows={2}
  415. className="w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors resize-none"
  416. placeholder={t('groups.form.descriptionPlaceholder')}
  417. />
  418. </div>
  419. <div>
  420. <label className="block text-sm font-medium text-white mb-2">
  421. {t('groups.form.permissions', { count: formData.permissions.length })}
  422. </label>
  423. {renderPermissionEditor()}
  424. </div>
  425. </div>
  426. <div className="mt-6 flex justify-end gap-3">
  427. <Button
  428. variant="secondary"
  429. onClick={() => {
  430. setShowCreateModal(false);
  431. setEditingGroup(null);
  432. resetForm();
  433. }}
  434. >
  435. {t('groups.modal.cancel')}
  436. </Button>
  437. <Button
  438. onClick={editingGroup ? handleUpdate : handleCreate}
  439. disabled={createMutation.isPending || updateMutation.isPending || !formData.name.trim()}
  440. >
  441. {(createMutation.isPending || updateMutation.isPending) ? (
  442. <>
  443. <Loader2 className="w-4 h-4 animate-spin" />
  444. {editingGroup ? t('groups.modal.saving') : t('groups.modal.creating')}
  445. </>
  446. ) : (
  447. <>
  448. <Save className="w-4 h-4" />
  449. {editingGroup ? t('groups.modal.saveChanges') : t('groups.modal.createGroup')}
  450. </>
  451. )}
  452. </Button>
  453. </div>
  454. </CardContent>
  455. </Card>
  456. </div>
  457. )}
  458. {/* Delete Confirmation Modal */}
  459. {deleteGroupId !== null && (
  460. <ConfirmModal
  461. title={t('groups.deleteModal.title')}
  462. message={t('groups.deleteModal.message')}
  463. confirmText={t('groups.deleteModal.confirm')}
  464. variant="danger"
  465. onConfirm={() => {
  466. deleteMutation.mutate(deleteGroupId);
  467. setDeleteGroupId(null);
  468. }}
  469. onCancel={() => setDeleteGroupId(null)}
  470. />
  471. )}
  472. </div>
  473. );
  474. }