Add edit user details on admin users page

- Backend: extend PUT /api/users/:id with email and accountStatus; admin-only for role/email/accountStatus; return isClaimed, rucNumber, accountStatus in user responses
- Frontend: add Edit button and modal on /admin/users to edit name, email, phone, role, language preference, account status

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Michilis
2026-02-12 03:17:30 +00:00
parent fe75912f23
commit 6a807a7cc6
2 changed files with 171 additions and 2 deletions

View File

@@ -17,9 +17,11 @@ const usersRouter = new Hono<{ Variables: { user: UserContext } }>();
const updateUserSchema = z.object({ const updateUserSchema = z.object({
name: z.string().min(2).optional(), name: z.string().min(2).optional(),
email: z.string().email().optional(),
phone: z.string().optional(), phone: z.string().optional(),
role: z.enum(['admin', 'organizer', 'staff', 'marketing', 'user']).optional(), role: z.enum(['admin', 'organizer', 'staff', 'marketing', 'user']).optional(),
languagePreference: z.enum(['en', 'es']).optional(), languagePreference: z.enum(['en', 'es']).optional(),
accountStatus: z.enum(['active', 'unclaimed', 'suspended']).optional(),
}); });
// Get all users (admin only) // Get all users (admin only)
@@ -33,6 +35,9 @@ usersRouter.get('/', requireAuth(['admin']), async (c) => {
phone: (users as any).phone, phone: (users as any).phone,
role: (users as any).role, role: (users as any).role,
languagePreference: (users as any).languagePreference, languagePreference: (users as any).languagePreference,
isClaimed: (users as any).isClaimed,
rucNumber: (users as any).rucNumber,
accountStatus: (users as any).accountStatus,
createdAt: (users as any).createdAt, createdAt: (users as any).createdAt,
}).from(users); }).from(users);
@@ -64,6 +69,9 @@ usersRouter.get('/:id', requireAuth(['admin', 'organizer', 'staff', 'marketing',
phone: (users as any).phone, phone: (users as any).phone,
role: (users as any).role, role: (users as any).role,
languagePreference: (users as any).languagePreference, languagePreference: (users as any).languagePreference,
isClaimed: (users as any).isClaimed,
rucNumber: (users as any).rucNumber,
accountStatus: (users as any).accountStatus,
createdAt: (users as any).createdAt, createdAt: (users as any).createdAt,
}) })
.from(users) .from(users)
@@ -88,10 +96,16 @@ usersRouter.put('/:id', requireAuth(['admin', 'organizer', 'staff', 'marketing',
return c.json({ error: 'Forbidden' }, 403); return c.json({ error: 'Forbidden' }, 403);
} }
// Only admin can change roles // Only admin can change roles, email, and account status
if (data.role && currentUser.role !== 'admin') { if (data.role && currentUser.role !== 'admin') {
delete data.role; delete data.role;
} }
if (data.email && currentUser.role !== 'admin') {
delete data.email;
}
if (data.accountStatus && currentUser.role !== 'admin') {
delete data.accountStatus;
}
const existing = await dbGet( const existing = await dbGet(
(db as any).select().from(users).where(eq((users as any).id, id)) (db as any).select().from(users).where(eq((users as any).id, id))
@@ -114,6 +128,10 @@ usersRouter.put('/:id', requireAuth(['admin', 'organizer', 'staff', 'marketing',
phone: (users as any).phone, phone: (users as any).phone,
role: (users as any).role, role: (users as any).role,
languagePreference: (users as any).languagePreference, languagePreference: (users as any).languagePreference,
isClaimed: (users as any).isClaimed,
rucNumber: (users as any).rucNumber,
accountStatus: (users as any).accountStatus,
createdAt: (users as any).createdAt,
}) })
.from(users) .from(users)
.where(eq((users as any).id, id)) .where(eq((users as any).id, id))

View File

@@ -5,7 +5,8 @@ import { useLanguage } from '@/context/LanguageContext';
import { usersApi, User } from '@/lib/api'; import { usersApi, User } from '@/lib/api';
import Card from '@/components/ui/Card'; import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button'; import Button from '@/components/ui/Button';
import { TrashIcon } from '@heroicons/react/24/outline'; import Input from '@/components/ui/Input';
import { TrashIcon, PencilSquareIcon } from '@heroicons/react/24/outline';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
export default function AdminUsersPage() { export default function AdminUsersPage() {
@@ -13,6 +14,16 @@ export default function AdminUsersPage() {
const [users, setUsers] = useState<User[]>([]); const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [roleFilter, setRoleFilter] = useState<string>(''); const [roleFilter, setRoleFilter] = useState<string>('');
const [editingUser, setEditingUser] = useState<User | null>(null);
const [editForm, setEditForm] = useState({
name: '',
email: '',
phone: '',
role: '' as User['role'],
languagePreference: '' as string,
accountStatus: '' as string,
});
const [saving, setSaving] = useState(false);
useEffect(() => { useEffect(() => {
loadUsers(); loadUsers();
@@ -51,6 +62,51 @@ export default function AdminUsersPage() {
} }
}; };
const openEditModal = (user: User) => {
setEditingUser(user);
setEditForm({
name: user.name,
email: user.email,
phone: user.phone || '',
role: user.role,
languagePreference: user.languagePreference || '',
accountStatus: user.accountStatus || 'active',
});
};
const handleEditSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!editingUser) return;
if (!editForm.name.trim() || editForm.name.trim().length < 2) {
toast.error('Name must be at least 2 characters');
return;
}
if (!editForm.email.trim()) {
toast.error('Email is required');
return;
}
setSaving(true);
try {
await usersApi.update(editingUser.id, {
name: editForm.name.trim(),
email: editForm.email.trim(),
phone: editForm.phone.trim() || undefined,
role: editForm.role,
languagePreference: editForm.languagePreference || undefined,
accountStatus: editForm.accountStatus || undefined,
} as Partial<User>);
toast.success('User updated successfully');
setEditingUser(null);
loadUsers();
} catch (error: any) {
toast.error(error.message || 'Failed to update user');
} finally {
setSaving(false);
}
};
const formatDate = (dateStr: string) => { const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', { return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
year: 'numeric', year: 'numeric',
@@ -162,6 +218,13 @@ export default function AdminUsersPage() {
</td> </td>
<td className="px-6 py-4"> <td className="px-6 py-4">
<div className="flex items-center justify-end gap-2"> <div className="flex items-center justify-end gap-2">
<button
onClick={() => openEditModal(user)}
className="p-2 hover:bg-blue-100 text-blue-600 rounded-btn"
title="Edit"
>
<PencilSquareIcon className="w-4 h-4" />
</button>
<button <button
onClick={() => handleDelete(user.id)} onClick={() => handleDelete(user.id)}
className="p-2 hover:bg-red-100 text-red-600 rounded-btn" className="p-2 hover:bg-red-100 text-red-600 rounded-btn"
@@ -178,6 +241,94 @@ export default function AdminUsersPage() {
</table> </table>
</div> </div>
</Card> </Card>
{/* Edit User Modal */}
{editingUser && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<Card className="w-full max-w-lg max-h-[90vh] overflow-y-auto p-6">
<h2 className="text-xl font-bold text-primary-dark mb-6">Edit User</h2>
<form onSubmit={handleEditSubmit} className="space-y-4">
<Input
label="Name"
value={editForm.name}
onChange={(e) => setEditForm({ ...editForm, name: e.target.value })}
required
minLength={2}
/>
<Input
label="Email"
type="email"
value={editForm.email}
onChange={(e) => setEditForm({ ...editForm, email: e.target.value })}
required
/>
<Input
label="Phone"
value={editForm.phone}
onChange={(e) => setEditForm({ ...editForm, phone: e.target.value })}
placeholder="Optional"
/>
<div>
<label className="block text-sm font-medium text-primary-dark mb-1.5">Role</label>
<select
value={editForm.role}
onChange={(e) => setEditForm({ ...editForm, role: e.target.value as User['role'] })}
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow focus:border-transparent"
>
<option value="user">{t('admin.users.roles.user')}</option>
<option value="staff">{t('admin.users.roles.staff')}</option>
<option value="marketing">{t('admin.users.roles.marketing')}</option>
<option value="organizer">{t('admin.users.roles.organizer')}</option>
<option value="admin">{t('admin.users.roles.admin')}</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-primary-dark mb-1.5">Language Preference</label>
<select
value={editForm.languagePreference}
onChange={(e) => setEditForm({ ...editForm, languagePreference: e.target.value })}
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow focus:border-transparent"
>
<option value="">Not set</option>
<option value="en">English</option>
<option value="es">Espa&#241;ol</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-primary-dark mb-1.5">Account Status</label>
<select
value={editForm.accountStatus}
onChange={(e) => setEditForm({ ...editForm, accountStatus: e.target.value })}
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow focus:border-transparent"
>
<option value="active">Active</option>
<option value="unclaimed">Unclaimed</option>
<option value="suspended">Suspended</option>
</select>
</div>
<div className="flex gap-4 justify-end mt-6 pt-4 border-t border-secondary-light-gray">
<Button
type="button"
variant="outline"
onClick={() => setEditingUser(null)}
>
Cancel
</Button>
<Button type="submit" isLoading={saving}>
Save Changes
</Button>
</div>
</form>
</Card>
</div>
)}
</div> </div>
); );
} }