GodSpeed / frontend /src /components /admin /AdminUserManagement.tsx
AdithyaVardan's picture
feat: add confluence/slack search tools, chat history, cloud Qdrant support, sync trigger fixes
68af3c5
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { apiFetch } from '@/lib/http'
import { UserInvite } from '@/types/settings'
import { LoadingSkeleton } from '../common/LoadingSkeleton'
interface WorkspaceUser {
id: string
email: string
name: string
role: string
is_active: boolean
}
export function AdminUserManagement() {
const qc = useQueryClient()
const [showAddForm, setShowAddForm] = useState(false)
const [formData, setFormData] = useState<UserInvite>({ email: '', name: '', role: 'engineer' })
const [inviteLink, setInviteLink] = useState<{ url: string; emailSent: boolean } | null>(null)
const [error, setError] = useState<string | null>(null)
const { data, isLoading } = useQuery({
queryKey: ['workspace-users'],
queryFn: async () => {
const res = await apiFetch('/api/workspace/users')
const json = await res.json()
return (json.users ?? []) as WorkspaceUser[]
},
})
const users = data ?? []
const { mutate: sendInvite, isPending: isSending } = useMutation({
mutationFn: async (input: UserInvite) => {
const res = await apiFetch('/api/auth/invite', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: input.email, name: input.name, role: input.role }),
})
if (!res.ok) {
const body = await res.json().catch(() => ({}))
throw new Error(body.detail ?? `Error ${res.status}`)
}
return res.json() as Promise<{ ok: boolean; email: string; invite_url: string; email_sent: boolean }>
},
onSuccess: (data) => {
setFormData({ email: '', name: '', role: 'engineer' })
setError(null)
setInviteLink({ url: data.invite_url, emailSent: data.email_sent })
qc.invalidateQueries({ queryKey: ['workspace-users'] })
},
onError: (err: Error) => {
setError(err.message)
},
})
const { mutate: removeUser } = useMutation({
mutationFn: async (userId: string) => {
const res = await apiFetch(`/api/workspace/users/${userId}`, { method: 'DELETE' })
if (!res.ok) throw new Error(`Error ${res.status}`)
},
onSuccess: () => qc.invalidateQueries({ queryKey: ['workspace-users'] }),
})
const handleInvite = () => {
if (!formData.email.trim() || !formData.name.trim()) return
setError(null)
setInviteLink(null as null)
sendInvite(formData)
}
return (
<div className="space-y-6 p-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h3 className="text-sm font-semibold">Workspace Users</h3>
<p className="mt-1 text-xs text-stone-500">{users.length} user{users.length !== 1 ? 's' : ''}</p>
</div>
<button
onClick={() => { setShowAddForm(!showAddForm); setInviteLink(null as null); setError(null) }}
className="rounded bg-brand px-3 py-1.5 text-sm font-medium text-white hover:bg-brand-dark"
>
+ Invite User
</button>
</div>
{/* Invite Form */}
{showAddForm && (
<div className="space-y-4 rounded-lg border border-blue-200 bg-blue-50 p-4 dark:border-blue-900 dark:bg-stone-900">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<div>
<label className="block text-xs font-medium text-stone-700 dark:text-stone-300">Name</label>
<input
type="text"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="Jane Smith"
className="mt-1 block w-full rounded border border-stone-300 bg-white px-3 py-2 text-sm text-stone-900 placeholder-stone-400 focus:border-brand focus:outline-none focus:ring-1 focus:ring-brand dark:border-stone-600 dark:bg-stone-800 dark:text-white"
/>
</div>
<div>
<label className="block text-xs font-medium text-stone-700 dark:text-stone-300">Email</label>
<input
type="email"
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
placeholder="jane@company.com"
className="mt-1 block w-full rounded border border-stone-300 bg-white px-3 py-2 text-sm text-stone-900 placeholder-stone-400 focus:border-brand focus:outline-none focus:ring-1 focus:ring-brand dark:border-stone-600 dark:bg-stone-800 dark:text-white"
/>
</div>
<div>
<label className="block text-xs font-medium text-stone-700 dark:text-stone-300">Role</label>
<select
value={formData.role}
onChange={(e) => setFormData({ ...formData, role: e.target.value as UserInvite['role'] })}
className="mt-1 block w-full rounded border border-stone-300 bg-white px-3 py-2 text-sm text-stone-900 focus:border-brand focus:outline-none focus:ring-1 focus:ring-brand dark:border-stone-600 dark:bg-stone-800 dark:text-white"
>
<option value="engineer">Engineer</option>
<option value="manager">Manager</option>
<option value="admin">Admin</option>
<option value="org_admin">Org Admin</option>
</select>
</div>
</div>
{error && (
<p className="rounded bg-red-100 px-3 py-2 text-xs text-red-700 dark:bg-red-900/30 dark:text-red-400">
{error}
</p>
)}
<div className="flex gap-2">
<button
onClick={handleInvite}
disabled={isSending || !formData.email.trim() || !formData.name.trim()}
className="rounded bg-brand px-3 py-1.5 text-sm font-medium text-white hover:bg-brand-dark disabled:opacity-50"
>
{isSending ? 'Sending...' : 'Send Invite'}
</button>
<button
onClick={() => { setShowAddForm(false); setInviteLink(null as null); setError(null) }}
className="rounded border border-stone-300 px-3 py-1.5 text-sm font-medium text-stone-700 hover:bg-stone-100 dark:border-stone-600 dark:text-stone-300 dark:hover:bg-stone-700"
>
Cancel
</button>
</div>
{inviteLink && (
<div className="rounded-lg border border-green-200 bg-green-50 p-3 dark:border-green-800 dark:bg-green-950/30">
<p className="mb-1 text-xs font-medium text-green-800 dark:text-green-300">
{inviteLink.emailSent
? 'Invite email sent! Share the link below as backup:'
: 'Email not configured — share this link directly with the user:'}
</p>
<div className="flex items-center gap-2">
<input
readOnly
value={inviteLink.url}
className="flex-1 rounded border border-green-300 bg-white px-2 py-1.5 text-xs font-mono text-stone-800 dark:border-green-700 dark:bg-stone-800 dark:text-stone-200"
/>
<button
onClick={() => navigator.clipboard.writeText(inviteLink.url)}
className="rounded border border-green-300 px-2 py-1.5 text-xs font-medium text-green-700 hover:bg-green-100 dark:border-green-700 dark:text-green-400 dark:hover:bg-green-900/40"
>
Copy
</button>
</div>
<p className="mt-1 text-xs text-green-700 dark:text-green-400">Expires in 7 days.</p>
</div>
)}
</div>
)}
{/* Users Table */}
<div>
{isLoading ? (
<LoadingSkeleton rows={5} className="h-10" />
) : users.length > 0 ? (
<div className="overflow-x-auto rounded-lg border border-stone-200 dark:border-stone-700">
<table className="w-full text-left text-sm">
<thead className="border-b border-stone-200 bg-stone-50 dark:border-stone-700 dark:bg-stone-800">
<tr>
<th className="px-4 py-2 font-semibold text-stone-700 dark:text-stone-300">Name</th>
<th className="px-4 py-2 font-semibold text-stone-700 dark:text-stone-300">Email</th>
<th className="px-4 py-2 font-semibold text-stone-700 dark:text-stone-300">Role</th>
<th className="px-4 py-2 font-semibold text-stone-700 dark:text-stone-300">Status</th>
<th className="px-4 py-2 text-right font-semibold text-stone-700 dark:text-stone-300">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-stone-200 dark:divide-stone-700">
{users.map((u) => (
<tr key={u.id} className="hover:bg-stone-50 dark:hover:bg-stone-800">
<td className="px-4 py-3 font-medium text-stone-900 dark:text-white">{u.name}</td>
<td className="px-4 py-3 text-stone-600 dark:text-stone-400">{u.email}</td>
<td className="px-4 py-3">
<span className="rounded-full bg-stone-200 px-2 py-0.5 text-xs font-medium text-stone-700 dark:bg-stone-700 dark:text-stone-300">
{u.role}
</span>
</td>
<td className="px-4 py-3">
<span className={`rounded-full px-2 py-0.5 text-xs font-medium ${
u.is_active
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
: 'bg-stone-100 text-stone-500 dark:bg-stone-700 dark:text-stone-400'
}`}>
{u.is_active ? 'Active' : 'Inactive'}
</span>
</td>
<td className="px-4 py-3 text-right">
<button
onClick={() => {
if (confirm(`Remove ${u.name} from workspace?`)) removeUser(u.id)
}}
className="text-xs font-medium text-stone-500 hover:text-red-600"
>
Remove
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="rounded-lg border-2 border-dashed border-stone-300 p-6 text-center dark:border-stone-600">
<p className="text-sm text-stone-500">No users yet</p>
<p className="mt-1 text-xs text-stone-400">Click "Invite User" to add your first team member</p>
</div>
)}
</div>
</div>
)
}