feat(frontend): Administração > Usuários e Perfis (convidar, trocar papel)
POST /users (convidar) fecha uma lacuna real: até aqui só dava pra adicionar alguém a um tenant criando o tenant inteiro ou via script. Se o e-mail já existe na plataforma, só adiciona membership+role (sem tocar na senha); se não existe, cria a conta com senha gerada e revelada uma única vez. PATCH /users/:id/role troca o papel (substitui, 1 papel por tenant). GET /roles novo (tenant-facing) — versão de /platform/roles filtrada só pras roles de escopo TENANT, sem expor que platform_super_admin existe. Frontend: /app/administracao/usuarios (convidar com papel, trocar papel de qualquer membro exceto o próprio usuário logado) e /perfis (o que cada papel pode fazer, só leitura). Testado ponta a ponta contra a API real: convidada conta nova com senha revelada, convidado usuário com papel Agente, trocado pra Supervisor via PATCH, confirmado persistido. Perfis mostra as 3 roles de tenant certas. Smoke test nas 19 telas do tenant + 10 telas platform, todas 200. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BFaBaBSQGhyXGEgtTYZGV8
This commit is contained in:
20
apps/api/src/users/dto/invite-user.dto.ts
Normal file
20
apps/api/src/users/dto/invite-user.dto.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { IsEmail, IsIn, IsString, MaxLength } from "class-validator";
|
||||
|
||||
const TENANT_ROLE_KEYS = ["tenant_admin", "supervisor", "agent"];
|
||||
|
||||
export class InviteUserDto {
|
||||
@IsEmail()
|
||||
email!: string;
|
||||
|
||||
@IsString()
|
||||
@MaxLength(120)
|
||||
name!: string;
|
||||
|
||||
@IsIn(TENANT_ROLE_KEYS)
|
||||
roleKey!: string;
|
||||
}
|
||||
|
||||
export class UpdateUserRoleDto {
|
||||
@IsIn(TENANT_ROLE_KEYS)
|
||||
roleKey!: string;
|
||||
}
|
||||
35
apps/api/src/users/roles.controller.ts
Normal file
35
apps/api/src/users/roles.controller.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { Controller, Get, UseGuards } from "@nestjs/common";
|
||||
import { getPrismaClient } from "@b2bcall/database";
|
||||
import { JwtAuthGuard } from "../common/guards/jwt-auth.guard";
|
||||
import { PermissionGuard } from "../common/guards/permission.guard";
|
||||
import { RequirePermission } from "../common/decorators/require-permission.decorator";
|
||||
|
||||
/**
|
||||
* "Administração > Perfis" (agente.md secao 169) — versão tenant-facing
|
||||
* do catálogo de roles, só as de escopo TENANT (Tenant Admin/Supervisor/
|
||||
* Agente). Diferente de `/platform/roles` (todas as roles, inclusive
|
||||
* `platform_super_admin`, só platform admin) — um tenant não precisa
|
||||
* saber que role de plataforma existe, só o que pode atribuir aos
|
||||
* próprios usuários. `roles`/`permissions` não têm RLS (catálogo global
|
||||
* do seed), mas o filtro por `scope: "TENANT"` já é suficiente aqui.
|
||||
*/
|
||||
@UseGuards(JwtAuthGuard, PermissionGuard)
|
||||
@Controller("roles")
|
||||
export class RolesController {
|
||||
@RequirePermission("users.manage")
|
||||
@Get()
|
||||
async list() {
|
||||
const prisma = getPrismaClient();
|
||||
const roles = await prisma.role.findMany({
|
||||
where: { scope: "TENANT" },
|
||||
include: { rolePermissions: { include: { permission: true } } },
|
||||
orderBy: { name: "asc" },
|
||||
});
|
||||
|
||||
return roles.map((r) => ({
|
||||
key: r.key,
|
||||
name: r.name,
|
||||
permissionKeys: r.rolePermissions.map((rp) => rp.permission.key),
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -1,21 +1,19 @@
|
||||
import { Controller, Get, UseGuards } from "@nestjs/common";
|
||||
import { Body, ConflictException, Controller, ForbiddenException, Get, NotFoundException, Param, Patch, Post, UseGuards } from "@nestjs/common";
|
||||
import { getPrismaClient, withTenantContext } from "@b2bcall/database";
|
||||
import type { AccessTokenClaims } from "@b2bcall/auth";
|
||||
import { recordAuditEvent, hashPassword, type AccessTokenClaims } from "@b2bcall/auth";
|
||||
import { generateStrongPassword } from "@b2bcall/shared";
|
||||
import { JwtAuthGuard } from "../common/guards/jwt-auth.guard";
|
||||
import { PermissionGuard } from "../common/guards/permission.guard";
|
||||
import { RequirePermission } from "../common/decorators/require-permission.decorator";
|
||||
import { CurrentUser } from "../common/decorators/current-user.decorator";
|
||||
import { InviteUserDto, UpdateUserRoleDto } from "./dto/invite-user.dto";
|
||||
|
||||
/**
|
||||
* Usuários do tenant ativo — hoje só o list existe, e só pra alimentar o
|
||||
* seletor de "Call Center > Agentes" (provisionar agente precisa de um
|
||||
* `userId` já existente, secao 45). Convite/criação de usuário continua
|
||||
* só via script (mesma lacuna documentada na PHASE 22 pra tenant/plano).
|
||||
* Gated por `users.manage` (não `agents.manage`) porque listar identidade
|
||||
* de login de outras pessoas é uma ação de administração de usuários, não
|
||||
* de call center — um supervisor com `agents.manage` mas sem
|
||||
* `users.manage` gerencia agentes já provisionados, mas não lista
|
||||
* usuários pra provisionar um novo.
|
||||
* Usuários do tenant ativo (agente.md secao 169 "Administração > Usuários/
|
||||
* Perfis"). Convite (secao 141) fecha a lacuna documentada desde a PHASE
|
||||
* 22/29: até aqui só dava pra criar usuário junto com o tenant inteiro
|
||||
* (Platform > Clientes > Tenants) ou via script — nenhuma forma de um
|
||||
* Tenant Admin adicionar um colega ao próprio tenant.
|
||||
*/
|
||||
@UseGuards(JwtAuthGuard, PermissionGuard)
|
||||
@Controller("users")
|
||||
@@ -33,9 +31,127 @@ export class UsersController {
|
||||
orderBy: { createdAt: "asc" },
|
||||
}),
|
||||
);
|
||||
const activeMemberships = memberships.filter((m) => m.user.deletedAt == null);
|
||||
|
||||
return memberships
|
||||
.filter((m) => m.user.deletedAt == null)
|
||||
.map((m) => ({ id: m.user.id, email: m.user.email, name: m.user.name, status: m.user.status }));
|
||||
const userIds = activeMemberships.map((m) => m.user.id);
|
||||
const roles = userIds.length
|
||||
? await prisma.userRole.findMany({
|
||||
where: { userId: { in: userIds }, tenantId },
|
||||
include: { role: true },
|
||||
})
|
||||
: [];
|
||||
const roleByUserId = new Map(roles.map((r) => [r.userId, { key: r.role.key, name: r.role.name }]));
|
||||
|
||||
return activeMemberships.map((m) => ({
|
||||
id: m.user.id,
|
||||
email: m.user.email,
|
||||
name: m.user.name,
|
||||
status: m.user.status,
|
||||
role: roleByUserId.get(m.user.id) ?? null,
|
||||
}));
|
||||
}
|
||||
|
||||
/** Convidar = criar o usuário se o e-mail ainda não existe (senha
|
||||
* gerada, revelada uma vez só na resposta — mesmo padrão de
|
||||
* `TenantsController.create`) ou só adicionar a membership+role se o
|
||||
* e-mail já é de um usuário existente (sem tocar na senha dele). */
|
||||
@RequirePermission("users.manage")
|
||||
@Post()
|
||||
async invite(@CurrentUser() user: AccessTokenClaims, @Body() dto: InviteUserDto) {
|
||||
const prisma = getPrismaClient();
|
||||
const tenantId = user.tenantId!;
|
||||
|
||||
const role = await prisma.role.findUnique({ where: { key: dto.roleKey } });
|
||||
if (!role || role.scope !== "TENANT") {
|
||||
throw new ForbiddenException("roleKey precisa ser uma role de escopo TENANT");
|
||||
}
|
||||
|
||||
const existingUser = await prisma.user.findUnique({ where: { email: dto.email } });
|
||||
|
||||
if (existingUser) {
|
||||
const existingMembership = await withTenantContext(prisma, tenantId, (tx) =>
|
||||
tx.tenantMembership.findUnique({ where: { tenantId_userId: { tenantId, userId: existingUser.id } } }),
|
||||
);
|
||||
if (existingMembership) {
|
||||
throw new ConflictException("Este usuário já é membro deste tenant");
|
||||
}
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.$executeRaw`SELECT set_config('app.current_tenant_id', ${tenantId}, true)`;
|
||||
await tx.tenantMembership.create({ data: { tenantId, userId: existingUser.id } });
|
||||
await tx.userRole.create({ data: { userId: existingUser.id, roleId: role.id, tenantId } });
|
||||
});
|
||||
|
||||
await recordAuditEvent(prisma, {
|
||||
action: "USER_ADDED_TO_TENANT",
|
||||
tenantId,
|
||||
userId: user.sub,
|
||||
entityType: "user",
|
||||
entityId: existingUser.id,
|
||||
after: { email: existingUser.email, roleKey: role.key },
|
||||
});
|
||||
|
||||
return { user: { id: existingUser.id, email: existingUser.email, name: existingUser.name }, temporaryPassword: null };
|
||||
}
|
||||
|
||||
const temporaryPassword = generateStrongPassword();
|
||||
const passwordHash = await hashPassword(temporaryPassword);
|
||||
|
||||
const created = await prisma.$transaction(async (tx) => {
|
||||
const newUser = await tx.user.create({
|
||||
data: { email: dto.email, passwordHash, name: dto.name, mustChangePassword: true },
|
||||
});
|
||||
await tx.$executeRaw`SELECT set_config('app.current_tenant_id', ${tenantId}, true)`;
|
||||
await tx.tenantMembership.create({ data: { tenantId, userId: newUser.id } });
|
||||
await tx.userRole.create({ data: { userId: newUser.id, roleId: role.id, tenantId } });
|
||||
return newUser;
|
||||
});
|
||||
|
||||
await recordAuditEvent(prisma, {
|
||||
action: "USER_INVITE",
|
||||
tenantId,
|
||||
userId: user.sub,
|
||||
entityType: "user",
|
||||
entityId: created.id,
|
||||
after: { email: created.email, roleKey: role.key },
|
||||
});
|
||||
|
||||
return { user: { id: created.id, email: created.email, name: created.name }, temporaryPassword };
|
||||
}
|
||||
|
||||
@RequirePermission("users.manage")
|
||||
@Patch(":id/role")
|
||||
async updateRole(@CurrentUser() user: AccessTokenClaims, @Param("id") id: string, @Body() dto: UpdateUserRoleDto) {
|
||||
const prisma = getPrismaClient();
|
||||
const tenantId = user.tenantId!;
|
||||
|
||||
const membership = await withTenantContext(prisma, tenantId, (tx) =>
|
||||
tx.tenantMembership.findUnique({ where: { tenantId_userId: { tenantId, userId: id } } }),
|
||||
);
|
||||
if (!membership) throw new NotFoundException("Usuário não pertence a este tenant");
|
||||
|
||||
const role = await prisma.role.findUnique({ where: { key: dto.roleKey } });
|
||||
if (!role || role.scope !== "TENANT") {
|
||||
throw new ForbiddenException("roleKey precisa ser uma role de escopo TENANT");
|
||||
}
|
||||
|
||||
// Simplificação deliberada: 1 role por usuário por tenant — trocar
|
||||
// substitui, não acumula (o schema permite várias, mas a UI não
|
||||
// oferece combinar papéis nesta primeira versão).
|
||||
await prisma.$transaction([
|
||||
prisma.userRole.deleteMany({ where: { userId: id, tenantId } }),
|
||||
prisma.userRole.create({ data: { userId: id, roleId: role.id, tenantId } }),
|
||||
]);
|
||||
|
||||
await recordAuditEvent(prisma, {
|
||||
action: "USER_ROLE_UPDATE",
|
||||
tenantId,
|
||||
userId: user.sub,
|
||||
entityType: "user",
|
||||
entityId: id,
|
||||
after: { roleKey: role.key },
|
||||
});
|
||||
|
||||
return { id, role: { key: role.key, name: role.name } };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { UsersController } from "./users.controller";
|
||||
import { RolesController } from "./roles.controller";
|
||||
|
||||
@Module({
|
||||
controllers: [UsersController],
|
||||
controllers: [UsersController, RolesController],
|
||||
})
|
||||
export class UsersModule {}
|
||||
|
||||
Reference in New Issue
Block a user