334 lines
9.9 KiB
TypeScript
334 lines
9.9 KiB
TypeScript
import { Router, Request, Response, NextFunction } from 'express';
|
|
import bcrypt from 'bcryptjs';
|
|
import { z } from 'zod';
|
|
import prisma from '../lib/prisma';
|
|
import { authenticate, authorize } from '../middleware/auth';
|
|
import { Role } from '@prisma/client';
|
|
|
|
const router = Router();
|
|
|
|
const createUserSchema = z.object({
|
|
email: z.string().email('Invalid email format'),
|
|
password: z.string().min(6, 'Password must be at least 6 characters'),
|
|
firstName: z.string().min(1, 'First name is required').max(100),
|
|
lastName: z.string().min(1, 'Last name is required').max(100),
|
|
role: z.nativeEnum(Role),
|
|
tenantId: z.string().uuid().optional().nullable(),
|
|
active: z.boolean().optional(),
|
|
});
|
|
|
|
const updateUserSchema = z.object({
|
|
email: z.string().email().optional(),
|
|
firstName: z.string().min(1).max(100).optional(),
|
|
lastName: z.string().min(1).max(100).optional(),
|
|
role: z.nativeEnum(Role).optional(),
|
|
tenantId: z.string().uuid().optional().nullable(),
|
|
active: z.boolean().optional(),
|
|
});
|
|
|
|
const changePasswordSchema = z.object({
|
|
currentPassword: z.string().min(1, 'Current password is required'),
|
|
newPassword: z.string().min(6, 'New password must be at least 6 characters'),
|
|
});
|
|
|
|
router.use(authenticate);
|
|
|
|
// GET /api/users
|
|
router.get('/', async (req: Request, res: Response, next: NextFunction) => {
|
|
try {
|
|
const user = req.user!;
|
|
let where: any = {};
|
|
|
|
if (user.role === 'CUSTOMER_ADMIN') {
|
|
// Customer admins see users in their tenant only
|
|
where = { tenantId: user.tenantId };
|
|
} else if (user.role === 'CUSTOMER_USER') {
|
|
// Customer users can only see themselves
|
|
where = { id: user.id };
|
|
}
|
|
// SYSTEM_ADMIN and TECHNICIAN see all users
|
|
|
|
// Optional tenant filter via query param
|
|
if ((user.role === 'SYSTEM_ADMIN' || user.role === 'TECHNICIAN') && req.query.tenantId) {
|
|
where.tenantId = req.query.tenantId as string;
|
|
}
|
|
|
|
const users = await prisma.user.findMany({
|
|
where,
|
|
select: {
|
|
id: true,
|
|
email: true,
|
|
firstName: true,
|
|
lastName: true,
|
|
role: true,
|
|
tenantId: true,
|
|
active: true,
|
|
createdAt: true,
|
|
updatedAt: true,
|
|
tenant: {
|
|
select: { id: true, name: true, slug: true },
|
|
},
|
|
},
|
|
orderBy: { lastName: 'asc' },
|
|
});
|
|
|
|
res.json({ data: users });
|
|
} catch (err) {
|
|
next(err);
|
|
}
|
|
});
|
|
|
|
// GET /api/users/:id
|
|
router.get('/:id', async (req: Request, res: Response, next: NextFunction) => {
|
|
try {
|
|
const currentUser = req.user!;
|
|
const id = req.params.id as string;
|
|
|
|
const targetUser = await prisma.user.findUnique({
|
|
where: { id },
|
|
select: {
|
|
id: true,
|
|
email: true,
|
|
firstName: true,
|
|
lastName: true,
|
|
role: true,
|
|
tenantId: true,
|
|
active: true,
|
|
createdAt: true,
|
|
updatedAt: true,
|
|
tenant: {
|
|
select: { id: true, name: true, slug: true },
|
|
},
|
|
},
|
|
});
|
|
|
|
if (!targetUser) {
|
|
res.status(404).json({ error: 'User not found' });
|
|
return;
|
|
}
|
|
|
|
// Access control
|
|
if (currentUser.role === 'CUSTOMER_ADMIN' && targetUser.tenantId !== currentUser.tenantId) {
|
|
res.status(403).json({ error: 'Forbidden', message: 'You can only view users in your tenant' });
|
|
return;
|
|
}
|
|
if (currentUser.role === 'CUSTOMER_USER' && targetUser.id !== currentUser.id) {
|
|
res.status(403).json({ error: 'Forbidden', message: 'You can only view your own profile' });
|
|
return;
|
|
}
|
|
|
|
res.json({ data: targetUser });
|
|
} catch (err) {
|
|
next(err);
|
|
}
|
|
});
|
|
|
|
// POST /api/users
|
|
router.post('/', authorize('SYSTEM_ADMIN', 'CUSTOMER_ADMIN'), async (req: Request, res: Response, next: NextFunction) => {
|
|
try {
|
|
const currentUser = req.user!;
|
|
const parsed = createUserSchema.safeParse(req.body);
|
|
if (!parsed.success) {
|
|
res.status(400).json({ error: 'Validation error', details: parsed.error.flatten().fieldErrors });
|
|
return;
|
|
}
|
|
|
|
const data = parsed.data;
|
|
|
|
// CUSTOMER_ADMIN can only create users in their own tenant with limited roles
|
|
if (currentUser.role === 'CUSTOMER_ADMIN') {
|
|
if (data.role === 'SYSTEM_ADMIN' || data.role === 'TECHNICIAN') {
|
|
res.status(403).json({ error: 'Forbidden', message: 'You cannot create users with this role' });
|
|
return;
|
|
}
|
|
data.tenantId = currentUser.tenantId;
|
|
}
|
|
|
|
// Validate that tenant-scoped roles have a tenantId
|
|
if ((data.role === 'CUSTOMER_ADMIN' || data.role === 'CUSTOMER_USER') && !data.tenantId) {
|
|
res.status(400).json({ error: 'Validation error', message: 'Customer roles require a tenantId' });
|
|
return;
|
|
}
|
|
|
|
// Check email uniqueness
|
|
const existing = await prisma.user.findUnique({ where: { email: data.email } });
|
|
if (existing) {
|
|
res.status(409).json({ error: 'Conflict', message: 'A user with this email already exists' });
|
|
return;
|
|
}
|
|
|
|
const passwordHash = await bcrypt.hash(data.password, 12);
|
|
|
|
const user = await prisma.user.create({
|
|
data: {
|
|
email: data.email,
|
|
passwordHash,
|
|
firstName: data.firstName,
|
|
lastName: data.lastName,
|
|
role: data.role,
|
|
tenantId: data.tenantId ?? null,
|
|
active: data.active ?? true,
|
|
},
|
|
select: {
|
|
id: true,
|
|
email: true,
|
|
firstName: true,
|
|
lastName: true,
|
|
role: true,
|
|
tenantId: true,
|
|
active: true,
|
|
createdAt: true,
|
|
},
|
|
});
|
|
|
|
res.status(201).json({ data: user });
|
|
} catch (err) {
|
|
next(err);
|
|
}
|
|
});
|
|
|
|
// PUT /api/users/:id
|
|
router.put('/:id', authorize('SYSTEM_ADMIN', 'CUSTOMER_ADMIN'), async (req: Request, res: Response, next: NextFunction) => {
|
|
try {
|
|
const currentUser = req.user!;
|
|
const id = req.params.id as string;
|
|
const parsed = updateUserSchema.safeParse(req.body);
|
|
if (!parsed.success) {
|
|
res.status(400).json({ error: 'Validation error', details: parsed.error.flatten().fieldErrors });
|
|
return;
|
|
}
|
|
|
|
const targetUser = await prisma.user.findUnique({ where: { id } });
|
|
if (!targetUser) {
|
|
res.status(404).json({ error: 'User not found' });
|
|
return;
|
|
}
|
|
|
|
// CUSTOMER_ADMIN can only edit users in their tenant
|
|
if (currentUser.role === 'CUSTOMER_ADMIN') {
|
|
if (targetUser.tenantId !== currentUser.tenantId) {
|
|
res.status(403).json({ error: 'Forbidden', message: 'You can only edit users in your tenant' });
|
|
return;
|
|
}
|
|
// Cannot change role to system-level roles
|
|
if (parsed.data.role && (parsed.data.role === 'SYSTEM_ADMIN' || parsed.data.role === 'TECHNICIAN')) {
|
|
res.status(403).json({ error: 'Forbidden', message: 'You cannot assign this role' });
|
|
return;
|
|
}
|
|
// Cannot change tenantId
|
|
delete parsed.data.tenantId;
|
|
}
|
|
|
|
// Check email uniqueness if changing
|
|
if (parsed.data.email && parsed.data.email !== targetUser.email) {
|
|
const existing = await prisma.user.findUnique({ where: { email: parsed.data.email } });
|
|
if (existing) {
|
|
res.status(409).json({ error: 'Conflict', message: 'A user with this email already exists' });
|
|
return;
|
|
}
|
|
}
|
|
|
|
const updated = await prisma.user.update({
|
|
where: { id },
|
|
data: parsed.data,
|
|
select: {
|
|
id: true,
|
|
email: true,
|
|
firstName: true,
|
|
lastName: true,
|
|
role: true,
|
|
tenantId: true,
|
|
active: true,
|
|
createdAt: true,
|
|
updatedAt: true,
|
|
},
|
|
});
|
|
|
|
res.json({ data: updated });
|
|
} catch (err) {
|
|
next(err);
|
|
}
|
|
});
|
|
|
|
// DELETE /api/users/:id
|
|
router.delete('/:id', authorize('SYSTEM_ADMIN', 'CUSTOMER_ADMIN'), async (req: Request, res: Response, next: NextFunction) => {
|
|
try {
|
|
const currentUser = req.user!;
|
|
const id = req.params.id as string;
|
|
|
|
if (id === currentUser.id) {
|
|
res.status(400).json({ error: 'Bad request', message: 'You cannot delete your own account' });
|
|
return;
|
|
}
|
|
|
|
const targetUser = await prisma.user.findUnique({ where: { id } });
|
|
if (!targetUser) {
|
|
res.status(404).json({ error: 'User not found' });
|
|
return;
|
|
}
|
|
|
|
if (currentUser.role === 'CUSTOMER_ADMIN' && targetUser.tenantId !== currentUser.tenantId) {
|
|
res.status(403).json({ error: 'Forbidden', message: 'You can only delete users in your tenant' });
|
|
return;
|
|
}
|
|
|
|
// Soft delete
|
|
await prisma.user.update({
|
|
where: { id },
|
|
data: { active: false },
|
|
});
|
|
|
|
res.json({ message: 'User deactivated successfully' });
|
|
} catch (err) {
|
|
next(err);
|
|
}
|
|
});
|
|
|
|
// PATCH /api/users/:id/password
|
|
router.patch('/:id/password', async (req: Request, res: Response, next: NextFunction) => {
|
|
try {
|
|
const currentUser = req.user!;
|
|
const id = req.params.id as string;
|
|
|
|
// Users can only change their own password (admins can reset via update)
|
|
if (id !== currentUser.id && currentUser.role !== 'SYSTEM_ADMIN') {
|
|
res.status(403).json({ error: 'Forbidden', message: 'You can only change your own password' });
|
|
return;
|
|
}
|
|
|
|
const parsed = changePasswordSchema.safeParse(req.body);
|
|
if (!parsed.success) {
|
|
res.status(400).json({ error: 'Validation error', details: parsed.error.flatten().fieldErrors });
|
|
return;
|
|
}
|
|
|
|
const user = await prisma.user.findUnique({ where: { id } });
|
|
if (!user) {
|
|
res.status(404).json({ error: 'User not found' });
|
|
return;
|
|
}
|
|
|
|
// Verify current password (skip for SYSTEM_ADMIN resetting another user's password)
|
|
if (id === currentUser.id) {
|
|
const validPassword = await bcrypt.compare(parsed.data.currentPassword, user.passwordHash);
|
|
if (!validPassword) {
|
|
res.status(400).json({ error: 'Invalid current password' });
|
|
return;
|
|
}
|
|
}
|
|
|
|
const passwordHash = await bcrypt.hash(parsed.data.newPassword, 12);
|
|
|
|
await prisma.user.update({
|
|
where: { id },
|
|
data: { passwordHash },
|
|
});
|
|
|
|
res.json({ message: 'Password changed successfully' });
|
|
} catch (err) {
|
|
next(err);
|
|
}
|
|
});
|
|
|
|
export default router;
|