// 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({ email: user?.email || '', name: user?.name || '', password: '', }) const [memberships, setMemberships] = useState([]) const [originalMemberships, setOriginalMemberships] = useState([]) const [isLoading, setIsLoading] = useState(false) const [isFetchingMemberships, setIsFetchingMemberships] = useState(false) const [error, setError] = useState(null) const [membershipError, setMembershipError] = useState(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 = { 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 (
{/* Header */}

{user ? 'Edit User' : 'Create User'}

{/* Tabs */}
{/* Content */}
{error && (
{error}
)} {activeTab === 'basic' && (
setFormData({ ...formData, email: e.target.value })} placeholder="user@example.com" required disabled={!!user} autoFocus={!user} /> setFormData({ ...formData, name: e.target.value })} placeholder="Full name" autoFocus={!!user} /> setFormData({ ...formData, password: e.target.value })} placeholder={user ? 'Leave empty to keep current password' : 'Enter password'} required={!user} />
)} {activeTab === 'access' && (
{membershipError && (
{membershipError}
)} {isFetchingMemberships ? (
) : ( )}
)}
{/* Footer */}
) } function DeleteConfirmModal({ user, onClose, onConfirm, }: { user: User onClose: () => void onConfirm: () => void }) { const [isLoading, setIsLoading] = useState(false) const [error, setError] = useState(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 (

Delete User

Are you sure you want to delete {user.name || user.email}? This action cannot be undone.

{error && (
{error}
)}
) } function Users() { const [users, setUsers] = useState([]) const [filteredUsers, setFilteredUsers] = useState([]) const [isLoading, setIsLoading] = useState(true) const [error, setError] = useState(null) const [searchQuery, setSearchQuery] = useState('') const [showCreateModal, setShowCreateModal] = useState(false) const [editingUser, setEditingUser] = useState(null) const [deletingUser, setDeletingUser] = useState(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 (

Users

Manage all users in the system

{/* Search */}
setSearchQuery(e.target.value)} />
{/* Error state */} {error &&
{error}
} {/* Loading state */} {isLoading && (
)} {/* User list */} {!isLoading && !error && (
{filteredUsers.length === 0 ? (

{searchQuery ? 'No users match your search' : 'No users found'}

) : ( {filteredUsers.map((user) => ( ))}
User ID Created Updated Actions
{user.name?.[0]?.toUpperCase() || user.email[0].toUpperCase()}
{user.name || 'No name'}
{user.email}
{user.id.slice(0, 8)}...
{formatDateTime(user.created_at)}
{user.created_by_name && (
by {user.created_by_name}
)}
{formatDateTime(user.updated_at)}
{user.updated_by_name && (
by {user.updated_by_name}
)}
)}
)} {/* Modals */} {showCreateModal && ( setShowCreateModal(false)} onSave={handleSave} /> )} {editingUser && ( setEditingUser(null)} onSave={handleSave} /> )} {deletingUser && ( setDeletingUser(null)} onConfirm={handleDelete} /> )}
) } export default Users