UsersPage.tsx 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620
  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 { X, Plus, Edit2, Trash2, Save, Loader2, Users as UsersIcon, Shield, ArrowLeft } from 'lucide-react';
  6. import { api } from '../api/client';
  7. import type { UserCreate, UserUpdate, UserResponse } from '../api/client';
  8. import { useAuth } from '../contexts/AuthContext';
  9. import { useToast } from '../contexts/ToastContext';
  10. import { Button } from '../components/Button';
  11. import { Card, CardContent, CardHeader } from '../components/Card';
  12. import { ConfirmModal } from '../components/ConfirmModal';
  13. interface FormData extends UserCreate {
  14. group_ids: number[];
  15. confirmPassword: string;
  16. }
  17. export function UsersPage() {
  18. const navigate = useNavigate();
  19. const { t } = useTranslation();
  20. const { user: currentUser, hasPermission } = useAuth();
  21. const { showToast } = useToast();
  22. const queryClient = useQueryClient();
  23. const [showCreateModal, setShowCreateModal] = useState(false);
  24. const [showEditModal, setShowEditModal] = useState(false);
  25. const [editingUserId, setEditingUserId] = useState<number | null>(null);
  26. const [deleteUserId, setDeleteUserId] = useState<number | null>(null);
  27. const [formData, setFormData] = useState<FormData>({
  28. username: '',
  29. password: '',
  30. confirmPassword: '',
  31. role: 'user',
  32. group_ids: [],
  33. });
  34. // Close modal on Escape key
  35. useEffect(() => {
  36. const handleKeyDown = (e: KeyboardEvent) => {
  37. if (e.key === 'Escape') {
  38. if (showCreateModal) {
  39. setShowCreateModal(false);
  40. setFormData({ username: '', password: '', confirmPassword: '', role: 'user', group_ids: [] });
  41. }
  42. if (showEditModal) {
  43. setShowEditModal(false);
  44. setEditingUserId(null);
  45. setFormData({ username: '', password: '', confirmPassword: '', role: 'user', group_ids: [] });
  46. }
  47. }
  48. };
  49. window.addEventListener('keydown', handleKeyDown);
  50. return () => window.removeEventListener('keydown', handleKeyDown);
  51. }, [showCreateModal, showEditModal]);
  52. const { data: users = [], isLoading } = useQuery({
  53. queryKey: ['users'],
  54. queryFn: () => api.getUsers(),
  55. enabled: hasPermission('users:read'),
  56. });
  57. const { data: groups = [] } = useQuery({
  58. queryKey: ['groups'],
  59. queryFn: () => api.getGroups(),
  60. enabled: hasPermission('groups:read'),
  61. });
  62. const createMutation = useMutation({
  63. mutationFn: (data: UserCreate) => api.createUser(data),
  64. onSuccess: () => {
  65. queryClient.invalidateQueries({ queryKey: ['users'] });
  66. queryClient.invalidateQueries({ queryKey: ['groups'] });
  67. setShowCreateModal(false);
  68. setFormData({ username: '', password: '', confirmPassword: '', role: 'user', group_ids: [] });
  69. showToast(t('users.toast.created'));
  70. },
  71. onError: (error: Error) => {
  72. showToast(error.message, 'error');
  73. },
  74. });
  75. const updateMutation = useMutation({
  76. mutationFn: ({ id, data }: { id: number; data: UserUpdate }) => api.updateUser(id, data),
  77. onSuccess: () => {
  78. queryClient.invalidateQueries({ queryKey: ['users'] });
  79. queryClient.invalidateQueries({ queryKey: ['groups'] });
  80. setShowEditModal(false);
  81. setEditingUserId(null);
  82. setFormData({ username: '', password: '', confirmPassword: '', role: 'user', group_ids: [] });
  83. showToast(t('users.toast.updated'));
  84. },
  85. onError: (error: Error) => {
  86. showToast(error.message, 'error');
  87. },
  88. });
  89. const deleteMutation = useMutation({
  90. mutationFn: (id: number) => api.deleteUser(id),
  91. onSuccess: () => {
  92. queryClient.invalidateQueries({ queryKey: ['users'] });
  93. showToast(t('users.toast.deleted'));
  94. },
  95. onError: (error: Error) => {
  96. showToast(error.message, 'error');
  97. },
  98. });
  99. const handleCreate = () => {
  100. if (!formData.username || !formData.password) {
  101. showToast(t('users.toast.fillRequired'), 'error');
  102. return;
  103. }
  104. if (formData.password !== formData.confirmPassword) {
  105. showToast(t('users.toast.passwordsDoNotMatch'), 'error');
  106. return;
  107. }
  108. if (formData.password.length < 6) {
  109. showToast(t('users.toast.passwordTooShort'), 'error');
  110. return;
  111. }
  112. createMutation.mutate({
  113. username: formData.username,
  114. password: formData.password,
  115. role: formData.role,
  116. group_ids: formData.group_ids.length > 0 ? formData.group_ids : undefined,
  117. });
  118. };
  119. const handleUpdate = (id: number) => {
  120. // Validate password confirmation if a new password is being set
  121. if (formData.password) {
  122. if (formData.password !== formData.confirmPassword) {
  123. showToast(t('users.toast.passwordsDoNotMatch'), 'error');
  124. return;
  125. }
  126. if (formData.password.length < 6) {
  127. showToast(t('users.toast.passwordTooShort'), 'error');
  128. return;
  129. }
  130. }
  131. const updateData: UserUpdate = {
  132. username: formData.username || undefined,
  133. password: formData.password || undefined,
  134. role: formData.role,
  135. group_ids: formData.group_ids,
  136. };
  137. // Remove password if empty
  138. if (!updateData.password) {
  139. delete updateData.password;
  140. }
  141. updateMutation.mutate({ id, data: updateData });
  142. };
  143. const handleDelete = (id: number) => {
  144. setDeleteUserId(id);
  145. };
  146. const startEdit = (user: UserResponse) => {
  147. setEditingUserId(user.id);
  148. setFormData({
  149. username: user.username,
  150. password: '',
  151. confirmPassword: '',
  152. role: user.role,
  153. group_ids: user.groups?.map(g => g.id) || [],
  154. });
  155. setShowEditModal(true);
  156. };
  157. const closeEditModal = () => {
  158. setShowEditModal(false);
  159. setEditingUserId(null);
  160. setFormData({ username: '', password: '', confirmPassword: '', role: 'user', group_ids: [] });
  161. };
  162. const toggleGroup = (groupId: number) => {
  163. setFormData(prev => ({
  164. ...prev,
  165. group_ids: prev.group_ids.includes(groupId)
  166. ? prev.group_ids.filter(id => id !== groupId)
  167. : [...prev.group_ids, groupId],
  168. }));
  169. };
  170. if (!hasPermission('users:read')) {
  171. return (
  172. <div className="p-6">
  173. <Card>
  174. <CardContent className="py-6">
  175. <div className="flex items-center gap-3 text-red-400">
  176. <Shield className="w-5 h-5" />
  177. <p className="text-white">{t('users.noPermission')}</p>
  178. </div>
  179. </CardContent>
  180. </Card>
  181. </div>
  182. );
  183. }
  184. return (
  185. <div className="p-6">
  186. <div className="flex justify-between items-center mb-6">
  187. <div className="flex items-center gap-4">
  188. <button
  189. onClick={() => navigate('/settings?tab=users')}
  190. className="p-2 rounded-lg bg-bambu-dark-secondary hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-white transition-colors"
  191. title={t('users.backToSettings')}
  192. >
  193. <ArrowLeft className="w-5 h-5" />
  194. </button>
  195. <div>
  196. <h1 className="text-2xl font-bold text-white flex items-center gap-2">
  197. <UsersIcon className="w-6 h-6 text-bambu-green" />
  198. {t('users.title')}
  199. </h1>
  200. <p className="text-sm text-bambu-gray mt-1">
  201. {t('users.subtitle')}
  202. </p>
  203. </div>
  204. </div>
  205. <Button
  206. onClick={() => {
  207. setShowCreateModal(true);
  208. setFormData({ username: '', password: '', confirmPassword: '', role: 'user', group_ids: [] });
  209. }}
  210. >
  211. <Plus className="w-4 h-4" />
  212. {t('users.createUser')}
  213. </Button>
  214. </div>
  215. {isLoading ? (
  216. <div className="flex items-center justify-center py-12">
  217. <Loader2 className="w-8 h-8 text-bambu-green animate-spin" />
  218. </div>
  219. ) : (
  220. <Card>
  221. <div className="overflow-x-auto">
  222. <table className="min-w-full divide-y divide-bambu-dark-tertiary">
  223. <thead>
  224. <tr>
  225. <th className="px-6 py-3 text-left text-xs font-medium text-bambu-gray uppercase tracking-wider">
  226. {t('users.table.username')}
  227. </th>
  228. <th className="px-6 py-3 text-left text-xs font-medium text-bambu-gray uppercase tracking-wider">
  229. {t('users.table.groups')}
  230. </th>
  231. <th className="px-6 py-3 text-left text-xs font-medium text-bambu-gray uppercase tracking-wider">
  232. {t('users.table.status')}
  233. </th>
  234. <th className="px-6 py-3 text-left text-xs font-medium text-bambu-gray uppercase tracking-wider">
  235. {t('users.table.actions')}
  236. </th>
  237. </tr>
  238. </thead>
  239. <tbody className="divide-y divide-bambu-dark-tertiary">
  240. {users.map((user) => (
  241. <tr key={user.id} className="hover:bg-bambu-dark-tertiary/50 transition-colors">
  242. <td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-white">
  243. {user.username}
  244. </td>
  245. <td className="px-6 py-4 text-sm">
  246. <div className="flex flex-wrap gap-1">
  247. {user.is_admin && (
  248. <span className="px-2 py-0.5 rounded-full text-xs font-medium bg-purple-500/20 text-purple-300">
  249. {t('users.admin')}
  250. </span>
  251. )}
  252. {user.groups?.map(group => (
  253. <span
  254. key={group.id}
  255. className={`px-2 py-0.5 rounded-full text-xs font-medium ${
  256. group.name === 'Administrators'
  257. ? 'bg-purple-500/20 text-purple-300'
  258. : group.name === 'Operators'
  259. ? 'bg-blue-500/20 text-blue-300'
  260. : group.name === 'Viewers'
  261. ? 'bg-green-500/20 text-green-300'
  262. : 'bg-gray-500/20 text-gray-300'
  263. }`}
  264. >
  265. {group.name}
  266. </span>
  267. ))}
  268. {(!user.groups || user.groups.length === 0) && !user.is_admin && (
  269. <span className="text-bambu-gray">{t('users.noGroups')}</span>
  270. )}
  271. </div>
  272. </td>
  273. <td className="px-6 py-4 whitespace-nowrap text-sm">
  274. <span className={`px-3 py-1 rounded-full text-xs font-medium ${
  275. user.is_active
  276. ? 'bg-bambu-green/20 text-bambu-green'
  277. : 'bg-red-500/20 text-red-400'
  278. }`}>
  279. {user.is_active ? t('users.active') : t('users.inactive')}
  280. </span>
  281. </td>
  282. <td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
  283. <div className="flex items-center gap-2">
  284. <Button
  285. size="sm"
  286. variant="ghost"
  287. onClick={() => startEdit(user)}
  288. >
  289. <Edit2 className="w-4 h-4" />
  290. {t('users.edit')}
  291. </Button>
  292. {user.id !== currentUser?.id && (
  293. <Button
  294. size="sm"
  295. variant="ghost"
  296. onClick={() => handleDelete(user.id)}
  297. >
  298. <Trash2 className="w-4 h-4" />
  299. {t('users.delete')}
  300. </Button>
  301. )}
  302. </div>
  303. </td>
  304. </tr>
  305. ))}
  306. </tbody>
  307. </table>
  308. </div>
  309. </Card>
  310. )}
  311. {/* Create User Modal */}
  312. {showCreateModal && (
  313. <div
  314. className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4"
  315. onClick={() => {
  316. setShowCreateModal(false);
  317. setFormData({ username: '', password: '', confirmPassword: '', role: 'user', group_ids: [] });
  318. }}
  319. >
  320. <Card
  321. className="w-full max-w-md"
  322. onClick={(e: React.MouseEvent) => e.stopPropagation()}
  323. >
  324. <CardHeader>
  325. <div className="flex items-center justify-between">
  326. <div className="flex items-center gap-2">
  327. <UsersIcon className="w-5 h-5 text-bambu-green" />
  328. <h2 className="text-lg font-semibold text-white">{t('users.modal.createUser')}</h2>
  329. </div>
  330. <Button
  331. variant="ghost"
  332. size="sm"
  333. onClick={() => {
  334. setShowCreateModal(false);
  335. setFormData({ username: '', password: '', confirmPassword: '', role: 'user', group_ids: [] });
  336. }}
  337. >
  338. <X className="w-5 h-5" />
  339. </Button>
  340. </div>
  341. </CardHeader>
  342. <CardContent>
  343. <div className="space-y-4">
  344. <div>
  345. <label className="block text-sm font-medium text-white mb-2">
  346. {t('users.form.username')}
  347. </label>
  348. <input
  349. type="text"
  350. value={formData.username}
  351. onChange={(e) => setFormData({ ...formData, username: e.target.value })}
  352. 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"
  353. placeholder={t('users.form.usernamePlaceholder')}
  354. autoComplete="username"
  355. />
  356. </div>
  357. <div>
  358. <label className="block text-sm font-medium text-white mb-2">
  359. {t('users.form.password')}
  360. </label>
  361. <input
  362. type="password"
  363. value={formData.password}
  364. onChange={(e) => setFormData({ ...formData, password: e.target.value })}
  365. 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"
  366. placeholder={t('users.form.passwordPlaceholder')}
  367. autoComplete="new-password"
  368. minLength={6}
  369. />
  370. </div>
  371. <div>
  372. <label className="block text-sm font-medium text-white mb-2">
  373. {t('users.form.confirmPassword')}
  374. </label>
  375. <input
  376. type="password"
  377. value={formData.confirmPassword}
  378. onChange={(e) => setFormData({ ...formData, confirmPassword: e.target.value })}
  379. className={`w-full px-4 py-3 bg-bambu-dark-secondary border rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors ${
  380. formData.confirmPassword && formData.password !== formData.confirmPassword
  381. ? 'border-red-500'
  382. : 'border-bambu-dark-tertiary'
  383. }`}
  384. placeholder={t('users.form.confirmPasswordPlaceholder')}
  385. autoComplete="new-password"
  386. minLength={6}
  387. />
  388. {formData.confirmPassword && formData.password !== formData.confirmPassword && (
  389. <p className="text-red-400 text-xs mt-1">{t('users.toast.passwordsDoNotMatch')}</p>
  390. )}
  391. </div>
  392. <div>
  393. <label className="block text-sm font-medium text-white mb-2">
  394. {t('users.form.groups')}
  395. </label>
  396. <div className="space-y-2 max-h-40 overflow-y-auto p-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg">
  397. {groups.map(group => (
  398. <label
  399. key={group.id}
  400. className="flex items-center gap-3 px-2 py-1.5 rounded hover:bg-bambu-dark-tertiary cursor-pointer"
  401. >
  402. <input
  403. type="checkbox"
  404. checked={formData.group_ids.includes(group.id)}
  405. onChange={() => toggleGroup(group.id)}
  406. className="w-4 h-4 rounded border-bambu-gray text-bambu-green focus:ring-bambu-green focus:ring-offset-0 bg-bambu-dark"
  407. />
  408. <span className="text-sm text-white">{group.name}</span>
  409. {group.is_system && (
  410. <span className="text-xs text-yellow-400">({t('users.system')})</span>
  411. )}
  412. </label>
  413. ))}
  414. {groups.length === 0 && (
  415. <p className="text-sm text-bambu-gray">{t('users.noGroupsAvailable')}</p>
  416. )}
  417. </div>
  418. </div>
  419. </div>
  420. <div className="mt-6 flex justify-end gap-3">
  421. <Button
  422. variant="secondary"
  423. onClick={() => {
  424. setShowCreateModal(false);
  425. setFormData({ username: '', password: '', confirmPassword: '', role: 'user', group_ids: [] });
  426. }}
  427. >
  428. {t('users.modal.cancel')}
  429. </Button>
  430. <Button
  431. onClick={handleCreate}
  432. disabled={createMutation.isPending || !formData.username || !formData.password || formData.password !== formData.confirmPassword || formData.password.length < 6}
  433. >
  434. {createMutation.isPending ? (
  435. <>
  436. <Loader2 className="w-4 h-4 animate-spin" />
  437. {t('users.modal.creating')}
  438. </>
  439. ) : (
  440. <>
  441. <Plus className="w-4 h-4" />
  442. {t('users.modal.createUser')}
  443. </>
  444. )}
  445. </Button>
  446. </div>
  447. </CardContent>
  448. </Card>
  449. </div>
  450. )}
  451. {/* Edit User Modal */}
  452. {showEditModal && editingUserId !== null && (
  453. <div
  454. className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4"
  455. onClick={closeEditModal}
  456. >
  457. <Card
  458. className="w-full max-w-md"
  459. onClick={(e: React.MouseEvent) => e.stopPropagation()}
  460. >
  461. <CardHeader>
  462. <div className="flex items-center justify-between">
  463. <div className="flex items-center gap-2">
  464. <Edit2 className="w-5 h-5 text-bambu-green" />
  465. <h2 className="text-lg font-semibold text-white">{t('users.modal.editUser')}</h2>
  466. </div>
  467. <Button
  468. variant="ghost"
  469. size="sm"
  470. onClick={closeEditModal}
  471. >
  472. <X className="w-5 h-5" />
  473. </Button>
  474. </div>
  475. </CardHeader>
  476. <CardContent>
  477. <div className="space-y-4">
  478. <div>
  479. <label className="block text-sm font-medium text-white mb-2">
  480. {t('users.form.username')}
  481. </label>
  482. <input
  483. type="text"
  484. value={formData.username}
  485. onChange={(e) => setFormData({ ...formData, username: e.target.value })}
  486. 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"
  487. placeholder={t('users.form.usernamePlaceholder')}
  488. autoComplete="username"
  489. />
  490. </div>
  491. <div>
  492. <label className="block text-sm font-medium text-white mb-2">
  493. {t('users.form.password')} <span className="text-bambu-gray font-normal">({t('users.form.leaveBlankToKeep')})</span>
  494. </label>
  495. <input
  496. type="password"
  497. value={formData.password}
  498. onChange={(e) => setFormData({ ...formData, password: e.target.value, confirmPassword: '' })}
  499. 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"
  500. placeholder={t('users.form.newPasswordPlaceholder')}
  501. autoComplete="new-password"
  502. minLength={6}
  503. />
  504. </div>
  505. {formData.password && (
  506. <div>
  507. <label className="block text-sm font-medium text-white mb-2">
  508. {t('users.form.confirmPassword')}
  509. </label>
  510. <input
  511. type="password"
  512. value={formData.confirmPassword}
  513. onChange={(e) => setFormData({ ...formData, confirmPassword: e.target.value })}
  514. className={`w-full px-4 py-3 bg-bambu-dark-secondary border rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors ${
  515. formData.confirmPassword && formData.password !== formData.confirmPassword
  516. ? 'border-red-500'
  517. : 'border-bambu-dark-tertiary'
  518. }`}
  519. placeholder={t('users.form.confirmNewPasswordPlaceholder')}
  520. autoComplete="new-password"
  521. minLength={6}
  522. />
  523. {formData.confirmPassword && formData.password !== formData.confirmPassword && (
  524. <p className="text-red-400 text-xs mt-1">{t('users.toast.passwordsDoNotMatch')}</p>
  525. )}
  526. </div>
  527. )}
  528. <div>
  529. <label className="block text-sm font-medium text-white mb-2">
  530. {t('users.form.groups')}
  531. </label>
  532. <div className="space-y-2 max-h-40 overflow-y-auto p-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg">
  533. {groups.map(group => (
  534. <label
  535. key={group.id}
  536. className="flex items-center gap-3 px-2 py-1.5 rounded hover:bg-bambu-dark-tertiary cursor-pointer"
  537. >
  538. <input
  539. type="checkbox"
  540. checked={formData.group_ids.includes(group.id)}
  541. onChange={() => toggleGroup(group.id)}
  542. className="w-4 h-4 rounded border-bambu-gray text-bambu-green focus:ring-bambu-green focus:ring-offset-0 bg-bambu-dark"
  543. />
  544. <span className="text-sm text-white">{group.name}</span>
  545. {group.is_system && (
  546. <span className="text-xs text-yellow-400">({t('users.system')})</span>
  547. )}
  548. </label>
  549. ))}
  550. {groups.length === 0 && (
  551. <p className="text-sm text-bambu-gray">{t('users.noGroupsAvailable')}</p>
  552. )}
  553. </div>
  554. </div>
  555. </div>
  556. <div className="mt-6 flex justify-end gap-3">
  557. <Button
  558. variant="secondary"
  559. onClick={closeEditModal}
  560. >
  561. {t('users.modal.cancel')}
  562. </Button>
  563. <Button
  564. onClick={() => handleUpdate(editingUserId)}
  565. disabled={updateMutation.isPending || !formData.username || !!(formData.password && (formData.password !== formData.confirmPassword || formData.password.length < 6))}
  566. >
  567. {updateMutation.isPending ? (
  568. <>
  569. <Loader2 className="w-4 h-4 animate-spin" />
  570. {t('users.modal.saving')}
  571. </>
  572. ) : (
  573. <>
  574. <Save className="w-4 h-4" />
  575. {t('users.modal.saveChanges')}
  576. </>
  577. )}
  578. </Button>
  579. </div>
  580. </CardContent>
  581. </Card>
  582. </div>
  583. )}
  584. {/* Delete Confirmation Modal */}
  585. {deleteUserId !== null && (
  586. <ConfirmModal
  587. title={t('users.deleteModal.title')}
  588. message={t('users.deleteModal.message')}
  589. confirmText={t('users.deleteModal.confirm')}
  590. variant="danger"
  591. onConfirm={() => {
  592. deleteMutation.mutate(deleteUserId);
  593. setDeleteUserId(null);
  594. }}
  595. onCancel={() => setDeleteUserId(null)}
  596. />
  597. )}
  598. </div>
  599. );
  600. }