Initial BizOne portal setup
This commit is contained in:
8
backend/src/users/dto/complete-invitation.dto.ts
Normal file
8
backend/src/users/dto/complete-invitation.dto.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import { IsNotEmpty, IsString, MinLength } from 'class-validator';
|
||||
|
||||
export class CompleteInvitationDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MinLength(8)
|
||||
password!: string;
|
||||
}
|
||||
14
backend/src/users/dto/create-user.dto.ts
Normal file
14
backend/src/users/dto/create-user.dto.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import { IsEmail, IsNotEmpty, IsOptional, IsString } from 'class-validator';
|
||||
|
||||
export class CreateUserDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
name!: string;
|
||||
|
||||
@IsEmail()
|
||||
email!: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
roleId?: string;
|
||||
}
|
||||
20
backend/src/users/dto/update-user.dto.ts
Normal file
20
backend/src/users/dto/update-user.dto.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import { IsEmail, IsIn, IsOptional, IsString } from 'class-validator';
|
||||
|
||||
export class UpdateUserDto {
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
name?: string;
|
||||
|
||||
@IsEmail()
|
||||
@IsOptional()
|
||||
email?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
roleId?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@IsIn(['invited', 'active', 'inactive', 'suspended'])
|
||||
status?: 'invited' | 'active' | 'inactive' | 'suspended';
|
||||
}
|
||||
84
backend/src/users/users.controller.ts
Normal file
84
backend/src/users/users.controller.ts
Normal file
@ -0,0 +1,84 @@
|
||||
import { Body, Controller, Get, Param, Patch, Post, Query, Req, Res, UseGuards } from '@nestjs/common';
|
||||
import type { Request, Response } from 'express';
|
||||
import { AuthenticatedUser } from '../auth/auth.types';
|
||||
import { AuthGuard } from '../common/auth.guard';
|
||||
import { RequirePermission } from '../common/permission.decorator';
|
||||
import { PermissionGuard } from '../common/permission.guard';
|
||||
import { CreateUserDto } from './dto/create-user.dto';
|
||||
import { UpdateUserDto } from './dto/update-user.dto';
|
||||
import { UsersService } from './users.service';
|
||||
import { CompleteInvitationDto } from './dto/complete-invitation.dto';
|
||||
|
||||
@Controller('users')
|
||||
export class UsersController {
|
||||
constructor(private readonly usersService: UsersService) {}
|
||||
|
||||
@UseGuards(AuthGuard, PermissionGuard)
|
||||
@Get()
|
||||
@RequirePermission('users', 'view')
|
||||
findAll(
|
||||
@Query('page') page?: string,
|
||||
@Query('limit') limit?: string,
|
||||
@Query('search') search?: string,
|
||||
@Query('roleId') roleId?: string,
|
||||
@Query('status') status?: string,
|
||||
) {
|
||||
return this.usersService.findAll({
|
||||
page: page ? Number(page) : 1,
|
||||
limit: limit ? Number(limit) : 10,
|
||||
search,
|
||||
roleId,
|
||||
status,
|
||||
});
|
||||
}
|
||||
|
||||
@UseGuards(AuthGuard, PermissionGuard)
|
||||
@Get('export')
|
||||
@RequirePermission('users', 'view')
|
||||
async exportCsv(
|
||||
@Res() response: Response,
|
||||
@Query('search') search?: string,
|
||||
@Query('roleId') roleId?: string,
|
||||
@Query('status') status?: string,
|
||||
) {
|
||||
const csv = await this.usersService.exportCsv({ search, roleId, status });
|
||||
response.setHeader('Content-Type', 'text/csv; charset=utf-8');
|
||||
response.setHeader('Content-Disposition', 'attachment; filename="users-export.csv"');
|
||||
response.send(csv);
|
||||
}
|
||||
|
||||
@UseGuards(AuthGuard, PermissionGuard)
|
||||
@Post('invite')
|
||||
@RequirePermission('users', 'manage')
|
||||
invite(
|
||||
@Req() request: Request & { user: AuthenticatedUser },
|
||||
@Body() dto: CreateUserDto,
|
||||
) {
|
||||
return this.usersService.invite(dto, request.user, request.ip);
|
||||
}
|
||||
|
||||
@UseGuards(AuthGuard, PermissionGuard)
|
||||
@Patch(':id')
|
||||
@RequirePermission('users', 'manage')
|
||||
update(
|
||||
@Req() request: Request & { user: AuthenticatedUser },
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateUserDto,
|
||||
) {
|
||||
return this.usersService.update(id, dto, request.user, request.ip);
|
||||
}
|
||||
|
||||
@Get('invitations/:token')
|
||||
getInvitation(@Param('token') token: string) {
|
||||
return this.usersService.getInvitation(token);
|
||||
}
|
||||
|
||||
@Post('invitations/:token/complete')
|
||||
completeInvitation(
|
||||
@Req() request: Request,
|
||||
@Param('token') token: string,
|
||||
@Body() dto: CompleteInvitationDto,
|
||||
) {
|
||||
return this.usersService.completeInvitation(token, dto, request.ip);
|
||||
}
|
||||
}
|
||||
14
backend/src/users/users.module.ts
Normal file
14
backend/src/users/users.module.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { MailerModule } from '../mailer/mailer.module';
|
||||
import { PrismaModule } from '../prisma/prisma.module';
|
||||
import { UsersController } from './users.controller';
|
||||
import { UsersService } from './users.service';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, MailerModule, AuthModule],
|
||||
controllers: [UsersController],
|
||||
providers: [UsersService],
|
||||
exports: [UsersService],
|
||||
})
|
||||
export class UsersModule {}
|
||||
380
backend/src/users/users.service.ts
Normal file
380
backend/src/users/users.service.ts
Normal file
@ -0,0 +1,380 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { createHash, randomBytes } from 'node:crypto';
|
||||
import { AuthenticatedUser } from '../auth/auth.types';
|
||||
import { normalizeEmail, normalizeText } from '../common/normalize';
|
||||
import { hasMinimumPasswordLength, hashPassword } from '../common/password';
|
||||
import { getAppConfig } from '../config/env';
|
||||
import { MailerService } from '../mailer/mailer.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { CreateUserDto } from './dto/create-user.dto';
|
||||
import { UpdateUserDto } from './dto/update-user.dto';
|
||||
import { CompleteInvitationDto } from './dto/complete-invitation.dto';
|
||||
|
||||
const config = getAppConfig();
|
||||
|
||||
@Injectable()
|
||||
export class UsersService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly mailer: MailerService,
|
||||
) {}
|
||||
|
||||
async findAll(params?: {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
search?: string;
|
||||
roleId?: string;
|
||||
status?: string;
|
||||
}) {
|
||||
const page = Math.max(1, params?.page || 1);
|
||||
const limit = Math.min(50, Math.max(1, params?.limit || 10));
|
||||
const where = this.buildWhere(params);
|
||||
|
||||
const [users, total] = await Promise.all([
|
||||
this.prisma.user.findMany({
|
||||
where,
|
||||
include: {
|
||||
role: {
|
||||
select: { id: true, name: true },
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip: (page - 1) * limit,
|
||||
take: limit,
|
||||
}),
|
||||
this.prisma.user.count({ where }),
|
||||
]);
|
||||
|
||||
return {
|
||||
items: users.map((user) => ({
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
status: user.status,
|
||||
roleId: user.roleId,
|
||||
roleName: user.role?.name || 'Unassigned',
|
||||
createdAt: user.createdAt,
|
||||
updatedAt: user.updatedAt,
|
||||
lastLoginAt: user.lastLoginAt,
|
||||
emailVerifiedAt: user.emailVerifiedAt,
|
||||
})),
|
||||
total,
|
||||
page,
|
||||
pageSize: limit,
|
||||
totalPages: Math.max(1, Math.ceil(total / limit)),
|
||||
};
|
||||
}
|
||||
|
||||
async exportCsv(params?: {
|
||||
search?: string;
|
||||
roleId?: string;
|
||||
status?: string;
|
||||
}) {
|
||||
const users = await this.prisma.user.findMany({
|
||||
where: this.buildWhere(params),
|
||||
include: {
|
||||
role: {
|
||||
select: { id: true, name: true },
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
const header = [
|
||||
'Name',
|
||||
'Email',
|
||||
'Role',
|
||||
'Status',
|
||||
'Created At',
|
||||
'Updated At',
|
||||
'Last Login At',
|
||||
'Email Verified At',
|
||||
];
|
||||
const rows = users.map((user) =>
|
||||
[
|
||||
user.name,
|
||||
user.email,
|
||||
user.role?.name || 'Unassigned',
|
||||
user.status,
|
||||
user.createdAt.toISOString(),
|
||||
user.updatedAt.toISOString(),
|
||||
user.lastLoginAt?.toISOString() || '',
|
||||
user.emailVerifiedAt?.toISOString() || '',
|
||||
]
|
||||
.map((cell) => `"${String(cell).replaceAll('"', '""')}"`)
|
||||
.join(','),
|
||||
);
|
||||
|
||||
return [header.join(','), ...rows].join('\n');
|
||||
}
|
||||
|
||||
async invite(dto: CreateUserDto, actorUser: AuthenticatedUser, ipAddress?: string) {
|
||||
const actor = await this.findActor(actorUser.sub, actorUser.email);
|
||||
const email = normalizeEmail(dto.email);
|
||||
const name = normalizeText(dto.name);
|
||||
if (!name) {
|
||||
throw new BadRequestException('Name is required');
|
||||
}
|
||||
|
||||
const role = dto.roleId
|
||||
? await this.prisma.role.findUnique({ where: { id: dto.roleId } })
|
||||
: await this.prisma.role.findUnique({ where: { key: 'agent' } });
|
||||
|
||||
if (dto.roleId && !role) {
|
||||
throw new NotFoundException('Role not found');
|
||||
}
|
||||
|
||||
const existingUser = await this.prisma.user.findUnique({
|
||||
where: { email },
|
||||
select: { id: true, status: true },
|
||||
});
|
||||
|
||||
if (existingUser?.status === 'active') {
|
||||
throw new BadRequestException('User already exists and is active');
|
||||
}
|
||||
|
||||
const token = randomBytes(24).toString('hex');
|
||||
const tokenHash = this.hashToken(token);
|
||||
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000);
|
||||
|
||||
const user = await this.prisma.user.upsert({
|
||||
where: { email },
|
||||
update: {
|
||||
name,
|
||||
roleId: role?.id || null,
|
||||
status: 'invited',
|
||||
inviteTokenHash: tokenHash,
|
||||
inviteTokenExpiresAt: expiresAt,
|
||||
passwordHash: null,
|
||||
emailVerifiedAt: null,
|
||||
},
|
||||
create: {
|
||||
name,
|
||||
email,
|
||||
roleId: role?.id || null,
|
||||
status: 'invited',
|
||||
inviteTokenHash: tokenHash,
|
||||
inviteTokenExpiresAt: expiresAt,
|
||||
},
|
||||
include: {
|
||||
role: {
|
||||
select: { name: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const invitationUrl = `${config.frontendOrigin}/invite/${token}`;
|
||||
let mailResult: { delivered: boolean } = { delivered: false };
|
||||
try {
|
||||
mailResult = await this.mailer.sendInvitationEmail({
|
||||
to: user.email,
|
||||
name: user.name,
|
||||
roleName: user.role?.name || 'User',
|
||||
invitationUrl,
|
||||
invitedBy: actor.name,
|
||||
});
|
||||
} catch {
|
||||
mailResult = { delivered: false };
|
||||
}
|
||||
|
||||
await this.prisma.auditLog.create({
|
||||
data: {
|
||||
actorUserId: actor.id,
|
||||
actorName: actor.name,
|
||||
actorEmail: actor.email,
|
||||
actionType: 'User Invited',
|
||||
module: 'User Management',
|
||||
ipAddress: ipAddress || null,
|
||||
severity: 'default',
|
||||
details: `Invited user ${user.email} as ${user.role?.name || 'Unassigned'}.`,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
status: user.status,
|
||||
roleId: user.roleId,
|
||||
roleName: user.role?.name || 'Unassigned',
|
||||
invitationUrl,
|
||||
emailSent: mailResult.delivered,
|
||||
};
|
||||
}
|
||||
|
||||
async update(id: string, dto: UpdateUserDto, actorUser: AuthenticatedUser, ipAddress?: string) {
|
||||
const actor = await this.findActor(actorUser.sub, actorUser.email);
|
||||
if (dto.roleId) {
|
||||
const role = await this.prisma.role.findUnique({ where: { id: dto.roleId } });
|
||||
if (!role) {
|
||||
throw new NotFoundException('Role not found');
|
||||
}
|
||||
}
|
||||
|
||||
const user = await this.prisma.user.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(dto.name !== undefined ? { name: normalizeText(dto.name) || '' } : {}),
|
||||
...(dto.email !== undefined ? { email: normalizeEmail(dto.email) } : {}),
|
||||
...(dto.roleId !== undefined ? { roleId: dto.roleId || null } : {}),
|
||||
...(dto.status !== undefined ? { status: dto.status } : {}),
|
||||
},
|
||||
include: {
|
||||
role: {
|
||||
select: { name: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await this.prisma.auditLog.create({
|
||||
data: {
|
||||
actorUserId: actor.id,
|
||||
actorName: actor.name,
|
||||
actorEmail: actor.email,
|
||||
actionType: 'User Updated',
|
||||
module: 'User Management',
|
||||
ipAddress: ipAddress || null,
|
||||
severity: 'default',
|
||||
details: `Updated user ${user.email}.`,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
status: user.status,
|
||||
roleId: user.roleId,
|
||||
roleName: user.role?.name || 'Unassigned',
|
||||
lastLoginAt: user.lastLoginAt,
|
||||
emailVerifiedAt: user.emailVerifiedAt,
|
||||
};
|
||||
}
|
||||
|
||||
async getInvitation(token: string) {
|
||||
const invite = await this.findInvitationByToken(token);
|
||||
return {
|
||||
email: invite.email,
|
||||
name: invite.name,
|
||||
roleName: invite.role?.name || 'User',
|
||||
expiresAt: invite.inviteTokenExpiresAt,
|
||||
};
|
||||
}
|
||||
|
||||
async completeInvitation(token: string, dto: CompleteInvitationDto, ipAddress?: string) {
|
||||
if (!hasMinimumPasswordLength(dto.password)) {
|
||||
throw new BadRequestException('Password must be at least 8 characters');
|
||||
}
|
||||
|
||||
const invite = await this.findInvitationByToken(token);
|
||||
const passwordHash = await hashPassword(dto.password);
|
||||
|
||||
const user = await this.prisma.user.update({
|
||||
where: { id: invite.id },
|
||||
data: {
|
||||
passwordHash,
|
||||
status: 'active',
|
||||
emailVerifiedAt: new Date(),
|
||||
inviteTokenHash: null,
|
||||
inviteTokenExpiresAt: null,
|
||||
},
|
||||
include: {
|
||||
role: {
|
||||
select: { name: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await this.prisma.auditLog.create({
|
||||
data: {
|
||||
actorUserId: user.id,
|
||||
actorName: user.name,
|
||||
actorEmail: user.email,
|
||||
actionType: 'User Activated',
|
||||
module: 'User Management',
|
||||
ipAddress: ipAddress || null,
|
||||
severity: 'default',
|
||||
details: `Completed password setup for ${user.email}.`,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
status: user.status,
|
||||
};
|
||||
}
|
||||
|
||||
private async findInvitationByToken(token: string) {
|
||||
const invite = await this.prisma.user.findFirst({
|
||||
where: {
|
||||
inviteTokenHash: this.hashToken(token),
|
||||
},
|
||||
include: {
|
||||
role: {
|
||||
select: { name: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!invite || !invite.inviteTokenExpiresAt || invite.inviteTokenExpiresAt.getTime() < Date.now()) {
|
||||
throw new UnauthorizedException('Invitation token is invalid or expired');
|
||||
}
|
||||
|
||||
return invite;
|
||||
}
|
||||
|
||||
private async findActor(userId: string, email: string) {
|
||||
const actor = await this.prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { id: true, name: true, email: true },
|
||||
});
|
||||
|
||||
return {
|
||||
id: actor?.id || userId,
|
||||
name: actor?.name || email,
|
||||
email: actor?.email || email,
|
||||
};
|
||||
}
|
||||
|
||||
private hashToken(token: string) {
|
||||
return createHash('sha256').update(token).digest('hex');
|
||||
}
|
||||
|
||||
private buildWhere(params?: {
|
||||
search?: string;
|
||||
roleId?: string;
|
||||
status?: string;
|
||||
}): Prisma.UserWhereInput {
|
||||
const search = params?.search?.trim();
|
||||
const roleId = params?.roleId?.trim();
|
||||
const status = params?.status?.trim();
|
||||
|
||||
const and: Prisma.UserWhereInput[] = [];
|
||||
|
||||
if (search) {
|
||||
and.push({
|
||||
OR: [
|
||||
{ name: { contains: search, mode: 'insensitive' } },
|
||||
{ email: { contains: search, mode: 'insensitive' } },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
if (roleId) {
|
||||
and.push({ roleId });
|
||||
}
|
||||
|
||||
if (status && ['invited', 'active', 'inactive', 'suspended'].includes(status)) {
|
||||
and.push({ status: status as 'invited' | 'active' | 'inactive' | 'suspended' });
|
||||
}
|
||||
|
||||
return and.length > 0 ? { AND: and } : {};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user