user management
This commit is contained in:
@@ -1,12 +1,119 @@
|
||||
import { permissions } from "@mrp/shared";
|
||||
import { Router } from "express";
|
||||
import { z } from "zod";
|
||||
|
||||
import { ok } from "../../lib/http.js";
|
||||
import { fail, ok } from "../../lib/http.js";
|
||||
import { requirePermissions } from "../../lib/rbac.js";
|
||||
import { getAdminDiagnostics } from "./service.js";
|
||||
import {
|
||||
createAdminRole,
|
||||
createAdminUser,
|
||||
getAdminDiagnostics,
|
||||
listAdminPermissions,
|
||||
listAdminRoles,
|
||||
listAdminUsers,
|
||||
updateAdminRole,
|
||||
updateAdminUser,
|
||||
} from "./service.js";
|
||||
|
||||
export const adminRouter = Router();
|
||||
|
||||
const roleSchema = z.object({
|
||||
name: z.string().trim().min(1).max(120),
|
||||
description: z.string(),
|
||||
permissionKeys: z.array(z.string().trim().min(1)),
|
||||
});
|
||||
|
||||
const userSchema = z.object({
|
||||
email: z.string().email(),
|
||||
firstName: z.string().trim().min(1).max(120),
|
||||
lastName: z.string().trim().min(1).max(120),
|
||||
isActive: z.boolean(),
|
||||
roleIds: z.array(z.string().trim().min(1)),
|
||||
password: z.string().min(8).nullable(),
|
||||
});
|
||||
|
||||
function getRouteParam(value: unknown) {
|
||||
return typeof value === "string" ? value : null;
|
||||
}
|
||||
|
||||
adminRouter.get("/diagnostics", requirePermissions([permissions.adminManage]), async (_request, response) => {
|
||||
return ok(response, await getAdminDiagnostics());
|
||||
});
|
||||
|
||||
adminRouter.get("/permissions", requirePermissions([permissions.adminManage]), async (_request, response) => {
|
||||
return ok(response, await listAdminPermissions());
|
||||
});
|
||||
|
||||
adminRouter.get("/roles", requirePermissions([permissions.adminManage]), async (_request, response) => {
|
||||
return ok(response, await listAdminRoles());
|
||||
});
|
||||
|
||||
adminRouter.post("/roles", requirePermissions([permissions.adminManage]), async (request, response) => {
|
||||
const parsed = roleSchema.safeParse(request.body);
|
||||
if (!parsed.success) {
|
||||
return fail(response, 400, "INVALID_INPUT", "Role payload is invalid.");
|
||||
}
|
||||
|
||||
const result = await createAdminRole(parsed.data, request.authUser?.id);
|
||||
if (!result.ok) {
|
||||
return fail(response, 400, "INVALID_INPUT", result.reason);
|
||||
}
|
||||
|
||||
return ok(response, result.role, 201);
|
||||
});
|
||||
|
||||
adminRouter.put("/roles/:roleId", requirePermissions([permissions.adminManage]), async (request, response) => {
|
||||
const roleId = getRouteParam(request.params.roleId);
|
||||
if (!roleId) {
|
||||
return fail(response, 400, "INVALID_INPUT", "Role id is invalid.");
|
||||
}
|
||||
|
||||
const parsed = roleSchema.safeParse(request.body);
|
||||
if (!parsed.success) {
|
||||
return fail(response, 400, "INVALID_INPUT", "Role payload is invalid.");
|
||||
}
|
||||
|
||||
const result = await updateAdminRole(roleId, parsed.data, request.authUser?.id);
|
||||
if (!result.ok) {
|
||||
return fail(response, 400, "INVALID_INPUT", result.reason);
|
||||
}
|
||||
|
||||
return ok(response, result.role);
|
||||
});
|
||||
|
||||
adminRouter.get("/users", requirePermissions([permissions.adminManage]), async (_request, response) => {
|
||||
return ok(response, await listAdminUsers());
|
||||
});
|
||||
|
||||
adminRouter.post("/users", requirePermissions([permissions.adminManage]), async (request, response) => {
|
||||
const parsed = userSchema.safeParse(request.body);
|
||||
if (!parsed.success) {
|
||||
return fail(response, 400, "INVALID_INPUT", "User payload is invalid.");
|
||||
}
|
||||
|
||||
const result = await createAdminUser(parsed.data, request.authUser?.id);
|
||||
if (!result.ok) {
|
||||
return fail(response, 400, "INVALID_INPUT", result.reason);
|
||||
}
|
||||
|
||||
return ok(response, result.user, 201);
|
||||
});
|
||||
|
||||
adminRouter.put("/users/:userId", requirePermissions([permissions.adminManage]), async (request, response) => {
|
||||
const userId = getRouteParam(request.params.userId);
|
||||
if (!userId) {
|
||||
return fail(response, 400, "INVALID_INPUT", "User id is invalid.");
|
||||
}
|
||||
|
||||
const parsed = userSchema.safeParse(request.body);
|
||||
if (!parsed.success) {
|
||||
return fail(response, 400, "INVALID_INPUT", "User payload is invalid.");
|
||||
}
|
||||
|
||||
const result = await updateAdminUser(userId, parsed.data, request.authUser?.id);
|
||||
if (!result.ok) {
|
||||
return fail(response, 400, "INVALID_INPUT", result.reason);
|
||||
}
|
||||
|
||||
return ok(response, result.user);
|
||||
});
|
||||
|
||||
@@ -1,8 +1,18 @@
|
||||
import type { AdminDiagnosticsDto, AuditEventDto } from "@mrp/shared";
|
||||
import type {
|
||||
AdminDiagnosticsDto,
|
||||
AdminPermissionOptionDto,
|
||||
AdminRoleDto,
|
||||
AdminRoleInput,
|
||||
AdminUserDto,
|
||||
AdminUserInput,
|
||||
AuditEventDto,
|
||||
} from "@mrp/shared";
|
||||
import fs from "node:fs/promises";
|
||||
|
||||
import { env } from "../../config/env.js";
|
||||
import { paths } from "../../config/paths.js";
|
||||
import { logAuditEvent } from "../../lib/audit.js";
|
||||
import { hashPassword } from "../../lib/password.js";
|
||||
import { prisma } from "../../lib/prisma.js";
|
||||
|
||||
function mapAuditEvent(record: {
|
||||
@@ -32,6 +42,427 @@ function mapAuditEvent(record: {
|
||||
};
|
||||
}
|
||||
|
||||
function mapRole(record: {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
rolePermissions: Array<{
|
||||
permission: {
|
||||
key: string;
|
||||
};
|
||||
}>;
|
||||
_count: {
|
||||
userRoles: number;
|
||||
};
|
||||
}): AdminRoleDto {
|
||||
return {
|
||||
id: record.id,
|
||||
name: record.name,
|
||||
description: record.description,
|
||||
permissionKeys: record.rolePermissions.map((rolePermission) => rolePermission.permission.key).sort(),
|
||||
userCount: record._count.userRoles,
|
||||
createdAt: record.createdAt.toISOString(),
|
||||
updatedAt: record.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function mapUser(record: {
|
||||
id: string;
|
||||
email: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
isActive: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
userRoles: Array<{
|
||||
role: {
|
||||
id: string;
|
||||
name: string;
|
||||
rolePermissions: Array<{
|
||||
permission: {
|
||||
key: string;
|
||||
};
|
||||
}>;
|
||||
};
|
||||
}>;
|
||||
}): AdminUserDto {
|
||||
const permissionKeys = new Set<string>();
|
||||
for (const userRole of record.userRoles) {
|
||||
for (const rolePermission of userRole.role.rolePermissions) {
|
||||
permissionKeys.add(rolePermission.permission.key);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: record.id,
|
||||
email: record.email,
|
||||
firstName: record.firstName,
|
||||
lastName: record.lastName,
|
||||
isActive: record.isActive,
|
||||
roleIds: record.userRoles.map((userRole) => userRole.role.id),
|
||||
roleNames: record.userRoles.map((userRole) => userRole.role.name),
|
||||
permissionKeys: [...permissionKeys].sort(),
|
||||
createdAt: record.createdAt.toISOString(),
|
||||
updatedAt: record.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
async function validatePermissionKeys(permissionKeys: string[]) {
|
||||
const uniquePermissionKeys = [...new Set(permissionKeys)];
|
||||
const permissions = await prisma.permission.findMany({
|
||||
where: {
|
||||
key: {
|
||||
in: uniquePermissionKeys,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
key: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (permissions.length !== uniquePermissionKeys.length) {
|
||||
return { ok: false as const, reason: "One or more selected permissions are invalid." };
|
||||
}
|
||||
|
||||
return { ok: true as const, permissions };
|
||||
}
|
||||
|
||||
async function validateRoleIds(roleIds: string[]) {
|
||||
const uniqueRoleIds = [...new Set(roleIds)];
|
||||
const roles = await prisma.role.findMany({
|
||||
where: {
|
||||
id: {
|
||||
in: uniqueRoleIds,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (roles.length !== uniqueRoleIds.length) {
|
||||
return { ok: false as const, reason: "One or more selected roles are invalid." };
|
||||
}
|
||||
|
||||
return { ok: true as const, roles };
|
||||
}
|
||||
|
||||
export async function listAdminPermissions(): Promise<AdminPermissionOptionDto[]> {
|
||||
const permissions = await prisma.permission.findMany({
|
||||
orderBy: [{ key: "asc" }],
|
||||
});
|
||||
|
||||
return permissions.map((permission) => ({
|
||||
key: permission.key,
|
||||
description: permission.description,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function listAdminRoles(): Promise<AdminRoleDto[]> {
|
||||
const roles = await prisma.role.findMany({
|
||||
include: {
|
||||
rolePermissions: {
|
||||
include: {
|
||||
permission: {
|
||||
select: {
|
||||
key: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
_count: {
|
||||
select: {
|
||||
userRoles: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: [{ name: "asc" }],
|
||||
});
|
||||
|
||||
return roles.map(mapRole);
|
||||
}
|
||||
|
||||
export async function createAdminRole(payload: AdminRoleInput, actorId?: string | null) {
|
||||
const validatedPermissions = await validatePermissionKeys(payload.permissionKeys);
|
||||
if (!validatedPermissions.ok) {
|
||||
return { ok: false as const, reason: validatedPermissions.reason };
|
||||
}
|
||||
|
||||
const role = await prisma.role.create({
|
||||
data: {
|
||||
name: payload.name.trim(),
|
||||
description: payload.description,
|
||||
rolePermissions: {
|
||||
create: validatedPermissions.permissions.map((permission) => ({
|
||||
permissionId: permission.id,
|
||||
})),
|
||||
},
|
||||
},
|
||||
include: {
|
||||
rolePermissions: {
|
||||
include: {
|
||||
permission: {
|
||||
select: {
|
||||
key: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
_count: {
|
||||
select: {
|
||||
userRoles: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await logAuditEvent({
|
||||
actorId,
|
||||
entityType: "role",
|
||||
entityId: role.id,
|
||||
action: "created",
|
||||
summary: `Created role ${role.name}.`,
|
||||
metadata: {
|
||||
name: role.name,
|
||||
permissionKeys: role.rolePermissions.map((rolePermission) => rolePermission.permission.key),
|
||||
},
|
||||
});
|
||||
|
||||
return { ok: true as const, role: mapRole(role) };
|
||||
}
|
||||
|
||||
export async function updateAdminRole(roleId: string, payload: AdminRoleInput, actorId?: string | null) {
|
||||
const existingRole = await prisma.role.findUnique({
|
||||
where: { id: roleId },
|
||||
select: { id: true, name: true },
|
||||
});
|
||||
|
||||
if (!existingRole) {
|
||||
return { ok: false as const, reason: "Role was not found." };
|
||||
}
|
||||
|
||||
const validatedPermissions = await validatePermissionKeys(payload.permissionKeys);
|
||||
if (!validatedPermissions.ok) {
|
||||
return { ok: false as const, reason: validatedPermissions.reason };
|
||||
}
|
||||
|
||||
const role = await prisma.role.update({
|
||||
where: { id: roleId },
|
||||
data: {
|
||||
name: payload.name.trim(),
|
||||
description: payload.description,
|
||||
rolePermissions: {
|
||||
deleteMany: {},
|
||||
create: validatedPermissions.permissions.map((permission) => ({
|
||||
permissionId: permission.id,
|
||||
})),
|
||||
},
|
||||
},
|
||||
include: {
|
||||
rolePermissions: {
|
||||
include: {
|
||||
permission: {
|
||||
select: {
|
||||
key: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
_count: {
|
||||
select: {
|
||||
userRoles: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await logAuditEvent({
|
||||
actorId,
|
||||
entityType: "role",
|
||||
entityId: role.id,
|
||||
action: "updated",
|
||||
summary: `Updated role ${role.name}.`,
|
||||
metadata: {
|
||||
previousName: existingRole.name,
|
||||
name: role.name,
|
||||
permissionKeys: role.rolePermissions.map((rolePermission) => rolePermission.permission.key),
|
||||
},
|
||||
});
|
||||
|
||||
return { ok: true as const, role: mapRole(role) };
|
||||
}
|
||||
|
||||
export async function listAdminUsers(): Promise<AdminUserDto[]> {
|
||||
const users = await prisma.user.findMany({
|
||||
include: {
|
||||
userRoles: {
|
||||
include: {
|
||||
role: {
|
||||
include: {
|
||||
rolePermissions: {
|
||||
include: {
|
||||
permission: {
|
||||
select: {
|
||||
key: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: [{ firstName: "asc" }, { lastName: "asc" }, { email: "asc" }],
|
||||
});
|
||||
|
||||
return users.map(mapUser);
|
||||
}
|
||||
|
||||
export async function createAdminUser(payload: AdminUserInput, actorId?: string | null) {
|
||||
if (!payload.password || payload.password.trim().length < 8) {
|
||||
return { ok: false as const, reason: "A password with at least 8 characters is required for new users." };
|
||||
}
|
||||
|
||||
const validatedRoles = await validateRoleIds(payload.roleIds);
|
||||
if (!validatedRoles.ok) {
|
||||
return { ok: false as const, reason: validatedRoles.reason };
|
||||
}
|
||||
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
email: payload.email.trim().toLowerCase(),
|
||||
firstName: payload.firstName.trim(),
|
||||
lastName: payload.lastName.trim(),
|
||||
isActive: payload.isActive,
|
||||
passwordHash: await hashPassword(payload.password.trim()),
|
||||
userRoles: {
|
||||
create: validatedRoles.roles.map((role) => ({
|
||||
roleId: role.id,
|
||||
assignedBy: actorId ?? null,
|
||||
})),
|
||||
},
|
||||
},
|
||||
include: {
|
||||
userRoles: {
|
||||
include: {
|
||||
role: {
|
||||
include: {
|
||||
rolePermissions: {
|
||||
include: {
|
||||
permission: {
|
||||
select: {
|
||||
key: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await logAuditEvent({
|
||||
actorId,
|
||||
entityType: "user",
|
||||
entityId: user.id,
|
||||
action: "created",
|
||||
summary: `Created user account for ${user.email}.`,
|
||||
metadata: {
|
||||
email: user.email,
|
||||
isActive: user.isActive,
|
||||
roleNames: user.userRoles.map((userRole) => userRole.role.name),
|
||||
},
|
||||
});
|
||||
|
||||
return { ok: true as const, user: mapUser(user) };
|
||||
}
|
||||
|
||||
export async function updateAdminUser(userId: string, payload: AdminUserInput, actorId?: string | null) {
|
||||
const existingUser = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!existingUser) {
|
||||
return { ok: false as const, reason: "User was not found." };
|
||||
}
|
||||
|
||||
const validatedRoles = await validateRoleIds(payload.roleIds);
|
||||
if (!validatedRoles.ok) {
|
||||
return { ok: false as const, reason: validatedRoles.reason };
|
||||
}
|
||||
|
||||
const data = {
|
||||
email: payload.email.trim().toLowerCase(),
|
||||
firstName: payload.firstName.trim(),
|
||||
lastName: payload.lastName.trim(),
|
||||
isActive: payload.isActive,
|
||||
...(payload.password?.trim()
|
||||
? {
|
||||
passwordHash: await hashPassword(payload.password.trim()),
|
||||
}
|
||||
: {}),
|
||||
userRoles: {
|
||||
deleteMany: {},
|
||||
create: validatedRoles.roles.map((role) => ({
|
||||
roleId: role.id,
|
||||
assignedBy: actorId ?? null,
|
||||
})),
|
||||
},
|
||||
};
|
||||
|
||||
const user = await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data,
|
||||
include: {
|
||||
userRoles: {
|
||||
include: {
|
||||
role: {
|
||||
include: {
|
||||
rolePermissions: {
|
||||
include: {
|
||||
permission: {
|
||||
select: {
|
||||
key: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await logAuditEvent({
|
||||
actorId,
|
||||
entityType: "user",
|
||||
entityId: user.id,
|
||||
action: "updated",
|
||||
summary: `Updated user account for ${user.email}.`,
|
||||
metadata: {
|
||||
previousEmail: existingUser.email,
|
||||
email: user.email,
|
||||
isActive: user.isActive,
|
||||
roleNames: user.userRoles.map((userRole) => userRole.role.name),
|
||||
passwordReset: Boolean(payload.password?.trim()),
|
||||
},
|
||||
});
|
||||
|
||||
return { ok: true as const, user: mapUser(user) };
|
||||
}
|
||||
|
||||
export async function getAdminDiagnostics(): Promise<AdminDiagnosticsDto> {
|
||||
const [
|
||||
companyProfile,
|
||||
|
||||
Reference in New Issue
Block a user