Users.tsx 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590
  1. // Users management page (US-030)
  2. import { useState, useEffect, useCallback } from 'react'
  3. import apiClient from '@/api/client'
  4. import Button from '@/components/Button'
  5. import Input from '@/components/Input'
  6. import { WorkspaceMembershipEditor } from '@/components/WorkspaceMembershipEditor'
  7. import type { User } from '@/types'
  8. import { formatDateTime } from '@/utils/format'
  9. interface UserFormData {
  10. email: string
  11. name: string
  12. password: string
  13. }
  14. interface UserMembership {
  15. id: string
  16. workspace_id: string
  17. workspace_name?: string
  18. groups: string[]
  19. group_names?: string[]
  20. }
  21. function UserModal({
  22. user,
  23. onClose,
  24. onSave,
  25. }: {
  26. user: User | null
  27. onClose: () => void
  28. onSave: () => void
  29. }) {
  30. const [formData, setFormData] = useState<UserFormData>({
  31. email: user?.email || '',
  32. name: user?.name || '',
  33. password: '',
  34. })
  35. const [memberships, setMemberships] = useState<UserMembership[]>([])
  36. const [originalMemberships, setOriginalMemberships] = useState<UserMembership[]>([])
  37. const [isLoading, setIsLoading] = useState(false)
  38. const [isFetchingMemberships, setIsFetchingMemberships] = useState(false)
  39. const [error, setError] = useState<string | null>(null)
  40. const [membershipError, setMembershipError] = useState<string | null>(null)
  41. const [activeTab, setActiveTab] = useState<'basic' | 'access'>('basic')
  42. // Fetch user memberships when editing
  43. useEffect(() => {
  44. if (!user) return
  45. let cancelled = false
  46. const fetchMemberships = async () => {
  47. setIsFetchingMemberships(true)
  48. setMembershipError(null)
  49. try {
  50. const response = await apiClient.get<{ memberships: UserMembership[] }>(
  51. `/users/${user.id}/memberships`
  52. )
  53. if (cancelled) return
  54. const fetchedMemberships = response.memberships || []
  55. setMemberships(fetchedMemberships)
  56. setOriginalMemberships(fetchedMemberships)
  57. } catch (err) {
  58. if (cancelled) return
  59. console.error('Failed to fetch memberships:', err)
  60. setMembershipError(err instanceof Error ? err.message : 'Failed to fetch memberships')
  61. } finally {
  62. if (!cancelled) {
  63. setIsFetchingMemberships(false)
  64. }
  65. }
  66. }
  67. fetchMemberships()
  68. return () => {
  69. cancelled = true
  70. }
  71. }, [user?.id])
  72. const handleSubmit = async (e: React.FormEvent) => {
  73. e.preventDefault()
  74. setIsLoading(true)
  75. setError(null)
  76. try {
  77. let userId = user?.id
  78. if (user) {
  79. // Update user - only send non-empty password
  80. const updateData: Partial<UserFormData> = {
  81. name: formData.name,
  82. }
  83. if (formData.password) {
  84. updateData.password = formData.password
  85. }
  86. await apiClient.patch(`/users/${user.id}`, updateData)
  87. } else {
  88. // Create user
  89. const createResponse = await apiClient.post<{ id: string }>('/users', formData)
  90. userId = createResponse.id
  91. }
  92. // Save membership changes
  93. if (userId) {
  94. await saveMembershipChanges(userId)
  95. }
  96. onSave()
  97. } catch (err) {
  98. setError(err instanceof Error ? err.message : 'Failed to save user')
  99. } finally {
  100. setIsLoading(false)
  101. }
  102. }
  103. const saveMembershipChanges = async (userId: string) => {
  104. const originalMap = new Map(originalMemberships.map((m) => [m.workspace_id, m]))
  105. const currentMap = new Map(memberships.map((m) => [m.workspace_id, m]))
  106. // Find added workspaces
  107. const added = memberships.filter((m) => !originalMap.has(m.workspace_id))
  108. // Find removed workspaces
  109. const removed = originalMemberships.filter((m) => !currentMap.has(m.workspace_id))
  110. // Find modified workspaces (group changes)
  111. const modified = memberships.filter((m) => {
  112. const original = originalMap.get(m.workspace_id)
  113. if (!original) return false
  114. const originalGroups = new Set(original.groups)
  115. const currentGroups = new Set(m.groups)
  116. if (originalGroups.size !== currentGroups.size) return true
  117. for (const g of originalGroups) {
  118. if (!currentGroups.has(g)) return true
  119. }
  120. return false
  121. })
  122. // Add new memberships
  123. for (const membership of added) {
  124. await apiClient.post(`/workspaces/${membership.workspace_id}/members`, {
  125. user_id: userId,
  126. group_ids: membership.groups,
  127. })
  128. }
  129. // Remove memberships
  130. for (const membership of removed) {
  131. await apiClient.delete(`/workspaces/${membership.workspace_id}/members/${userId}`)
  132. }
  133. // Update modified memberships
  134. for (const membership of modified) {
  135. await apiClient.put(`/workspaces/${membership.workspace_id}/members/${userId}`, {
  136. group_ids: membership.groups,
  137. })
  138. }
  139. }
  140. return (
  141. <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
  142. <div className="flex max-h-[90vh] w-full max-w-2xl flex-col rounded-lg bg-white shadow-xl">
  143. {/* Header */}
  144. <div className="flex items-center justify-between border-b px-6 py-4">
  145. <h2 className="text-xl font-semibold text-gray-900">
  146. {user ? 'Edit User' : 'Create User'}
  147. </h2>
  148. <button
  149. type="button"
  150. onClick={onClose}
  151. className="rounded-lg p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-500"
  152. >
  153. <svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
  154. <path
  155. strokeLinecap="round"
  156. strokeLinejoin="round"
  157. strokeWidth={2}
  158. d="M6 18L18 6M6 6l12 12"
  159. />
  160. </svg>
  161. </button>
  162. </div>
  163. {/* Tabs */}
  164. <div className="border-b px-6">
  165. <nav className="-mb-px flex space-x-8">
  166. <button
  167. type="button"
  168. onClick={() => setActiveTab('basic')}
  169. className={`whitespace-nowrap border-b-2 py-4 px-1 text-sm font-medium ${
  170. activeTab === 'basic'
  171. ? 'border-primary-500 text-primary-600'
  172. : 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700'
  173. }`}
  174. >
  175. Basic Info
  176. </button>
  177. <button
  178. type="button"
  179. onClick={() => setActiveTab('access')}
  180. className={`whitespace-nowrap border-b-2 py-4 px-1 text-sm font-medium ${
  181. activeTab === 'access'
  182. ? 'border-primary-500 text-primary-600'
  183. : 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700'
  184. }`}
  185. >
  186. Workspace Access
  187. {memberships.length > 0 && (
  188. <span className="ml-2 rounded-full bg-gray-100 px-2 py-0.5 text-xs text-gray-600">
  189. {memberships.length}
  190. </span>
  191. )}
  192. </button>
  193. </nav>
  194. </div>
  195. {/* Content */}
  196. <form onSubmit={handleSubmit} className="flex flex-1 flex-col overflow-hidden">
  197. <div className="flex-1 overflow-y-auto px-6 py-4">
  198. {error && (
  199. <div className="mb-4 rounded-lg bg-red-50 px-4 py-3 text-sm text-red-700">{error}</div>
  200. )}
  201. {activeTab === 'basic' && (
  202. <div className="space-y-4">
  203. <Input
  204. label="Email"
  205. type="email"
  206. name="email"
  207. value={formData.email}
  208. onChange={(e) => setFormData({ ...formData, email: e.target.value })}
  209. placeholder="user@example.com"
  210. required
  211. disabled={!!user}
  212. autoFocus={!user}
  213. />
  214. <Input
  215. label="Name"
  216. name="name"
  217. value={formData.name}
  218. onChange={(e) => setFormData({ ...formData, name: e.target.value })}
  219. placeholder="Full name"
  220. autoFocus={!!user}
  221. />
  222. <Input
  223. label={user ? 'New Password (leave empty to keep current)' : 'Password'}
  224. type="password"
  225. name="password"
  226. value={formData.password}
  227. onChange={(e) => setFormData({ ...formData, password: e.target.value })}
  228. placeholder={user ? 'Leave empty to keep current password' : 'Enter password'}
  229. required={!user}
  230. />
  231. </div>
  232. )}
  233. {activeTab === 'access' && (
  234. <div>
  235. {membershipError && (
  236. <div className="mb-4 rounded-lg bg-red-50 px-4 py-3 text-sm text-red-700">
  237. {membershipError}
  238. </div>
  239. )}
  240. {isFetchingMemberships ? (
  241. <div className="flex items-center justify-center py-8">
  242. <svg
  243. className="h-6 w-6 animate-spin text-primary-600"
  244. fill="none"
  245. viewBox="0 0 24 24"
  246. >
  247. <circle
  248. className="opacity-25"
  249. cx="12"
  250. cy="12"
  251. r="10"
  252. stroke="currentColor"
  253. strokeWidth="4"
  254. />
  255. <path
  256. className="opacity-75"
  257. fill="currentColor"
  258. d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
  259. />
  260. </svg>
  261. </div>
  262. ) : (
  263. <WorkspaceMembershipEditor
  264. memberships={memberships}
  265. onChange={setMemberships}
  266. disabled={isLoading}
  267. />
  268. )}
  269. </div>
  270. )}
  271. </div>
  272. {/* Footer */}
  273. <div className="flex justify-end gap-3 border-t px-6 py-4">
  274. <Button type="button" variant="secondary" onClick={onClose} disabled={isLoading}>
  275. Cancel
  276. </Button>
  277. <Button type="submit" isLoading={isLoading}>
  278. {user ? 'Save Changes' : 'Create User'}
  279. </Button>
  280. </div>
  281. </form>
  282. </div>
  283. </div>
  284. )
  285. }
  286. function DeleteConfirmModal({
  287. user,
  288. onClose,
  289. onConfirm,
  290. }: {
  291. user: User
  292. onClose: () => void
  293. onConfirm: () => void
  294. }) {
  295. const [isLoading, setIsLoading] = useState(false)
  296. const [error, setError] = useState<string | null>(null)
  297. const handleDelete = async () => {
  298. setIsLoading(true)
  299. setError(null)
  300. try {
  301. await apiClient.delete(`/users/${user.id}`)
  302. onConfirm()
  303. } catch (err) {
  304. setError(err instanceof Error ? err.message : 'Failed to delete user')
  305. setIsLoading(false)
  306. }
  307. }
  308. return (
  309. <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
  310. <div className="w-full max-w-md rounded-lg bg-white p-6 shadow-xl">
  311. <h2 className="text-xl font-semibold text-gray-900">Delete User</h2>
  312. <p className="mt-2 text-gray-600">
  313. Are you sure you want to delete <strong>{user.name || user.email}</strong>? This action
  314. cannot be undone.
  315. </p>
  316. {error && (
  317. <div className="mt-4 rounded-lg bg-red-50 px-4 py-3 text-sm text-red-700">{error}</div>
  318. )}
  319. <div className="mt-6 flex justify-end gap-3">
  320. <Button type="button" variant="secondary" onClick={onClose} disabled={isLoading}>
  321. Cancel
  322. </Button>
  323. <Button type="button" variant="danger" onClick={handleDelete} isLoading={isLoading}>
  324. Delete User
  325. </Button>
  326. </div>
  327. </div>
  328. </div>
  329. )
  330. }
  331. function Users() {
  332. const [users, setUsers] = useState<User[]>([])
  333. const [filteredUsers, setFilteredUsers] = useState<User[]>([])
  334. const [isLoading, setIsLoading] = useState(true)
  335. const [error, setError] = useState<string | null>(null)
  336. const [searchQuery, setSearchQuery] = useState('')
  337. const [showCreateModal, setShowCreateModal] = useState(false)
  338. const [editingUser, setEditingUser] = useState<User | null>(null)
  339. const [deletingUser, setDeletingUser] = useState<User | null>(null)
  340. const [modalKey, setModalKey] = useState(0)
  341. const fetchUsers = useCallback(async () => {
  342. setIsLoading(true)
  343. setError(null)
  344. try {
  345. // Always fetch all users - this is a system-level user management page
  346. const response = await apiClient.get<{ users?: User[] }>('/users')
  347. setUsers(response.users || [])
  348. } catch (err) {
  349. setError(err instanceof Error ? err.message : 'Failed to fetch users')
  350. } finally {
  351. setIsLoading(false)
  352. }
  353. }, [])
  354. useEffect(() => {
  355. fetchUsers()
  356. }, [fetchUsers])
  357. useEffect(() => {
  358. const query = searchQuery.toLowerCase()
  359. setFilteredUsers(
  360. users.filter(
  361. (u) =>
  362. u.email.toLowerCase().includes(query) ||
  363. (u.name && u.name.toLowerCase().includes(query)) ||
  364. u.id.toLowerCase().includes(query)
  365. )
  366. )
  367. }, [users, searchQuery])
  368. const handleSave = () => {
  369. setShowCreateModal(false)
  370. setEditingUser(null)
  371. fetchUsers()
  372. }
  373. const handleDelete = () => {
  374. setDeletingUser(null)
  375. fetchUsers()
  376. }
  377. return (
  378. <div className="space-y-6">
  379. <div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
  380. <div>
  381. <h1 className="text-2xl font-bold text-gray-900">Users</h1>
  382. <p className="mt-1 text-gray-600">Manage all users in the system</p>
  383. </div>
  384. <Button onClick={() => {
  385. setShowCreateModal(true)
  386. setModalKey((k) => k + 1)
  387. }}>
  388. <svg className="-ml-1 mr-2 h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
  389. <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
  390. </svg>
  391. Create User
  392. </Button>
  393. </div>
  394. {/* Search */}
  395. <div className="max-w-md">
  396. <Input
  397. type="search"
  398. placeholder="Search users..."
  399. value={searchQuery}
  400. onChange={(e) => setSearchQuery(e.target.value)}
  401. />
  402. </div>
  403. {/* Error state */}
  404. {error && <div className="rounded-lg bg-red-50 px-4 py-3 text-sm text-red-700">{error}</div>}
  405. {/* Loading state */}
  406. {isLoading && (
  407. <div className="flex items-center justify-center py-12">
  408. <svg
  409. className="h-8 w-8 animate-spin text-primary-600"
  410. xmlns="http://www.w3.org/2000/svg"
  411. fill="none"
  412. viewBox="0 0 24 24"
  413. >
  414. <circle
  415. className="opacity-25"
  416. cx="12"
  417. cy="12"
  418. r="10"
  419. stroke="currentColor"
  420. strokeWidth="4"
  421. />
  422. <path
  423. className="opacity-75"
  424. fill="currentColor"
  425. d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
  426. />
  427. </svg>
  428. </div>
  429. )}
  430. {/* User list */}
  431. {!isLoading && !error && (
  432. <div className="overflow-hidden rounded-lg bg-white shadow">
  433. {filteredUsers.length === 0 ? (
  434. <div className="px-6 py-12 text-center">
  435. <p className="text-gray-500">
  436. {searchQuery ? 'No users match your search' : 'No users found'}
  437. </p>
  438. </div>
  439. ) : (
  440. <table className="min-w-full divide-y divide-gray-200">
  441. <thead className="bg-gray-50">
  442. <tr>
  443. <th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
  444. User
  445. </th>
  446. <th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
  447. ID
  448. </th>
  449. <th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
  450. Created
  451. </th>
  452. <th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
  453. Updated
  454. </th>
  455. <th className="px-6 py-3 text-right text-xs font-medium uppercase tracking-wider text-gray-500">
  456. Actions
  457. </th>
  458. </tr>
  459. </thead>
  460. <tbody className="divide-y divide-gray-200 bg-white">
  461. {filteredUsers.map((user) => (
  462. <tr key={user.id} className="hover:bg-gray-50">
  463. <td className="whitespace-nowrap px-6 py-4">
  464. <div className="flex items-center">
  465. <div className="flex h-10 w-10 items-center justify-center rounded-full bg-primary-100 text-primary-700">
  466. {user.name?.[0]?.toUpperCase() || user.email[0].toUpperCase()}
  467. </div>
  468. <div className="ml-4">
  469. <div className="font-medium text-gray-900">{user.name || 'No name'}</div>
  470. <div className="text-sm text-gray-500">{user.email}</div>
  471. </div>
  472. </div>
  473. </td>
  474. <td className="whitespace-nowrap px-6 py-4">
  475. <code className="rounded bg-gray-100 px-2 py-1 text-sm text-gray-600">
  476. {user.id.slice(0, 8)}...
  477. </code>
  478. </td>
  479. <td className="px-6 py-4 text-sm text-gray-500">
  480. <div>{formatDateTime(user.created_at)}</div>
  481. {user.created_by_name && (
  482. <div className="text-xs text-gray-400">by {user.created_by_name}</div>
  483. )}
  484. </td>
  485. <td className="px-6 py-4 text-sm text-gray-500">
  486. <div>{formatDateTime(user.updated_at)}</div>
  487. {user.updated_by_name && (
  488. <div className="text-xs text-gray-400">by {user.updated_by_name}</div>
  489. )}
  490. </td>
  491. <td className="whitespace-nowrap px-6 py-4 text-right">
  492. <div className="flex justify-end gap-2">
  493. <button
  494. onClick={() => {
  495. setEditingUser(user)
  496. setModalKey((k) => k + 1)
  497. }}
  498. className="rounded px-3 py-1 text-sm text-primary-600 hover:bg-primary-50"
  499. >
  500. Edit
  501. </button>
  502. <button
  503. onClick={() => setDeletingUser(user)}
  504. className="rounded px-3 py-1 text-sm text-red-600 hover:bg-red-50"
  505. >
  506. Delete
  507. </button>
  508. </div>
  509. </td>
  510. </tr>
  511. ))}
  512. </tbody>
  513. </table>
  514. )}
  515. </div>
  516. )}
  517. {/* Modals */}
  518. {showCreateModal && (
  519. <UserModal
  520. key={`create-${modalKey}`}
  521. user={null}
  522. onClose={() => setShowCreateModal(false)}
  523. onSave={handleSave}
  524. />
  525. )}
  526. {editingUser && (
  527. <UserModal
  528. key={`edit-${editingUser.id}-${modalKey}`}
  529. user={editingUser}
  530. onClose={() => setEditingUser(null)}
  531. onSave={handleSave}
  532. />
  533. )}
  534. {deletingUser && (
  535. <DeleteConfirmModal
  536. user={deletingUser}
  537. onClose={() => setDeletingUser(null)}
  538. onConfirm={handleDelete}
  539. />
  540. )}
  541. </div>
  542. )
  543. }
  544. export default Users