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:
@@ -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))
|
||||||
|
|||||||
@@ -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ñ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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user