feat(auth): role-based permissions + user management (plan phase 1)
Adds the RBAC foundation the CRUD phases build on, and the first write module (users). The platform was read-only: every controller was guarded only by AuthenticatedGuard and UserRole was ADMIN|STAFF. The old PHP app stored level+role but enforced neither, so this is a fresh design. Permission model (server-authoritative): - UserRole expanded to an ordered rank ADMIN > MANAGER > STAFF > VIEWER. VIEWER is the read-only role; STAFF+ can write. - auth/abilities.ts: ROLE_RANK + ABILITY_MIN matrix + can()/abilitiesFor(). - @RequireAbility decorator + AbilityGuard enforce it on write routes; reads stay on AuthenticatedGuard so any logged-in user can read. - /auth/login and /auth/me now return the resolved abilities map, so the web gates its UI off one payload instead of duplicating the rules. User management (ADMIN-only, ability "user:manage"): - UsersService gains list/create/update/resetPassword (argon2), never returns passwordHash; blocks self-deactivation and self-demotion; maps duplicate email to 409. - UsersController: GET/POST /users, PATCH /users/:id, POST /users/:id/reset-password. - Every mutation logged via new AuditService over the existing ActivityLog model (global CommonModule). Web: - AuthContext + useAuth/useCan; AppShell provides the user and gates the new "Usuarios" nav entry on user:manage; shows the user's role. - /usuarios admin page: list + create/edit form + password reset + active toggle, Spanish-first, reusing existing card/table/field styles. Schema pushed to dev (enum only, non-destructive). Verified end-to-end against dev: admin CRUD works, VIEWER writes 403 while reads 200, self-lockout guards and duplicate-email 409 all hold. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
import { IsEmail, IsEnum, IsOptional, IsString, MinLength } from "class-validator";
|
||||
import { UserRole } from "@jorgecuadros/database";
|
||||
|
||||
export class CreateUserDto {
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
name!: string;
|
||||
|
||||
@IsEmail()
|
||||
email!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(8)
|
||||
password!: string;
|
||||
|
||||
@IsEnum(UserRole)
|
||||
role!: UserRole;
|
||||
|
||||
@IsOptional()
|
||||
active?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { IsString, MinLength } from "class-validator";
|
||||
|
||||
export class ResetPasswordDto {
|
||||
@IsString()
|
||||
@MinLength(8)
|
||||
password!: string;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { IsBoolean, IsEmail, IsEnum, IsOptional, IsString, MinLength } from "class-validator";
|
||||
import { UserRole } from "@jorgecuadros/database";
|
||||
|
||||
/** Password changes go through the dedicated reset-password route, not here. */
|
||||
export class UpdateUserDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
name?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEmail()
|
||||
email?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(UserRole)
|
||||
role?: UserRole;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
active?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Req,
|
||||
UseGuards,
|
||||
} from "@nestjs/common";
|
||||
import { Request } from "express";
|
||||
import { AuthenticatedGuard } from "../auth/authenticated.guard";
|
||||
import { AbilityGuard } from "../auth/ability.guard";
|
||||
import { RequireAbility } from "../auth/require-ability.decorator";
|
||||
import { AuditService } from "../common/audit.service";
|
||||
import { UsersService } from "./users.service";
|
||||
import { CreateUserDto } from "./create-user.dto";
|
||||
import { UpdateUserDto } from "./update-user.dto";
|
||||
import { ResetPasswordDto } from "./reset-password.dto";
|
||||
|
||||
/** Every route here is ADMIN-only (ability "user:manage"). */
|
||||
@UseGuards(AuthenticatedGuard, AbilityGuard)
|
||||
@RequireAbility("user:manage")
|
||||
@Controller("users")
|
||||
export class UsersController {
|
||||
constructor(
|
||||
private readonly users: UsersService,
|
||||
private readonly audit: AuditService,
|
||||
) {}
|
||||
|
||||
private actingId(req: Request): string {
|
||||
return (req.user as { id: string }).id;
|
||||
}
|
||||
|
||||
@Get()
|
||||
list() {
|
||||
return this.users.list();
|
||||
}
|
||||
|
||||
@Post()
|
||||
async create(@Body() dto: CreateUserDto, @Req() req: Request) {
|
||||
const user = await this.users.create(dto);
|
||||
void this.audit.log(this.actingId(req), "user.create", {
|
||||
userId: user.id,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
});
|
||||
return user;
|
||||
}
|
||||
|
||||
@Patch(":id")
|
||||
async update(
|
||||
@Param("id") id: string,
|
||||
@Body() dto: UpdateUserDto,
|
||||
@Req() req: Request,
|
||||
) {
|
||||
const user = await this.users.update(id, dto, this.actingId(req));
|
||||
void this.audit.log(this.actingId(req), "user.update", { userId: id, changes: dto });
|
||||
return user;
|
||||
}
|
||||
|
||||
@Post(":id/reset-password")
|
||||
async resetPassword(
|
||||
@Param("id") id: string,
|
||||
@Body() dto: ResetPasswordDto,
|
||||
@Req() req: Request,
|
||||
) {
|
||||
const user = await this.users.resetPassword(id, dto.password);
|
||||
void this.audit.log(this.actingId(req), "user.reset_password", { userId: id });
|
||||
return user;
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { UsersService } from "./users.service";
|
||||
import { UsersController } from "./users.controller";
|
||||
|
||||
@Module({
|
||||
providers: [UsersService],
|
||||
controllers: [UsersController],
|
||||
exports: [UsersService],
|
||||
})
|
||||
export class UsersModule {}
|
||||
|
||||
@@ -1,11 +1,35 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
import * as argon2 from "argon2";
|
||||
import { Prisma } from "@jorgecuadros/database";
|
||||
import type { User } from "@jorgecuadros/database";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
import { CreateUserDto } from "./create-user.dto";
|
||||
import { UpdateUserDto } from "./update-user.dto";
|
||||
|
||||
/** Shape returned to the UI — never carries passwordHash. */
|
||||
const safeSelect = {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
role: true,
|
||||
active: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
} satisfies Prisma.UserSelect;
|
||||
|
||||
export type SafeUserRow = Prisma.UserGetPayload<{ select: typeof safeSelect }>;
|
||||
|
||||
@Injectable()
|
||||
export class UsersService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
// --- used by auth (need the hash / full row) -----------------------------
|
||||
|
||||
findByEmail(email: string): Promise<User | null> {
|
||||
return this.prisma.user.findUnique({ where: { email } });
|
||||
}
|
||||
@@ -13,4 +37,89 @@ export class UsersService {
|
||||
findById(id: string): Promise<User | null> {
|
||||
return this.prisma.user.findUnique({ where: { id } });
|
||||
}
|
||||
|
||||
// --- admin CRUD (safe rows only) -----------------------------------------
|
||||
|
||||
list(): Promise<SafeUserRow[]> {
|
||||
return this.prisma.user.findMany({
|
||||
orderBy: [{ active: "desc" }, { name: "asc" }],
|
||||
select: safeSelect,
|
||||
});
|
||||
}
|
||||
|
||||
async create(dto: CreateUserDto): Promise<SafeUserRow> {
|
||||
const passwordHash = await argon2.hash(dto.password);
|
||||
try {
|
||||
return await this.prisma.user.create({
|
||||
data: {
|
||||
name: dto.name,
|
||||
email: dto.email,
|
||||
passwordHash,
|
||||
role: dto.role,
|
||||
active: dto.active ?? true,
|
||||
},
|
||||
select: safeSelect,
|
||||
});
|
||||
} catch (e) {
|
||||
throw this.mapError(e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `actingUserId` is the admin making the change — used to stop an admin from
|
||||
* locking themselves out (deactivating or demoting their own account).
|
||||
*/
|
||||
async update(
|
||||
id: string,
|
||||
dto: UpdateUserDto,
|
||||
actingUserId: string,
|
||||
): Promise<SafeUserRow> {
|
||||
await this.ensureExists(id);
|
||||
|
||||
if (id === actingUserId) {
|
||||
if (dto.active === false) {
|
||||
throw new BadRequestException("No puede desactivar su propia cuenta");
|
||||
}
|
||||
if (dto.role && dto.role !== "ADMIN") {
|
||||
throw new BadRequestException("No puede quitarse su propio rol de administrador");
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
return await this.prisma.user.update({
|
||||
where: { id },
|
||||
data: {
|
||||
name: dto.name,
|
||||
email: dto.email,
|
||||
role: dto.role,
|
||||
active: dto.active,
|
||||
},
|
||||
select: safeSelect,
|
||||
});
|
||||
} catch (e) {
|
||||
throw this.mapError(e);
|
||||
}
|
||||
}
|
||||
|
||||
async resetPassword(id: string, password: string): Promise<SafeUserRow> {
|
||||
await this.ensureExists(id);
|
||||
const passwordHash = await argon2.hash(password);
|
||||
return this.prisma.user.update({
|
||||
where: { id },
|
||||
data: { passwordHash },
|
||||
select: safeSelect,
|
||||
});
|
||||
}
|
||||
|
||||
private async ensureExists(id: string): Promise<void> {
|
||||
const found = await this.prisma.user.findUnique({ where: { id }, select: { id: true } });
|
||||
if (!found) throw new NotFoundException(`Usuario ${id} no encontrado`);
|
||||
}
|
||||
|
||||
private mapError(e: unknown): Error {
|
||||
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === "P2002") {
|
||||
return new ConflictException("Ya existe un usuario con ese correo");
|
||||
}
|
||||
return e as Error;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user