| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590 |
- // Users management page (US-030)
- import { useState, useEffect, useCallback } from 'react'
- import apiClient from '@/api/client'
- import Button from '@/components/Button'
- import Input from '@/components/Input'
- import { WorkspaceMembershipEditor } from '@/components/WorkspaceMembershipEditor'
- import type { User } from '@/types'
- import { formatDateTime } from '@/utils/format'
- interface UserFormData {
- email: string
- name: string
- password: string
- }
- interface UserMembership {
- id: string
- workspace_id: string
- workspace_name?: string
- groups: string[]
- group_names?: string[]
- }
- function UserModal({
- user,
- onClose,
- onSave,
- }: {
- user: User | null
- onClose: () => void
- onSave: () => void
- }) {
- const [formData, setFormData] = useState<UserFormData>({
- email: user?.email || '',
- name: user?.name || '',
- password: '',
- })
- const [memberships, setMemberships] = useState<UserMembership[]>([])
- const [originalMemberships, setOriginalMemberships] = useState<UserMembership[]>([])
- const [isLoading, setIsLoading] = useState(false)
- const [isFetchingMemberships, setIsFetchingMemberships] = useState(false)
- const [error, setError] = useState<string | null>(null)
- const [membershipError, setMembershipError] = useState<string | null>(null)
- const [activeTab, setActiveTab] = useState<'basic' | 'access'>('basic')
- // Fetch user memberships when editing
- useEffect(() => {
- if (!user) return
- let cancelled = false
- const fetchMemberships = async () => {
- setIsFetchingMemberships(true)
- setMembershipError(null)
- try {
- const response = await apiClient.get<{ memberships: UserMembership[] }>(
- `/users/${user.id}/memberships`
- )
- if (cancelled) return
- const fetchedMemberships = response.memberships || []
- setMemberships(fetchedMemberships)
- setOriginalMemberships(fetchedMemberships)
- } catch (err) {
- if (cancelled) return
- console.error('Failed to fetch memberships:', err)
- setMembershipError(err instanceof Error ? err.message : 'Failed to fetch memberships')
- } finally {
- if (!cancelled) {
- setIsFetchingMemberships(false)
- }
- }
- }
- fetchMemberships()
- return () => {
- cancelled = true
- }
- }, [user?.id])
- const handleSubmit = async (e: React.FormEvent) => {
- e.preventDefault()
- setIsLoading(true)
- setError(null)
- try {
- let userId = user?.id
- if (user) {
- // Update user - only send non-empty password
- const updateData: Partial<UserFormData> = {
- name: formData.name,
- }
- if (formData.password) {
- updateData.password = formData.password
- }
- await apiClient.patch(`/users/${user.id}`, updateData)
- } else {
- // Create user
- const createResponse = await apiClient.post<{ id: string }>('/users', formData)
- userId = createResponse.id
- }
- // Save membership changes
- if (userId) {
- await saveMembershipChanges(userId)
- }
- onSave()
- } catch (err) {
- setError(err instanceof Error ? err.message : 'Failed to save user')
- } finally {
- setIsLoading(false)
- }
- }
- const saveMembershipChanges = async (userId: string) => {
- const originalMap = new Map(originalMemberships.map((m) => [m.workspace_id, m]))
- const currentMap = new Map(memberships.map((m) => [m.workspace_id, m]))
- // Find added workspaces
- const added = memberships.filter((m) => !originalMap.has(m.workspace_id))
- // Find removed workspaces
- const removed = originalMemberships.filter((m) => !currentMap.has(m.workspace_id))
- // Find modified workspaces (group changes)
- const modified = memberships.filter((m) => {
- const original = originalMap.get(m.workspace_id)
- if (!original) return false
- const originalGroups = new Set(original.groups)
- const currentGroups = new Set(m.groups)
- if (originalGroups.size !== currentGroups.size) return true
- for (const g of originalGroups) {
- if (!currentGroups.has(g)) return true
- }
- return false
- })
- // Add new memberships
- for (const membership of added) {
- await apiClient.post(`/workspaces/${membership.workspace_id}/members`, {
- user_id: userId,
- group_ids: membership.groups,
- })
- }
- // Remove memberships
- for (const membership of removed) {
- await apiClient.delete(`/workspaces/${membership.workspace_id}/members/${userId}`)
- }
- // Update modified memberships
- for (const membership of modified) {
- await apiClient.put(`/workspaces/${membership.workspace_id}/members/${userId}`, {
- group_ids: membership.groups,
- })
- }
- }
- return (
- <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
- <div className="flex max-h-[90vh] w-full max-w-2xl flex-col rounded-lg bg-white shadow-xl">
- {/* Header */}
- <div className="flex items-center justify-between border-b px-6 py-4">
- <h2 className="text-xl font-semibold text-gray-900">
- {user ? 'Edit User' : 'Create User'}
- </h2>
- <button
- type="button"
- onClick={onClose}
- className="rounded-lg p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-500"
- >
- <svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
- <path
- strokeLinecap="round"
- strokeLinejoin="round"
- strokeWidth={2}
- d="M6 18L18 6M6 6l12 12"
- />
- </svg>
- </button>
- </div>
- {/* Tabs */}
- <div className="border-b px-6">
- <nav className="-mb-px flex space-x-8">
- <button
- type="button"
- onClick={() => setActiveTab('basic')}
- className={`whitespace-nowrap border-b-2 py-4 px-1 text-sm font-medium ${
- activeTab === 'basic'
- ? 'border-primary-500 text-primary-600'
- : 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700'
- }`}
- >
- Basic Info
- </button>
- <button
- type="button"
- onClick={() => setActiveTab('access')}
- className={`whitespace-nowrap border-b-2 py-4 px-1 text-sm font-medium ${
- activeTab === 'access'
- ? 'border-primary-500 text-primary-600'
- : 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700'
- }`}
- >
- Workspace Access
- {memberships.length > 0 && (
- <span className="ml-2 rounded-full bg-gray-100 px-2 py-0.5 text-xs text-gray-600">
- {memberships.length}
- </span>
- )}
- </button>
- </nav>
- </div>
- {/* Content */}
- <form onSubmit={handleSubmit} className="flex flex-1 flex-col overflow-hidden">
- <div className="flex-1 overflow-y-auto px-6 py-4">
- {error && (
- <div className="mb-4 rounded-lg bg-red-50 px-4 py-3 text-sm text-red-700">{error}</div>
- )}
- {activeTab === 'basic' && (
- <div className="space-y-4">
- <Input
- label="Email"
- type="email"
- name="email"
- value={formData.email}
- onChange={(e) => setFormData({ ...formData, email: e.target.value })}
- placeholder="user@example.com"
- required
- disabled={!!user}
- autoFocus={!user}
- />
- <Input
- label="Name"
- name="name"
- value={formData.name}
- onChange={(e) => setFormData({ ...formData, name: e.target.value })}
- placeholder="Full name"
- autoFocus={!!user}
- />
- <Input
- label={user ? 'New Password (leave empty to keep current)' : 'Password'}
- type="password"
- name="password"
- value={formData.password}
- onChange={(e) => setFormData({ ...formData, password: e.target.value })}
- placeholder={user ? 'Leave empty to keep current password' : 'Enter password'}
- required={!user}
- />
- </div>
- )}
- {activeTab === 'access' && (
- <div>
- {membershipError && (
- <div className="mb-4 rounded-lg bg-red-50 px-4 py-3 text-sm text-red-700">
- {membershipError}
- </div>
- )}
- {isFetchingMemberships ? (
- <div className="flex items-center justify-center py-8">
- <svg
- className="h-6 w-6 animate-spin text-primary-600"
- fill="none"
- viewBox="0 0 24 24"
- >
- <circle
- className="opacity-25"
- cx="12"
- cy="12"
- r="10"
- stroke="currentColor"
- strokeWidth="4"
- />
- <path
- className="opacity-75"
- fill="currentColor"
- 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"
- />
- </svg>
- </div>
- ) : (
- <WorkspaceMembershipEditor
- memberships={memberships}
- onChange={setMemberships}
- disabled={isLoading}
- />
- )}
- </div>
- )}
- </div>
- {/* Footer */}
- <div className="flex justify-end gap-3 border-t px-6 py-4">
- <Button type="button" variant="secondary" onClick={onClose} disabled={isLoading}>
- Cancel
- </Button>
- <Button type="submit" isLoading={isLoading}>
- {user ? 'Save Changes' : 'Create User'}
- </Button>
- </div>
- </form>
- </div>
- </div>
- )
- }
- function DeleteConfirmModal({
- user,
- onClose,
- onConfirm,
- }: {
- user: User
- onClose: () => void
- onConfirm: () => void
- }) {
- const [isLoading, setIsLoading] = useState(false)
- const [error, setError] = useState<string | null>(null)
- const handleDelete = async () => {
- setIsLoading(true)
- setError(null)
- try {
- await apiClient.delete(`/users/${user.id}`)
- onConfirm()
- } catch (err) {
- setError(err instanceof Error ? err.message : 'Failed to delete user')
- setIsLoading(false)
- }
- }
- return (
- <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
- <div className="w-full max-w-md rounded-lg bg-white p-6 shadow-xl">
- <h2 className="text-xl font-semibold text-gray-900">Delete User</h2>
- <p className="mt-2 text-gray-600">
- Are you sure you want to delete <strong>{user.name || user.email}</strong>? This action
- cannot be undone.
- </p>
- {error && (
- <div className="mt-4 rounded-lg bg-red-50 px-4 py-3 text-sm text-red-700">{error}</div>
- )}
- <div className="mt-6 flex justify-end gap-3">
- <Button type="button" variant="secondary" onClick={onClose} disabled={isLoading}>
- Cancel
- </Button>
- <Button type="button" variant="danger" onClick={handleDelete} isLoading={isLoading}>
- Delete User
- </Button>
- </div>
- </div>
- </div>
- )
- }
- function Users() {
- const [users, setUsers] = useState<User[]>([])
- const [filteredUsers, setFilteredUsers] = useState<User[]>([])
- const [isLoading, setIsLoading] = useState(true)
- const [error, setError] = useState<string | null>(null)
- const [searchQuery, setSearchQuery] = useState('')
- const [showCreateModal, setShowCreateModal] = useState(false)
- const [editingUser, setEditingUser] = useState<User | null>(null)
- const [deletingUser, setDeletingUser] = useState<User | null>(null)
- const [modalKey, setModalKey] = useState(0)
- const fetchUsers = useCallback(async () => {
- setIsLoading(true)
- setError(null)
- try {
- // Always fetch all users - this is a system-level user management page
- const response = await apiClient.get<{ users?: User[] }>('/users')
- setUsers(response.users || [])
- } catch (err) {
- setError(err instanceof Error ? err.message : 'Failed to fetch users')
- } finally {
- setIsLoading(false)
- }
- }, [])
- useEffect(() => {
- fetchUsers()
- }, [fetchUsers])
- useEffect(() => {
- const query = searchQuery.toLowerCase()
- setFilteredUsers(
- users.filter(
- (u) =>
- u.email.toLowerCase().includes(query) ||
- (u.name && u.name.toLowerCase().includes(query)) ||
- u.id.toLowerCase().includes(query)
- )
- )
- }, [users, searchQuery])
- const handleSave = () => {
- setShowCreateModal(false)
- setEditingUser(null)
- fetchUsers()
- }
- const handleDelete = () => {
- setDeletingUser(null)
- fetchUsers()
- }
- return (
- <div className="space-y-6">
- <div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
- <div>
- <h1 className="text-2xl font-bold text-gray-900">Users</h1>
- <p className="mt-1 text-gray-600">Manage all users in the system</p>
- </div>
- <Button onClick={() => {
- setShowCreateModal(true)
- setModalKey((k) => k + 1)
- }}>
- <svg className="-ml-1 mr-2 h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
- <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
- </svg>
- Create User
- </Button>
- </div>
- {/* Search */}
- <div className="max-w-md">
- <Input
- type="search"
- placeholder="Search users..."
- value={searchQuery}
- onChange={(e) => setSearchQuery(e.target.value)}
- />
- </div>
- {/* Error state */}
- {error && <div className="rounded-lg bg-red-50 px-4 py-3 text-sm text-red-700">{error}</div>}
- {/* Loading state */}
- {isLoading && (
- <div className="flex items-center justify-center py-12">
- <svg
- className="h-8 w-8 animate-spin text-primary-600"
- xmlns="http://www.w3.org/2000/svg"
- fill="none"
- viewBox="0 0 24 24"
- >
- <circle
- className="opacity-25"
- cx="12"
- cy="12"
- r="10"
- stroke="currentColor"
- strokeWidth="4"
- />
- <path
- className="opacity-75"
- fill="currentColor"
- 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"
- />
- </svg>
- </div>
- )}
- {/* User list */}
- {!isLoading && !error && (
- <div className="overflow-hidden rounded-lg bg-white shadow">
- {filteredUsers.length === 0 ? (
- <div className="px-6 py-12 text-center">
- <p className="text-gray-500">
- {searchQuery ? 'No users match your search' : 'No users found'}
- </p>
- </div>
- ) : (
- <table className="min-w-full divide-y divide-gray-200">
- <thead className="bg-gray-50">
- <tr>
- <th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
- User
- </th>
- <th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
- ID
- </th>
- <th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
- Created
- </th>
- <th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
- Updated
- </th>
- <th className="px-6 py-3 text-right text-xs font-medium uppercase tracking-wider text-gray-500">
- Actions
- </th>
- </tr>
- </thead>
- <tbody className="divide-y divide-gray-200 bg-white">
- {filteredUsers.map((user) => (
- <tr key={user.id} className="hover:bg-gray-50">
- <td className="whitespace-nowrap px-6 py-4">
- <div className="flex items-center">
- <div className="flex h-10 w-10 items-center justify-center rounded-full bg-primary-100 text-primary-700">
- {user.name?.[0]?.toUpperCase() || user.email[0].toUpperCase()}
- </div>
- <div className="ml-4">
- <div className="font-medium text-gray-900">{user.name || 'No name'}</div>
- <div className="text-sm text-gray-500">{user.email}</div>
- </div>
- </div>
- </td>
- <td className="whitespace-nowrap px-6 py-4">
- <code className="rounded bg-gray-100 px-2 py-1 text-sm text-gray-600">
- {user.id.slice(0, 8)}...
- </code>
- </td>
- <td className="px-6 py-4 text-sm text-gray-500">
- <div>{formatDateTime(user.created_at)}</div>
- {user.created_by_name && (
- <div className="text-xs text-gray-400">by {user.created_by_name}</div>
- )}
- </td>
- <td className="px-6 py-4 text-sm text-gray-500">
- <div>{formatDateTime(user.updated_at)}</div>
- {user.updated_by_name && (
- <div className="text-xs text-gray-400">by {user.updated_by_name}</div>
- )}
- </td>
- <td className="whitespace-nowrap px-6 py-4 text-right">
- <div className="flex justify-end gap-2">
- <button
- onClick={() => {
- setEditingUser(user)
- setModalKey((k) => k + 1)
- }}
- className="rounded px-3 py-1 text-sm text-primary-600 hover:bg-primary-50"
- >
- Edit
- </button>
- <button
- onClick={() => setDeletingUser(user)}
- className="rounded px-3 py-1 text-sm text-red-600 hover:bg-red-50"
- >
- Delete
- </button>
- </div>
- </td>
- </tr>
- ))}
- </tbody>
- </table>
- )}
- </div>
- )}
- {/* Modals */}
- {showCreateModal && (
- <UserModal
- key={`create-${modalKey}`}
- user={null}
- onClose={() => setShowCreateModal(false)}
- onSave={handleSave}
- />
- )}
- {editingUser && (
- <UserModal
- key={`edit-${editingUser.id}-${modalKey}`}
- user={editingUser}
- onClose={() => setEditingUser(null)}
- onSave={handleSave}
- />
- )}
- {deletingUser && (
- <DeleteConfirmModal
- user={deletingUser}
- onClose={() => setDeletingUser(null)}
- onConfirm={handleDelete}
- />
- )}
- </div>
- )
- }
- export default Users
|