Users.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376
  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 type { User } from '@/types'
  7. import { formatDateTime } from '@/utils/format'
  8. interface UserFormData {
  9. email: string
  10. name: string
  11. password: string
  12. }
  13. function UserModal({
  14. user,
  15. onClose,
  16. onSave,
  17. }: {
  18. user: User | null
  19. onClose: () => void
  20. onSave: () => void
  21. }) {
  22. const [formData, setFormData] = useState<UserFormData>({
  23. email: user?.email || '',
  24. name: user?.name || '',
  25. password: '',
  26. })
  27. const [isLoading, setIsLoading] = useState(false)
  28. const [error, setError] = useState<string | null>(null)
  29. const handleSubmit = async (e: React.FormEvent) => {
  30. e.preventDefault()
  31. setIsLoading(true)
  32. setError(null)
  33. try {
  34. if (user) {
  35. // Update user - only send non-empty password
  36. const updateData: Partial<UserFormData> = {
  37. name: formData.name,
  38. }
  39. if (formData.password) {
  40. updateData.password = formData.password
  41. }
  42. await apiClient.patch(`/users/${user.id}`, updateData)
  43. } else {
  44. // Create user
  45. await apiClient.post('/users', formData)
  46. }
  47. onSave()
  48. } catch (err) {
  49. setError(err instanceof Error ? err.message : 'Failed to save user')
  50. } finally {
  51. setIsLoading(false)
  52. }
  53. }
  54. return (
  55. <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
  56. <div className="w-full max-w-md rounded-lg bg-white p-6 shadow-xl">
  57. <h2 className="text-xl font-semibold text-gray-900">
  58. {user ? 'Edit User' : 'Create User'}
  59. </h2>
  60. <form onSubmit={handleSubmit} className="mt-4 space-y-4">
  61. {error && (
  62. <div className="rounded-lg bg-red-50 px-4 py-3 text-sm text-red-700">{error}</div>
  63. )}
  64. <Input
  65. label="Email"
  66. type="email"
  67. name="email"
  68. value={formData.email}
  69. onChange={(e) => setFormData({ ...formData, email: e.target.value })}
  70. placeholder="user@example.com"
  71. required
  72. disabled={!!user}
  73. autoFocus={!user}
  74. />
  75. <Input
  76. label="Name"
  77. name="name"
  78. value={formData.name}
  79. onChange={(e) => setFormData({ ...formData, name: e.target.value })}
  80. placeholder="Full name"
  81. autoFocus={!!user}
  82. />
  83. <Input
  84. label={user ? 'New Password (leave empty to keep current)' : 'Password'}
  85. type="password"
  86. name="password"
  87. value={formData.password}
  88. onChange={(e) => setFormData({ ...formData, password: e.target.value })}
  89. placeholder={user ? 'Leave empty to keep current password' : 'Enter password'}
  90. required={!user}
  91. />
  92. <div className="flex justify-end gap-3 pt-4">
  93. <Button type="button" variant="secondary" onClick={onClose} disabled={isLoading}>
  94. Cancel
  95. </Button>
  96. <Button type="submit" isLoading={isLoading}>
  97. {user ? 'Save Changes' : 'Create User'}
  98. </Button>
  99. </div>
  100. </form>
  101. </div>
  102. </div>
  103. )
  104. }
  105. function DeleteConfirmModal({
  106. user,
  107. onClose,
  108. onConfirm,
  109. }: {
  110. user: User
  111. onClose: () => void
  112. onConfirm: () => void
  113. }) {
  114. const [isLoading, setIsLoading] = useState(false)
  115. const [error, setError] = useState<string | null>(null)
  116. const handleDelete = async () => {
  117. setIsLoading(true)
  118. setError(null)
  119. try {
  120. await apiClient.delete(`/users/${user.id}`)
  121. onConfirm()
  122. } catch (err) {
  123. setError(err instanceof Error ? err.message : 'Failed to delete user')
  124. setIsLoading(false)
  125. }
  126. }
  127. return (
  128. <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
  129. <div className="w-full max-w-md rounded-lg bg-white p-6 shadow-xl">
  130. <h2 className="text-xl font-semibold text-gray-900">Delete User</h2>
  131. <p className="mt-2 text-gray-600">
  132. Are you sure you want to delete <strong>{user.name || user.email}</strong>? This action
  133. cannot be undone.
  134. </p>
  135. {error && (
  136. <div className="mt-4 rounded-lg bg-red-50 px-4 py-3 text-sm text-red-700">{error}</div>
  137. )}
  138. <div className="mt-6 flex justify-end gap-3">
  139. <Button type="button" variant="secondary" onClick={onClose} disabled={isLoading}>
  140. Cancel
  141. </Button>
  142. <Button type="button" variant="danger" onClick={handleDelete} isLoading={isLoading}>
  143. Delete User
  144. </Button>
  145. </div>
  146. </div>
  147. </div>
  148. )
  149. }
  150. function Users() {
  151. const [users, setUsers] = useState<User[]>([])
  152. const [filteredUsers, setFilteredUsers] = useState<User[]>([])
  153. const [isLoading, setIsLoading] = useState(true)
  154. const [error, setError] = useState<string | null>(null)
  155. const [searchQuery, setSearchQuery] = useState('')
  156. const [showCreateModal, setShowCreateModal] = useState(false)
  157. const [editingUser, setEditingUser] = useState<User | null>(null)
  158. const [deletingUser, setDeletingUser] = useState<User | null>(null)
  159. const fetchUsers = useCallback(async () => {
  160. setIsLoading(true)
  161. setError(null)
  162. try {
  163. // Always fetch all users - this is a system-level user management page
  164. const response = await apiClient.get<{ users?: User[] }>('/users')
  165. setUsers(response.users || [])
  166. } catch (err) {
  167. setError(err instanceof Error ? err.message : 'Failed to fetch users')
  168. } finally {
  169. setIsLoading(false)
  170. }
  171. }, [])
  172. useEffect(() => {
  173. fetchUsers()
  174. }, [fetchUsers])
  175. useEffect(() => {
  176. const query = searchQuery.toLowerCase()
  177. setFilteredUsers(
  178. users.filter(
  179. (u) =>
  180. u.email.toLowerCase().includes(query) ||
  181. (u.name && u.name.toLowerCase().includes(query)) ||
  182. u.id.toLowerCase().includes(query)
  183. )
  184. )
  185. }, [users, searchQuery])
  186. const handleSave = () => {
  187. setShowCreateModal(false)
  188. setEditingUser(null)
  189. fetchUsers()
  190. }
  191. const handleDelete = () => {
  192. setDeletingUser(null)
  193. fetchUsers()
  194. }
  195. return (
  196. <div className="space-y-6">
  197. <div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
  198. <div>
  199. <h1 className="text-2xl font-bold text-gray-900">Users</h1>
  200. <p className="mt-1 text-gray-600">Manage all users in the system</p>
  201. </div>
  202. <Button onClick={() => setShowCreateModal(true)}>
  203. <svg className="-ml-1 mr-2 h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
  204. <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
  205. </svg>
  206. Create User
  207. </Button>
  208. </div>
  209. {/* Search */}
  210. <div className="max-w-md">
  211. <Input
  212. type="search"
  213. placeholder="Search users..."
  214. value={searchQuery}
  215. onChange={(e) => setSearchQuery(e.target.value)}
  216. />
  217. </div>
  218. {/* Error state */}
  219. {error && <div className="rounded-lg bg-red-50 px-4 py-3 text-sm text-red-700">{error}</div>}
  220. {/* Loading state */}
  221. {isLoading && (
  222. <div className="flex items-center justify-center py-12">
  223. <svg
  224. className="h-8 w-8 animate-spin text-primary-600"
  225. xmlns="http://www.w3.org/2000/svg"
  226. fill="none"
  227. viewBox="0 0 24 24"
  228. >
  229. <circle
  230. className="opacity-25"
  231. cx="12"
  232. cy="12"
  233. r="10"
  234. stroke="currentColor"
  235. strokeWidth="4"
  236. />
  237. <path
  238. className="opacity-75"
  239. fill="currentColor"
  240. 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"
  241. />
  242. </svg>
  243. </div>
  244. )}
  245. {/* User list */}
  246. {!isLoading && !error && (
  247. <div className="overflow-hidden rounded-lg bg-white shadow">
  248. {filteredUsers.length === 0 ? (
  249. <div className="px-6 py-12 text-center">
  250. <p className="text-gray-500">
  251. {searchQuery ? 'No users match your search' : 'No users found'}
  252. </p>
  253. </div>
  254. ) : (
  255. <table className="min-w-full divide-y divide-gray-200">
  256. <thead className="bg-gray-50">
  257. <tr>
  258. <th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
  259. User
  260. </th>
  261. <th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
  262. ID
  263. </th>
  264. <th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
  265. Created
  266. </th>
  267. <th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
  268. Updated
  269. </th>
  270. <th className="px-6 py-3 text-right text-xs font-medium uppercase tracking-wider text-gray-500">
  271. Actions
  272. </th>
  273. </tr>
  274. </thead>
  275. <tbody className="divide-y divide-gray-200 bg-white">
  276. {filteredUsers.map((user) => (
  277. <tr key={user.id} className="hover:bg-gray-50">
  278. <td className="whitespace-nowrap px-6 py-4">
  279. <div className="flex items-center">
  280. <div className="flex h-10 w-10 items-center justify-center rounded-full bg-primary-100 text-primary-700">
  281. {user.name?.[0]?.toUpperCase() || user.email[0].toUpperCase()}
  282. </div>
  283. <div className="ml-4">
  284. <div className="font-medium text-gray-900">{user.name || 'No name'}</div>
  285. <div className="text-sm text-gray-500">{user.email}</div>
  286. </div>
  287. </div>
  288. </td>
  289. <td className="whitespace-nowrap px-6 py-4">
  290. <code className="rounded bg-gray-100 px-2 py-1 text-sm text-gray-600">
  291. {user.id.slice(0, 8)}...
  292. </code>
  293. </td>
  294. <td className="px-6 py-4 text-sm text-gray-500">
  295. <div>{formatDateTime(user.created_at)}</div>
  296. {user.created_by_name && (
  297. <div className="text-xs text-gray-400">by {user.created_by_name}</div>
  298. )}
  299. </td>
  300. <td className="px-6 py-4 text-sm text-gray-500">
  301. <div>{formatDateTime(user.updated_at)}</div>
  302. {user.updated_by_name && (
  303. <div className="text-xs text-gray-400">by {user.updated_by_name}</div>
  304. )}
  305. </td>
  306. <td className="whitespace-nowrap px-6 py-4 text-right">
  307. <div className="flex justify-end gap-2">
  308. <button
  309. onClick={() => setEditingUser(user)}
  310. className="rounded px-3 py-1 text-sm text-primary-600 hover:bg-primary-50"
  311. >
  312. Edit
  313. </button>
  314. <button
  315. onClick={() => setDeletingUser(user)}
  316. className="rounded px-3 py-1 text-sm text-red-600 hover:bg-red-50"
  317. >
  318. Delete
  319. </button>
  320. </div>
  321. </td>
  322. </tr>
  323. ))}
  324. </tbody>
  325. </table>
  326. )}
  327. </div>
  328. )}
  329. {/* Modals */}
  330. {showCreateModal && (
  331. <UserModal user={null} onClose={() => setShowCreateModal(false)} onSave={handleSave} />
  332. )}
  333. {editingUser && (
  334. <UserModal user={editingUser} onClose={() => setEditingUser(null)} onSave={handleSave} />
  335. )}
  336. {deletingUser && (
  337. <DeleteConfirmModal
  338. user={deletingUser}
  339. onClose={() => setDeletingUser(null)}
  340. onConfirm={handleDelete}
  341. />
  342. )}
  343. </div>
  344. )
  345. }
  346. export default Users