|
|
@@ -4,6 +4,7 @@ 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'
|
|
|
|
|
|
@@ -13,6 +14,14 @@ interface UserFormData {
|
|
|
password: string
|
|
|
}
|
|
|
|
|
|
+interface UserMembership {
|
|
|
+ id: string
|
|
|
+ workspace_id: string
|
|
|
+ workspace_name?: string
|
|
|
+ groups: string[]
|
|
|
+ group_names?: string[]
|
|
|
+}
|
|
|
+
|
|
|
function UserModal({
|
|
|
user,
|
|
|
onClose,
|
|
|
@@ -27,8 +36,46 @@ function UserModal({
|
|
|
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()
|
|
|
@@ -36,6 +83,8 @@ function UserModal({
|
|
|
setError(null)
|
|
|
|
|
|
try {
|
|
|
+ let userId = user?.id
|
|
|
+
|
|
|
if (user) {
|
|
|
// Update user - only send non-empty password
|
|
|
const updateData: Partial<UserFormData> = {
|
|
|
@@ -47,8 +96,15 @@ function UserModal({
|
|
|
await apiClient.patch(`/users/${user.id}`, updateData)
|
|
|
} else {
|
|
|
// Create user
|
|
|
- await apiClient.post('/users', formData)
|
|
|
+ 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')
|
|
|
@@ -57,50 +113,191 @@ function UserModal({
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+ 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">
|
|
|
- <div className="w-full max-w-md rounded-lg bg-white p-6 shadow-xl">
|
|
|
- <h2 className="text-xl font-semibold text-gray-900">
|
|
|
- {user ? 'Edit User' : 'Create User'}
|
|
|
- </h2>
|
|
|
+ <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>
|
|
|
|
|
|
- <form onSubmit={handleSubmit} className="mt-4 space-y-4">
|
|
|
- {error && (
|
|
|
- <div className="rounded-lg bg-red-50 px-4 py-3 text-sm text-red-700">{error}</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="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 className="flex justify-end gap-3 pt-4">
|
|
|
+ <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>
|
|
|
@@ -174,6 +371,7 @@ function Users() {
|
|
|
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)
|
|
|
@@ -223,7 +421,10 @@ function Users() {
|
|
|
<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)}>
|
|
|
+ <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>
|
|
|
@@ -334,7 +535,10 @@ function Users() {
|
|
|
<td className="whitespace-nowrap px-6 py-4 text-right">
|
|
|
<div className="flex justify-end gap-2">
|
|
|
<button
|
|
|
- onClick={() => setEditingUser(user)}
|
|
|
+ onClick={() => {
|
|
|
+ setEditingUser(user)
|
|
|
+ setModalKey((k) => k + 1)
|
|
|
+ }}
|
|
|
className="rounded px-3 py-1 text-sm text-primary-600 hover:bg-primary-50"
|
|
|
>
|
|
|
Edit
|
|
|
@@ -357,10 +561,20 @@ function Users() {
|
|
|
|
|
|
{/* Modals */}
|
|
|
{showCreateModal && (
|
|
|
- <UserModal user={null} onClose={() => setShowCreateModal(false)} onSave={handleSave} />
|
|
|
+ <UserModal
|
|
|
+ key={`create-${modalKey}`}
|
|
|
+ user={null}
|
|
|
+ onClose={() => setShowCreateModal(false)}
|
|
|
+ onSave={handleSave}
|
|
|
+ />
|
|
|
)}
|
|
|
{editingUser && (
|
|
|
- <UserModal user={editingUser} onClose={() => setEditingUser(null)} onSave={handleSave} />
|
|
|
+ <UserModal
|
|
|
+ key={`edit-${editingUser.id}-${modalKey}`}
|
|
|
+ user={editingUser}
|
|
|
+ onClose={() => setEditingUser(null)}
|
|
|
+ onSave={handleSave}
|
|
|
+ />
|
|
|
)}
|
|
|
{deletingUser && (
|
|
|
<DeleteConfirmModal
|