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:
2026-07-23 12:02:00 -07:00
co-authored by Claude Opus 4.8
parent d9f9e8a920
commit 74e2ad8bcd
21 changed files with 912 additions and 24 deletions
+21
View File
@@ -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;
}
+7
View File
@@ -0,0 +1,7 @@
import { IsString, MinLength } from "class-validator";
export class ResetPasswordDto {
@IsString()
@MinLength(8)
password!: string;
}
+22
View File
@@ -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;
}
+72
View File
@@ -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;
}
}
+2
View File
@@ -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 {}
+111 -2
View File
@@ -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;
}
}