195 lines
8.1 KiB
TypeScript
195 lines
8.1 KiB
TypeScript
import { useEffect, useState } from 'react'
|
|
import { apiRequest, listUsers, createUser, deleteUser, toggleUserActive } from '../api/client'
|
|
import { Plus, Trash2, UserCheck, UserX } from 'lucide-react'
|
|
|
|
interface UserRecord {
|
|
id: number
|
|
username: string
|
|
email: string
|
|
is_active: boolean
|
|
is_superuser: boolean
|
|
}
|
|
|
|
export function UsersPage() {
|
|
const [users, setUsers] = useState<UserRecord[]>([])
|
|
const [loading, setLoading] = useState(true)
|
|
const [error, setError] = useState('')
|
|
const [showCreate, setShowCreate] = useState(false)
|
|
const [creating, setCreating] = useState(false)
|
|
const [createError, setCreateError] = useState('')
|
|
const [currentUserId, setCurrentUserId] = useState<number | null>(null)
|
|
|
|
const loadUsers = () => {
|
|
setLoading(true)
|
|
listUsers()
|
|
.then((data) => {
|
|
setUsers(data)
|
|
apiRequest<{ id: number }>('/auth/me').then((me) => setCurrentUserId(me.id))
|
|
})
|
|
.catch((err) => setError(err.message))
|
|
.finally(() => setLoading(false))
|
|
}
|
|
|
|
useEffect(() => {
|
|
loadUsers()
|
|
}, [])
|
|
|
|
const handleCreate = (e: React.FormEvent<HTMLFormElement>) => {
|
|
e.preventDefault()
|
|
const form = e.currentTarget
|
|
const username = (form.elements.namedItem('username') as HTMLInputElement).value.trim()
|
|
const password = (form.elements.namedItem('password') as HTMLInputElement).value
|
|
const email = (form.elements.namedItem('email') as HTMLInputElement).value.trim()
|
|
|
|
if (!username || username.length < 2) {
|
|
setCreateError('Username must be at least 2 characters')
|
|
return
|
|
}
|
|
if (!password || password.length < 6) {
|
|
setCreateError('Password must be at least 6 characters')
|
|
return
|
|
}
|
|
setCreating(true)
|
|
setCreateError('')
|
|
createUser({ username, password, email })
|
|
.then(() => {
|
|
setShowCreate(false)
|
|
form.reset()
|
|
loadUsers()
|
|
})
|
|
.catch((err) => setCreateError(err.message))
|
|
.finally(() => setCreating(false))
|
|
}
|
|
|
|
const handleDelete = (id: number, username: string) => {
|
|
if (!confirm(`Delete user "${username}"?`)) return
|
|
deleteUser(id)
|
|
.then(loadUsers)
|
|
.catch((err) => setError(err.message))
|
|
}
|
|
|
|
const handleToggleActive = (id: number) => {
|
|
toggleUserActive(id)
|
|
.then(loadUsers)
|
|
.catch((err) => setError(err.message))
|
|
}
|
|
|
|
if (loading && users.length === 0) return <div className="text-gray-500">Loading...</div>
|
|
if (error) return <div className="p-4 rounded bg-red-100 text-red-700">{error}</div>
|
|
|
|
return (
|
|
<div>
|
|
<div className="flex justify-between items-center mb-4">
|
|
<h1 className="text-2xl font-bold text-gray-800 dark:text-white">Users</h1>
|
|
<button
|
|
onClick={() => setShowCreate(true)}
|
|
className="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg font-medium"
|
|
>
|
|
<Plus className="w-4 h-4" />
|
|
Add User
|
|
</button>
|
|
</div>
|
|
|
|
<div className="bg-white dark:bg-gray-800 rounded-lg overflow-hidden">
|
|
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
|
<thead className="bg-gray-50 dark:bg-gray-700">
|
|
<tr>
|
|
<th className="px-4 py-2 text-left text-sm font-medium text-gray-700 dark:text-gray-300">Username</th>
|
|
<th className="px-4 py-2 text-left text-sm font-medium text-gray-700 dark:text-gray-300">Email</th>
|
|
<th className="px-4 py-2 text-left text-sm font-medium text-gray-700 dark:text-gray-300">Status</th>
|
|
<th className="px-4 py-2 text-left text-sm font-medium text-gray-700 dark:text-gray-300">Role</th>
|
|
<th className="px-4 py-2 text-right text-sm font-medium text-gray-700 dark:text-gray-300">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
|
{users.map((u) => (
|
|
<tr key={u.id}>
|
|
<td className="px-4 py-2 text-gray-900 dark:text-white">{u.username}</td>
|
|
<td className="px-4 py-2 text-gray-600 dark:text-gray-400">{u.email || '-'}</td>
|
|
<td className="px-4 py-2 text-gray-600 dark:text-gray-400">
|
|
<span className={u.is_active ? 'text-green-600' : 'text-gray-500'}>{u.is_active ? 'Active' : 'Inactive'}</span>
|
|
</td>
|
|
<td className="px-4 py-2 text-gray-600 dark:text-gray-400">{u.is_superuser ? 'Admin' : 'User'}</td>
|
|
<td className="px-4 py-2 text-right">
|
|
<span className="flex gap-1 justify-end">
|
|
{u.id !== currentUserId && (
|
|
<>
|
|
<button
|
|
onClick={() => handleToggleActive(u.id)}
|
|
className={`p-2 rounded ${u.is_active ? 'text-amber-600 hover:bg-amber-50 dark:hover:bg-amber-900/20' : 'text-green-600 hover:bg-green-50 dark:hover:bg-green-900/20'}`}
|
|
title={u.is_active ? 'Deactivate' : 'Activate'}
|
|
>
|
|
{u.is_active ? <UserX className="w-4 h-4" /> : <UserCheck className="w-4 h-4" />}
|
|
</button>
|
|
<button
|
|
onClick={() => handleDelete(u.id, u.username)}
|
|
className="p-2 text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 rounded"
|
|
title="Delete"
|
|
>
|
|
<Trash2 className="w-4 h-4" />
|
|
</button>
|
|
</>
|
|
)}
|
|
</span>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
{showCreate && (
|
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl p-6 w-full max-w-md">
|
|
<h2 className="text-xl font-bold mb-4 text-gray-800 dark:text-white">Add User</h2>
|
|
<form onSubmit={handleCreate} className="space-y-4">
|
|
{createError && (
|
|
<div className="p-2 rounded bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300 text-sm">{createError}</div>
|
|
)}
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Username</label>
|
|
<input
|
|
name="username"
|
|
type="text"
|
|
placeholder="newuser"
|
|
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700"
|
|
required
|
|
minLength={2}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Password</label>
|
|
<input
|
|
name="password"
|
|
type="password"
|
|
placeholder="••••••••"
|
|
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700"
|
|
required
|
|
minLength={6}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Email (optional)</label>
|
|
<input
|
|
name="email"
|
|
type="email"
|
|
placeholder="user@example.com"
|
|
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700"
|
|
/>
|
|
</div>
|
|
<div className="flex gap-2 justify-end pt-2">
|
|
<button type="button" onClick={() => setShowCreate(false)} className="px-4 py-2 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700">
|
|
Cancel
|
|
</button>
|
|
<button type="submit" disabled={creating} className="px-4 py-2 bg-blue-600 text-white rounded-lg disabled:opacity-50">
|
|
{creating ? 'Creating...' : 'Create'}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|