From 0b704e0e271fd9e5f30a049e3bd0b7804cbfa86c Mon Sep 17 00:00:00 2001 From: Horux Dev Date: Sun, 17 May 2026 14:32:45 +0000 Subject: [PATCH] feat(admin/usuarios): agregar usuario globalmente desde admin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit El admin global ahora puede crear usuarios directamente desde /admin/usuarios sin depender de que un owner los invite. Backend: - Nuevo endpoint POST /usuarios/global (controller + service) - Valida límite de usuarios del plan del tenant destino - Si el email ya existe, agrega membership al tenant destino - Si no existe, crea user con temp password + membership - Schema Zod: email, nombre, role, tenantId, supervisorUserId? Frontend: - Botón 'Agregar Usuario' en /admin/usuarios - Formulario con: nombre, email, rol, empresa - Hook useCreateUsuarioGlobal con invalidación de queries --- .../src/controllers/usuarios.controller.ts | 37 +++++ apps/api/src/routes/usuarios.routes.ts | 1 + apps/api/src/services/usuarios.service.ts | 59 ++++++++ .../app/(dashboard)/admin/usuarios/page.tsx | 137 ++++++++++++++++-- apps/web/lib/api/usuarios.ts | 5 + apps/web/lib/hooks/use-usuarios.ts | 11 ++ 6 files changed, 239 insertions(+), 11 deletions(-) diff --git a/apps/api/src/controllers/usuarios.controller.ts b/apps/api/src/controllers/usuarios.controller.ts index 3ffb3dd..b842462 100644 --- a/apps/api/src/controllers/usuarios.controller.ts +++ b/apps/api/src/controllers/usuarios.controller.ts @@ -26,6 +26,14 @@ const updateGlobalSchema = z.object({ tenantId: z.string().uuid().optional(), }); +const createGlobalSchema = z.object({ + email: z.string().email('email inválido'), + nombre: z.string().min(2).max(100), + role: z.enum(['contador', 'visor', 'auxiliar', 'supervisor', 'cliente']), + tenantId: z.string().uuid('tenantId inválido'), + supervisorUserId: z.string().uuid().optional(), +}); + async function isGlobalAdmin(req: Request): Promise { return checkGlobalAdmin(req.user!.tenantId, req.user!.role, req.user!.userId); } @@ -190,6 +198,35 @@ export async function deleteUsuario(req: Request, res: Response, next: NextFunct } } +/** + * Crea un usuario globalmente (solo admin global) + */ +export async function createUsuarioGlobal(req: Request, res: Response, next: NextFunction) { + try { + if (!(await isGlobalAdmin(req))) { + throw new AppError(403, 'Solo el administrador global puede crear usuarios'); + } + + const data = createGlobalSchema.parse(req.body); + + if (data.role === 'auxiliar' && !data.supervisorUserId) { + throw new AppError(400, 'Debes asignar un supervisor al auxiliar'); + } + + const usuario = await usuariosService.createUsuarioGlobal(data.tenantId, { + email: data.email, + nombre: data.nombre, + role: data.role, + supervisorUserId: data.supervisorUserId, + }); + + res.status(201).json(usuario); + } catch (error) { + if (error instanceof z.ZodError) return next(new AppError(400, error.errors[0].message)); + next(error); + } +} + /** * Actualiza un usuario globalmente (puede cambiar de empresa) */ diff --git a/apps/api/src/routes/usuarios.routes.ts b/apps/api/src/routes/usuarios.routes.ts index 0dbda62..92c490a 100644 --- a/apps/api/src/routes/usuarios.routes.ts +++ b/apps/api/src/routes/usuarios.routes.ts @@ -23,6 +23,7 @@ router.put('/:id/supervisor', tenantMiddleware, usuariosController.updateSupervi // Rutas globales (solo admin global) router.get('/global/all', usuariosController.getAllUsuarios); +router.post('/global', usuariosController.createUsuarioGlobal); router.patch('/global/:id', usuariosController.updateUsuarioGlobal); router.delete('/global/:id', usuariosController.deleteUsuarioGlobal); diff --git a/apps/api/src/services/usuarios.service.ts b/apps/api/src/services/usuarios.service.ts index 03a96e4..637c6e9 100644 --- a/apps/api/src/services/usuarios.service.ts +++ b/apps/api/src/services/usuarios.service.ts @@ -200,6 +200,65 @@ export async function getAllUsuarios(): Promise { return memberships.map(m => mapMembershipRow(m, true)); } +export async function createUsuarioGlobal( + tenantId: string, + data: UserInvite +): Promise { + const tenant = await prisma.tenant.findUnique({ + where: { id: tenantId }, + select: { plan: true }, + }); + + // Límite del catálogo despacho desde BD (con cache). -1 = ilimitado. + const planLimits = tenant ? await getDespachoPlanLimits(tenant.plan) : null; + const maxUsers = planLimits?.maxUsers ?? 1; + + const currentCount = await prisma.tenantMembership.count({ + where: { tenantId, active: true }, + }); + + if (maxUsers !== -1 && currentCount >= maxUsers) { + throw new Error('Límite de usuarios alcanzado para este plan'); + } + + // Si el email ya existe como user global, agregamos membership en este tenant + let user = await prisma.user.findUnique({ where: { email: data.email } }); + + if (!user) { + const tempPassword = randomBytes(4).toString('hex'); + const passwordHash = await bcrypt.hash(tempPassword, 12); + user = await prisma.user.create({ + data: { + email: data.email, + passwordHash, + nombre: data.nombre, + lastTenantId: tenantId, + }, + }); + } + + const rolId = await getRolId(data.role); + + await prisma.tenantMembership.upsert({ + where: { userId_tenantId: { userId: user.id, tenantId } }, + update: { rolId, isOwner: false, active: true }, + create: { + userId: user.id, + tenantId, + rolId, + isOwner: false, + active: true, + }, + }); + + const membership = await prisma.tenantMembership.findUnique({ + where: { userId_tenantId: { userId: user.id, tenantId } }, + include: MEMBERSHIP_INCLUDE, + }); + + return mapMembershipRow(membership!); +} + export async function updateUsuarioGlobal( userId: string, data: UserUpdate & { tenantId?: string } diff --git a/apps/web/app/(dashboard)/admin/usuarios/page.tsx b/apps/web/app/(dashboard)/admin/usuarios/page.tsx index 8b8a90f..a557adf 100644 --- a/apps/web/app/(dashboard)/admin/usuarios/page.tsx +++ b/apps/web/app/(dashboard)/admin/usuarios/page.tsx @@ -3,11 +3,11 @@ import { useState, useEffect } from 'react'; import { DashboardShell } from '@/components/layouts/dashboard-shell'; import { Card, CardContent, CardHeader, CardTitle, Button, Input, Label, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@horux/shared-ui'; -import { useAllUsuarios, useUpdateUsuarioGlobal, useDeleteUsuarioGlobal } from '@/lib/hooks/use-usuarios'; +import { useAllUsuarios, useCreateUsuarioGlobal, useUpdateUsuarioGlobal, useDeleteUsuarioGlobal } from '@/lib/hooks/use-usuarios'; import { getTenants, type Tenant } from '@/lib/api/tenants'; import { useAuthStore } from '@/stores/auth-store'; import { isGlobalAdminRfc } from '@horux/shared'; -import { Users, Pencil, Trash2, Shield, Eye, Calculator, Building2, X, Check, UserCog, UserCheck, User, Briefcase } from 'lucide-react'; +import { Users, Pencil, Trash2, Shield, Eye, Calculator, Building2, X, Check, UserCog, UserCheck, User, Briefcase, Plus } from 'lucide-react'; import { cn } from '@horux/shared-ui'; // Mapa de roles + fallback defensivo. El fork despacho introduce roles @@ -43,6 +43,15 @@ export default function AdminUsuariosPage() { const [editingUser, setEditingUser] = useState(null); const [filterTenant, setFilterTenant] = useState('all'); const [searchTerm, setSearchTerm] = useState(''); + const [showCreateForm, setShowCreateForm] = useState(false); + const [createFormData, setCreateFormData] = useState({ + email: '', + nombre: '', + role: 'contador' as 'contador' | 'visor' | 'auxiliar' | 'supervisor' | 'cliente', + tenantId: '', + }); + + const createUsuario = useCreateUsuarioGlobal(); const isGlobal = isGlobalAdminRfc(currentUser?.tenantRfc, currentUser?.role, currentUser?.platformRoles); @@ -86,6 +95,26 @@ export default function AdminUsuariosPage() { } }; + const handleCreate = async (e: React.FormEvent) => { + e.preventDefault(); + if (!createFormData.tenantId) { + alert('Selecciona una empresa'); + return; + } + try { + await createUsuario.mutateAsync({ + email: createFormData.email, + nombre: createFormData.nombre, + role: createFormData.role, + tenantId: createFormData.tenantId, + }); + setCreateFormData({ email: '', nombre: '', role: 'contador', tenantId: '' }); + setShowCreateForm(false); + } catch (err: any) { + alert(err.response?.data?.error || err.message || 'Error al crear usuario'); + } + }; + const filteredUsuarios = usuarios?.filter(u => { const matchesTenant = filterTenant === 'all' || u.tenantId === filterTenant; const matchesSearch = !searchTerm || @@ -152,18 +181,104 @@ export default function AdminUsuariosPage() { - {/* Stats */} -
-
- - {filteredUsuarios?.length || 0} usuarios -
-
- - {Object.keys(groupedByTenant || {}).length} empresas + {/* Stats + Acción */} +
+
+
+ + {filteredUsuarios?.length || 0} usuarios +
+
+ + {Object.keys(groupedByTenant || {}).length} empresas +
+
+ {/* Formulario de creación */} + {showCreateForm && ( + + + Nuevo Usuario + + +
+
+
+ + setCreateFormData({ ...createFormData, nombre: e.target.value })} + placeholder="Juan Pérez" + required + /> +
+
+ + setCreateFormData({ ...createFormData, email: e.target.value })} + placeholder="juan@empresa.com" + required + /> +
+
+
+
+ + +
+
+ + +
+
+
+ + +
+
+
+
+ )} + {/* Users by tenant */} {isLoading ? ( diff --git a/apps/web/lib/api/usuarios.ts b/apps/web/lib/api/usuarios.ts index c0c4442..39dfa10 100644 --- a/apps/web/lib/api/usuarios.ts +++ b/apps/web/lib/api/usuarios.ts @@ -26,6 +26,11 @@ export async function getAllUsuarios(): Promise { return response.data; } +export async function createUsuarioGlobal(data: UserInvite & { tenantId: string }): Promise { + const response = await apiClient.post('/usuarios/global', data); + return response.data; +} + export async function updateUsuarioGlobal(id: string, data: UserUpdate): Promise { const response = await apiClient.patch(`/usuarios/global/${id}`, data); return response.data; diff --git a/apps/web/lib/hooks/use-usuarios.ts b/apps/web/lib/hooks/use-usuarios.ts index b3cc790..0be775d 100644 --- a/apps/web/lib/hooks/use-usuarios.ts +++ b/apps/web/lib/hooks/use-usuarios.ts @@ -47,6 +47,17 @@ export function useAllUsuarios() { }); } +export function useCreateUsuarioGlobal() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (data: UserInvite & { tenantId: string }) => usuariosApi.createUsuarioGlobal(data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['usuarios', 'global'] }); + queryClient.invalidateQueries({ queryKey: ['usuarios'] }); + }, + }); +} + export function useUpdateUsuarioGlobal() { const queryClient = useQueryClient(); return useMutation({