UsersPage.tsx 30 KB

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