feat(admin/usuarios): agregar usuario globalmente desde admin
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
This commit is contained in:
@@ -26,6 +26,14 @@ const updateGlobalSchema = z.object({
|
|||||||
tenantId: z.string().uuid().optional(),
|
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<boolean> {
|
async function isGlobalAdmin(req: Request): Promise<boolean> {
|
||||||
return checkGlobalAdmin(req.user!.tenantId, req.user!.role, req.user!.userId);
|
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)
|
* Actualiza un usuario globalmente (puede cambiar de empresa)
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ router.put('/:id/supervisor', tenantMiddleware, usuariosController.updateSupervi
|
|||||||
|
|
||||||
// Rutas globales (solo admin global)
|
// Rutas globales (solo admin global)
|
||||||
router.get('/global/all', usuariosController.getAllUsuarios);
|
router.get('/global/all', usuariosController.getAllUsuarios);
|
||||||
|
router.post('/global', usuariosController.createUsuarioGlobal);
|
||||||
router.patch('/global/:id', usuariosController.updateUsuarioGlobal);
|
router.patch('/global/:id', usuariosController.updateUsuarioGlobal);
|
||||||
router.delete('/global/:id', usuariosController.deleteUsuarioGlobal);
|
router.delete('/global/:id', usuariosController.deleteUsuarioGlobal);
|
||||||
|
|
||||||
|
|||||||
@@ -200,6 +200,65 @@ export async function getAllUsuarios(): Promise<UserListItem[]> {
|
|||||||
return memberships.map(m => mapMembershipRow(m, true));
|
return memberships.map(m => mapMembershipRow(m, true));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function createUsuarioGlobal(
|
||||||
|
tenantId: string,
|
||||||
|
data: UserInvite
|
||||||
|
): Promise<UserListItem> {
|
||||||
|
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(
|
export async function updateUsuarioGlobal(
|
||||||
userId: string,
|
userId: string,
|
||||||
data: UserUpdate & { tenantId?: string }
|
data: UserUpdate & { tenantId?: string }
|
||||||
|
|||||||
@@ -3,11 +3,11 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { DashboardShell } from '@/components/layouts/dashboard-shell';
|
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 { 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 { getTenants, type Tenant } from '@/lib/api/tenants';
|
||||||
import { useAuthStore } from '@/stores/auth-store';
|
import { useAuthStore } from '@/stores/auth-store';
|
||||||
import { isGlobalAdminRfc } from '@horux/shared';
|
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';
|
import { cn } from '@horux/shared-ui';
|
||||||
|
|
||||||
// Mapa de roles + fallback defensivo. El fork despacho introduce roles
|
// Mapa de roles + fallback defensivo. El fork despacho introduce roles
|
||||||
@@ -43,6 +43,15 @@ export default function AdminUsuariosPage() {
|
|||||||
const [editingUser, setEditingUser] = useState<EditingUser | null>(null);
|
const [editingUser, setEditingUser] = useState<EditingUser | null>(null);
|
||||||
const [filterTenant, setFilterTenant] = useState<string>('all');
|
const [filterTenant, setFilterTenant] = useState<string>('all');
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
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);
|
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 filteredUsuarios = usuarios?.filter(u => {
|
||||||
const matchesTenant = filterTenant === 'all' || u.tenantId === filterTenant;
|
const matchesTenant = filterTenant === 'all' || u.tenantId === filterTenant;
|
||||||
const matchesSearch = !searchTerm ||
|
const matchesSearch = !searchTerm ||
|
||||||
@@ -152,7 +181,8 @@ export default function AdminUsuariosPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Stats */}
|
{/* Stats + Acción */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Users className="h-5 w-5" />
|
<Users className="h-5 w-5" />
|
||||||
@@ -163,6 +193,91 @@ export default function AdminUsuariosPage() {
|
|||||||
<span className="text-sm">{Object.keys(groupedByTenant || {}).length} empresas</span>
|
<span className="text-sm">{Object.keys(groupedByTenant || {}).length} empresas</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<Button onClick={() => setShowCreateForm(true)}>
|
||||||
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
|
Agregar Usuario
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Formulario de creación */}
|
||||||
|
{showCreateForm && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="py-3">
|
||||||
|
<CardTitle className="text-base">Nuevo Usuario</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form onSubmit={handleCreate} className="space-y-4">
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="create-nombre">Nombre</Label>
|
||||||
|
<Input
|
||||||
|
id="create-nombre"
|
||||||
|
value={createFormData.nombre}
|
||||||
|
onChange={(e) => setCreateFormData({ ...createFormData, nombre: e.target.value })}
|
||||||
|
placeholder="Juan Pérez"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="create-email">Email</Label>
|
||||||
|
<Input
|
||||||
|
id="create-email"
|
||||||
|
type="email"
|
||||||
|
value={createFormData.email}
|
||||||
|
onChange={(e) => setCreateFormData({ ...createFormData, email: e.target.value })}
|
||||||
|
placeholder="juan@empresa.com"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="create-role">Rol</Label>
|
||||||
|
<Select
|
||||||
|
value={createFormData.role}
|
||||||
|
onValueChange={(v) => setCreateFormData({ ...createFormData, role: v as any })}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="contador">Contador</SelectItem>
|
||||||
|
<SelectItem value="visor">Visor</SelectItem>
|
||||||
|
<SelectItem value="auxiliar">Auxiliar</SelectItem>
|
||||||
|
<SelectItem value="supervisor">Supervisor</SelectItem>
|
||||||
|
<SelectItem value="cliente">Cliente</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="create-tenant">Empresa</Label>
|
||||||
|
<Select
|
||||||
|
value={createFormData.tenantId}
|
||||||
|
onValueChange={(v) => setCreateFormData({ ...createFormData, tenantId: v })}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Selecciona empresa" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{tenants.map(t => (
|
||||||
|
<SelectItem key={t.id} value={t.id}>{t.nombre}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 justify-end">
|
||||||
|
<Button type="button" variant="outline" onClick={() => setShowCreateForm(false)}>
|
||||||
|
Cancelar
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={createUsuario.isPending}>
|
||||||
|
{createUsuario.isPending ? 'Creando...' : 'Crear Usuario'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Users by tenant */}
|
{/* Users by tenant */}
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
|
|||||||
@@ -26,6 +26,11 @@ export async function getAllUsuarios(): Promise<UserListItem[]> {
|
|||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function createUsuarioGlobal(data: UserInvite & { tenantId: string }): Promise<UserListItem> {
|
||||||
|
const response = await apiClient.post<UserListItem>('/usuarios/global', data);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
export async function updateUsuarioGlobal(id: string, data: UserUpdate): Promise<UserListItem> {
|
export async function updateUsuarioGlobal(id: string, data: UserUpdate): Promise<UserListItem> {
|
||||||
const response = await apiClient.patch<UserListItem>(`/usuarios/global/${id}`, data);
|
const response = await apiClient.patch<UserListItem>(`/usuarios/global/${id}`, data);
|
||||||
return response.data;
|
return response.data;
|
||||||
|
|||||||
@@ -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() {
|
export function useUpdateUsuarioGlobal() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
|
|||||||
Reference in New Issue
Block a user