UsersPage.tsx 34 KB

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