Users.jsx
import * as React from 'react';
import { useState, useEffect, useCallback } from 'react';
import { apiGet, apiPost, apiPatch, apiDelete } from '../hooks/useAPI.js';
import { useChat } from '../layouts/AdminLayout.jsx';
export function Users() {
const { toggleChat } = useChat() || {};
const [users, setUsers] = useState(null);
const [loading, setLoading] = useState(true);
const [page, setPage] = useState(1);
const [showModal, setShowModal] = useState(false);
const [editUser, setEditUser] = useState(null);
const [saving, setSaving] = useState(false);
const [signupEnabled, setSignupEnabled] = useState(false);
const fetchUsers = useCallback(() => {
setLoading(true);
Promise.all([
apiGet(`/api/users?page=${page}`),
apiGet('/api/admin/settings'),
]).then(([usersData, settings]) => {
setUsers(usersData);
setSignupEnabled(settings?.signupEnabled || false);
}).finally(() => setLoading(false));
}, [page]);
useEffect(() => {
fetchUsers();
}, [fetchUsers]);
const toggleSignup = async () => {
const newValue = !signupEnabled;
setSignupEnabled(newValue);
try {
await apiPatch('/api/admin/settings', { signupEnabled: newValue });
} catch {
setSignupEnabled(!newValue); // Revert on error
}
};
const handleSubmit = async (e) => {
e.preventDefault();
const form = e.target;
const formData = new FormData(form);
setSaving(true);
try {
if (editUser) {
await apiPost(`/api/users/${editUser.id}`, {
name: formData.get('name'),
email: formData.get('email'),
role: formData.get('role'),
});
} else {
await apiPost('/api/users', {
name: formData.get('name'),
email: formData.get('email'),
password: formData.get('password'),
role: formData.get('role'),
});
}
setShowModal(false);
setEditUser(null);
form.reset();
fetchUsers();
} finally {
setSaving(false);
}
};
const openEdit = (user) => {
setEditUser(user);
setShowModal(true);
};
const openNew = () => {
setEditUser(null);
setShowModal(true);
};
const pagination = users?.pagination || { totalItems: 0, totalPages: 1, page: 1 };
return (
<div className="flex flex-col h-full">
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-base-300 bg-base-200/50">
<div>
<h2 className="font-semibold">Users</h2>
<p className="text-xs text-base-content/50">User accounts</p>
</div>
<div className="flex items-center gap-3">
{/* Signup Toggle */}
<label className="flex items-center gap-2 cursor-pointer">
<span className="text-sm text-base-content/70">Public signup</span>
<input
type="checkbox"
className="toggle toggle-sm toggle-primary"
checked={signupEnabled}
onChange={toggleSignup}
/>
</label>
<div className="w-px h-6 bg-base-300" />
<button onClick={openNew} className="btn btn-ghost btn-sm gap-1">
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 4v16m8-8H4" />
</svg>
User
</button>
{/* AI Assistant */}
{toggleChat && (
<button onClick={toggleChat} className="btn btn-soft btn-primary btn-sm gap-2">
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
</svg>
AI Assistant
</button>
)}
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-auto p-4">
{loading ? (
<div className="flex justify-center py-12">
<span className="loading loading-spinner loading-lg" />
</div>
) : users?.items?.length ? (
<div className="max-w-4xl mx-auto">
<div className="overflow-x-auto">
<table className="table table-zebra w-full">
<thead>
<tr>
<th>User</th>
<th>Role</th>
<th>Status</th>
<th>Created</th>
<th className="w-24"></th>
</tr>
</thead>
<tbody>
{users.items.map((user) => (
<tr key={user.id} className="hover">
<td>
<div className="flex items-center gap-3">
<div className={user.avatarUrl ? "avatar" : "avatar avatar-placeholder"}>
<div className="w-8 rounded-full bg-neutral text-neutral-content">
{user.avatarUrl ? (
<img src={user.avatarUrl} alt={user.name} />
) : (
<span className="text-xs">{(user.email || '?')[0].toUpperCase()}</span>
)}
</div>
</div>
<div>
<div className="font-medium">{user.name || user.email}</div>
{user.name && <div className="text-xs text-base-content/50">{user.email}</div>}
</div>
</div>
</td>
<td>
{user.role === 'admin' ? (
<span className="badge badge-primary badge-sm">Admin</span>
) : user.role === 'user' ? (
<span className="badge badge-ghost badge-sm">User</span>
) : (
<span className="badge badge-ghost badge-sm">{user.role}</span>
)}
</td>
<td>
{user.verified ? (
<span className="badge badge-success badge-sm gap-1">
<svg xmlns="http://www.w3.org/2000/svg" className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M5 13l4 4L19 7" />
</svg>
Verified
</span>
) : (
<span className="badge badge-warning badge-sm">Pending</span>
)}
</td>
<td className="text-sm text-base-content/50">
{new Date(user.created).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' })}
</td>
<td>
<div className="flex gap-1 justify-end">
<button onClick={() => openEdit(user)} className="btn btn-ghost btn-xs">
Edit
</button>
<button
onClick={async () => {
if (!confirm('Delete this user?')) return;
await apiDelete(`/api/users/${user.id}`);
fetchUsers();
}}
className="btn btn-ghost btn-xs text-error"
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Pagination */}
{pagination.totalPages > 1 && (
<div className="flex justify-center mt-6">
<div className="join">
{page > 1 ? (
<button onClick={() => setPage(page - 1)} className="join-item btn btn-sm">Previous</button>
) : (
<button className="join-item btn btn-sm btn-disabled">Previous</button>
)}
{Array.from({ length: pagination.totalPages }, (_, i) => i + 1).map((pageNum) => (
<button
key={pageNum}
onClick={() => setPage(pageNum)}
className={`join-item btn btn-sm ${pageNum === page ? 'btn-active' : ''}`}
>
{pageNum}
</button>
))}
{page < pagination.totalPages ? (
<button onClick={() => setPage(page + 1)} className="join-item btn btn-sm">Next</button>
) : (
<button className="join-item btn btn-sm btn-disabled">Next</button>
)}
</div>
</div>
)}
</div>
) : (
<div className="flex flex-col items-center justify-center h-full text-center px-6">
<div className="w-16 h-16 rounded-2xl bg-base-200 flex items-center justify-center mb-4">
<svg xmlns="http://www.w3.org/2000/svg" className="h-8 w-8 text-base-content/30" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.5" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
</div>
<h3 className="text-lg font-semibold mb-1">No users yet</h3>
<p className="text-base-content/50 text-sm mb-4">Create the first user to get started.</p>
<button onClick={openNew} className="btn btn-primary btn-sm">Create First User</button>
</div>
)}
</div>
{/* User Modal */}
{showModal && (
<dialog className="modal modal-open">
<div className="modal-box w-11/12 max-w-lg">
<button className="btn btn-sm btn-circle btn-ghost absolute right-2 top-2" onClick={() => { setShowModal(false); setEditUser(null); }}>✕</button>
<h3 className="font-bold text-lg mb-4">{editUser ? 'Edit User' : 'New User'}</h3>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="form-control">
<label className="floating-label">
<span>Name</span>
<input type="text" name="name" placeholder="Full Name" className="input input-bordered w-full" defaultValue={editUser?.name || ''} />
</label>
</div>
<div className="form-control">
<label className="floating-label">
<span>Email</span>
<input type="email" name="email" placeholder="email@example.com" className="input input-bordered w-full" required defaultValue={editUser?.email || ''} />
</label>
</div>
{!editUser && (
<div className="form-control">
<label className="floating-label">
<span>Password</span>
<input type="password" name="password" placeholder="Password" className="input input-bordered w-full" required />
</label>
</div>
)}
<div className="form-control">
<label className="label">
<span className="label-text text-xs">Role</span>
</label>
<select name="role" className="select select-bordered select-sm w-full" defaultValue={editUser?.role || 'user'}>
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
</div>
<div className="flex justify-end gap-2 pt-4">
<button type="button" className="btn btn-ghost btn-sm" onClick={() => { setShowModal(false); setEditUser(null); }}>Cancel</button>
<button type="submit" className="btn btn-primary btn-sm" disabled={saving}>
{saving ? <span className="loading loading-spinner loading-sm" /> : (editUser ? 'Save Changes' : 'Create User')}
</button>
</div>
</form>
</div>
<div className="modal-backdrop" onClick={() => { setShowModal(false); setEditUser(null); }} />
</dialog>
)}
</div>
);
}