diff --git a/TODO.md b/TODO.md index bc12158..f8fbe90 100644 --- a/TODO.md +++ b/TODO.md @@ -1353,6 +1353,49 @@ secao 134-139, 168) definido na especificação além do nome, mesma pendência já documentada na PHASE 34 +## PHASE 37 — Frontend: Administração > Usuários e Perfis (tenant) +(agente.md secao 141-145, 169) +- [x] **Lacuna de backend fechada primeiro**: `POST /users` (convidar) — + até aqui só dava pra adicionar um usuário a um tenant criando o + tenant inteiro (Platform > Clientes > Tenants) ou via script. Se o + e-mail já existe na plataforma, só adiciona `TenantMembership` + + `UserRole` (sem tocar na senha da conta existente); se não existe, + cria o `User` com senha gerada e revelada uma única vez (mesmo + padrão de `TenantsController.create`). `PATCH /users/:id/role` + troca o papel (substitui, não acumula — simplificação deliberada de + "1 papel por tenant" mesmo o schema permitindo várias `UserRole` + por par usuário/tenant). +- [x] `GET /roles` (novo, tenant-facing) — versão filtrada de `/platform/ + roles` só com as roles de escopo TENANT (Tenant Admin/Supervisor/ + Agente); um tenant não precisa saber que `platform_super_admin` + existe. +- [x] Frontend: `/app/administracao/usuarios` (convidar com papel, + revela senha só quando é conta nova, trocar papel de qualquer + membro exceto o próprio usuário logado — trava de segurança + deliberada contra se auto-rebaixar/trancar fora sem querer). + `/app/administracao/perfis` — cards com o que cada papel pode + fazer, só leitura, mesmo componente visual de `Sistema > + Permissões` (platform) mas com os dados filtrados certos. +- [x] Testado ponta a ponta contra a API real: convidado + `supervisor@acme.b2bcall.local` (conta nova, senha revelada) com + papel Supervisor, convidado `agente1@acme.b2bcall.local` com papel + Agente, depois trocado o papel dele pra Supervisor via `PATCH + .../role` — confirmado via `GET /users` que persistiu. Perfis + mostra as 3 roles de tenant com as permissions certas, sem + `platform_super_admin` na lista. Smoke test de regressão nas 19 + telas do tenant + 10 telas platform, todas 200 (alguns timeouts de + navegação intermitentes no script de teste — reproduzidos + isoladamente como falso-positivo do harness, não da aplicação; + toda rota confirmada 200 numa reexecução limpa). +- [ ] Sem remover um usuário do tenant (só trocar papel) — sem endpoint + de "remover membership" ainda; desabilitar a conta inteira já + existe em Platform > Sistema > Usuários, mas isso afeta todos os + tenants dela, não só este +- [ ] Sem trava contra remover o último Tenant Admin de um tenant (ex.: + trocar o papel do único admin pra Agente deixaria o tenant sem + ninguém com `users.manage`) — não implementado, mesma classe de + risco documentada em outras ações administrativas desta sessão + --- ## Riscos conhecidos diff --git a/apps/api/src/users/dto/invite-user.dto.ts b/apps/api/src/users/dto/invite-user.dto.ts new file mode 100644 index 0000000..a2309b3 --- /dev/null +++ b/apps/api/src/users/dto/invite-user.dto.ts @@ -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; +} diff --git a/apps/api/src/users/roles.controller.ts b/apps/api/src/users/roles.controller.ts new file mode 100644 index 0000000..7728c7d --- /dev/null +++ b/apps/api/src/users/roles.controller.ts @@ -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), + })); + } +} diff --git a/apps/api/src/users/users.controller.ts b/apps/api/src/users/users.controller.ts index 7755f83..3f98820 100644 --- a/apps/api/src/users/users.controller.ts +++ b/apps/api/src/users/users.controller.ts @@ -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 } }; } } diff --git a/apps/api/src/users/users.module.ts b/apps/api/src/users/users.module.ts index 9477c08..ebc3c7a 100644 --- a/apps/api/src/users/users.module.ts +++ b/apps/api/src/users/users.module.ts @@ -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 {} diff --git a/apps/frontend/.impeccable/review/admin-perfis-desktop.png b/apps/frontend/.impeccable/review/admin-perfis-desktop.png new file mode 100644 index 0000000..d30a0b0 Binary files /dev/null and b/apps/frontend/.impeccable/review/admin-perfis-desktop.png differ diff --git a/apps/frontend/.impeccable/review/admin-usuarios-desktop.png b/apps/frontend/.impeccable/review/admin-usuarios-desktop.png new file mode 100644 index 0000000..0a7df3a Binary files /dev/null and b/apps/frontend/.impeccable/review/admin-usuarios-desktop.png differ diff --git a/apps/frontend/.impeccable/review/admin-usuarios-invited-desktop.png b/apps/frontend/.impeccable/review/admin-usuarios-invited-desktop.png new file mode 100644 index 0000000..75d01b0 Binary files /dev/null and b/apps/frontend/.impeccable/review/admin-usuarios-invited-desktop.png differ diff --git a/apps/frontend/.impeccable/review/admin-usuarios-list-desktop.png b/apps/frontend/.impeccable/review/admin-usuarios-list-desktop.png new file mode 100644 index 0000000..08898ad Binary files /dev/null and b/apps/frontend/.impeccable/review/admin-usuarios-list-desktop.png differ diff --git a/apps/frontend/src/app/app/administracao/perfis/page.tsx b/apps/frontend/src/app/app/administracao/perfis/page.tsx new file mode 100644 index 0000000..b4a8625 --- /dev/null +++ b/apps/frontend/src/app/app/administracao/perfis/page.tsx @@ -0,0 +1,15 @@ +import { requireSession } from "@/lib/session"; +import { apiFetch } from "@/lib/api"; +import { PerfisView } from "./perfis-view"; + +interface TenantRole { + key: string; + name: string; + permissionKeys: string[]; +} + +export default async function PerfisPage() { + const session = await requireSession(); + const roles = await apiFetch("/roles", session.accessToken); + return ; +} diff --git a/apps/frontend/src/app/app/administracao/perfis/perfis-view.tsx b/apps/frontend/src/app/app/administracao/perfis/perfis-view.tsx new file mode 100644 index 0000000..e3264cc --- /dev/null +++ b/apps/frontend/src/app/app/administracao/perfis/perfis-view.tsx @@ -0,0 +1,41 @@ +import { ShieldCheck } from "lucide-react"; +import { Panel } from "@/components/ui/panel"; +import { Pill } from "@/components/ui/pill"; + +interface TenantRole { + key: string; + name: string; + permissionKeys: string[]; +} + +export function PerfisView({ roles }: { roles: TenantRole[] }) { + return ( +
+
+

Perfis

+

+ O que cada papel pode fazer neste tenant (agente.md secao 142-145) — use isso pra decidir qual papel dar + a alguém em Administração > Usuários. Papéis são definidos pelo sistema, sem criar um customizado + ainda. +

+
+ +
+ {roles.map((role) => ( + +
+ +

{role.name}

+ {role.permissionKeys.length} permission(ões) +
+
+ {role.permissionKeys.map((key) => ( + {key} + ))} +
+
+ ))} +
+
+ ); +} diff --git a/apps/frontend/src/app/app/administracao/usuarios/actions.ts b/apps/frontend/src/app/app/administracao/usuarios/actions.ts new file mode 100644 index 0000000..4e73269 --- /dev/null +++ b/apps/frontend/src/app/app/administracao/usuarios/actions.ts @@ -0,0 +1,52 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { requireSession } from "@/lib/session"; +import { apiFetch, ApiError } from "@/lib/api"; + +function extractErrorMessage(err: unknown): string { + if (err instanceof ApiError) { + try { + const parsed = JSON.parse(err.message); + if (Array.isArray(parsed.message)) return parsed.message.join(" "); + if (typeof parsed.message === "string") return parsed.message; + } catch { + // corpo não era JSON + } + return err.message || "Falha inesperada na API."; + } + return "Falha inesperada. Tente novamente."; +} + +export interface InviteUserInput { + email: string; + name: string; + roleKey: string; +} + +export interface InviteUserResult { + user: { id: string; email: string; name: string }; + temporaryPassword: string | null; +} + +export async function inviteUser(input: InviteUserInput): Promise<({ ok: true } & InviteUserResult) | { ok: false; error: string }> { + const session = await requireSession(); + try { + const result = await apiFetch("/users", session.accessToken, { method: "POST", body: JSON.stringify(input) }); + revalidatePath("/app/administracao/usuarios"); + return { ok: true, ...result }; + } catch (err) { + return { ok: false, error: extractErrorMessage(err) }; + } +} + +export async function updateUserRole(id: string, roleKey: string): Promise<{ ok: true } | { ok: false; error: string }> { + const session = await requireSession(); + try { + await apiFetch(`/users/${id}/role`, session.accessToken, { method: "PATCH", body: JSON.stringify({ roleKey }) }); + revalidatePath("/app/administracao/usuarios"); + return { ok: true }; + } catch (err) { + return { ok: false, error: extractErrorMessage(err) }; + } +} diff --git a/apps/frontend/src/app/app/administracao/usuarios/page.tsx b/apps/frontend/src/app/app/administracao/usuarios/page.tsx new file mode 100644 index 0000000..7732fed --- /dev/null +++ b/apps/frontend/src/app/app/administracao/usuarios/page.tsx @@ -0,0 +1,11 @@ +import { requireSession } from "@/lib/session"; +import { apiFetch } from "@/lib/api"; +import type { TenantMember } from "@/lib/admin-types"; +import { UsuariosView } from "./usuarios-view"; + +export default async function AdminUsuariosPage() { + const session = await requireSession(); + const members = await apiFetch("/users", session.accessToken); + const me = await apiFetch<{ id: string }>("/auth/me", session.accessToken); + return ; +} diff --git a/apps/frontend/src/app/app/administracao/usuarios/usuarios-view.tsx b/apps/frontend/src/app/app/administracao/usuarios/usuarios-view.tsx new file mode 100644 index 0000000..4abf572 --- /dev/null +++ b/apps/frontend/src/app/app/administracao/usuarios/usuarios-view.tsx @@ -0,0 +1,213 @@ +"use client"; + +import { useState, useTransition } from "react"; +import { useRouter } from "next/navigation"; +import { Check, Plus, User as UserIcon, X } from "lucide-react"; +import { Panel, PanelHeader } from "@/components/ui/panel"; +import { Button } from "@/components/ui/button"; +import { Input, Select, FieldLabel } from "@/components/ui/input"; +import { Pill } from "@/components/ui/pill"; +import { SecretReveal } from "@/components/ui/secret-reveal"; +import { TBody, TD, TH, THead, TR, Table } from "@/components/ui/table"; +import { TENANT_ROLE_OPTIONS, type TenantMember } from "@/lib/admin-types"; +import { inviteUser, updateUserRole } from "./actions"; + +export function UsuariosView({ members, currentUserId }: { members: TenantMember[]; currentUserId: string }) { + const [showForm, setShowForm] = useState(false); + + return ( +
+
+
+

Usuários

+

+ Quem tem acesso a este tenant (agente.md secao 141, 169) — convidar um e-mail novo cria a conta (senha + revelada uma única vez); um e-mail já existente na plataforma só ganha acesso a este tenant, sem mexer + na senha dele. +

+
+ +
+ + {showForm && setShowForm(false)} />} + + + + + + + + + + + + + + {members.map((m) => ( + + ))} + +
NomeE-mailStatusPapel
+
+
+ ); +} + +function InviteForm({ onDone }: { onDone: () => void }) { + const router = useRouter(); + const [email, setEmail] = useState(""); + const [name, setName] = useState(""); + const [roleKey, setRoleKey] = useState(TENANT_ROLE_OPTIONS[2].key); + const [error, setError] = useState(null); + const [pending, startTransition] = useTransition(); + const [created, setCreated] = useState<{ email: string; temporaryPassword: string | null } | null>(null); + + function onSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(null); + if (!email.trim() || !name.trim()) { + setError("E-mail e nome são obrigatórios."); + return; + } + startTransition(async () => { + const result = await inviteUser({ email: email.trim(), name: name.trim(), roleKey }); + if (!result.ok) { + setError(result.error); + return; + } + setCreated({ email: result.user.email, temporaryPassword: result.temporaryPassword }); + router.refresh(); + }); + } + + if (created) { + return ( + +
+ + + +
+

+ {created.temporaryPassword ? "Usuário criado" : "Acesso concedido"}: {created.email} +

+ {!created.temporaryPassword && ( +

E-mail já existia na plataforma — senha dele não foi alterada.

+ )} +
+
+ {created.temporaryPassword && } +
+ +
+
+ ); + } + + return ( + +
+
+
+ Nome + setName(e.target.value)} disabled={pending} /> +
+
+ E-mail + setEmail(e.target.value)} disabled={pending} /> +
+
+ Papel + +
+
+ {error && ( +

+ {error} +

+ )} +
+ +
+
+
+ ); +} + +function MemberRow({ member, isSelf }: { member: TenantMember; isSelf: boolean }) { + const router = useRouter(); + const [editing, setEditing] = useState(false); + const [roleKey, setRoleKey] = useState(member.role?.key ?? TENANT_ROLE_OPTIONS[2].key); + const [error, setError] = useState(null); + const [pending, startTransition] = useTransition(); + + function onSave() { + setError(null); + startTransition(async () => { + const result = await updateUserRole(member.id, roleKey); + if (!result.ok) { + setError(result.error); + return; + } + setEditing(false); + router.refresh(); + }); + } + + return ( + + + + + {member.name} + + + {member.email} + + {member.status === "ACTIVE" ? "Ativo" : "Desabilitado"} + + + {editing ? ( +
+ + + + {error && {error}} +
+ ) : ( +
+ {member.role?.name ?? "sem papel"} + {!isSelf && ( + + )} +
+ )} + + + ); +} diff --git a/apps/frontend/src/components/tenant-shell/nav-data.ts b/apps/frontend/src/components/tenant-shell/nav-data.ts index cd5845a..8e0ad09 100644 --- a/apps/frontend/src/components/tenant-shell/nav-data.ts +++ b/apps/frontend/src/components/tenant-shell/nav-data.ts @@ -162,6 +162,18 @@ export const TENANT_NAV: NavSection[] = [ { label: "Administração", icon: ShieldCheck, - children: [{ label: "Usuários" }, { label: "Perfis" }, { label: "Configurações" }], + children: [ + { + label: "Usuários", + href: "/app/administracao/usuarios", + description: "Quem tem acesso a este tenant — convidar e trocar papel", + }, + { + label: "Perfis", + href: "/app/administracao/perfis", + description: "O que cada papel pode fazer neste tenant", + }, + { label: "Configurações" }, + ], }, ]; diff --git a/apps/frontend/src/lib/admin-types.ts b/apps/frontend/src/lib/admin-types.ts new file mode 100644 index 0000000..1282b12 --- /dev/null +++ b/apps/frontend/src/lib/admin-types.ts @@ -0,0 +1,18 @@ +export interface RoleInfo { + key: string; + name: string; +} + +export interface TenantMember { + id: string; + email: string; + name: string; + status: "ACTIVE" | "DISABLED"; + role: RoleInfo | null; +} + +export const TENANT_ROLE_OPTIONS: { key: string; name: string }[] = [ + { key: "tenant_admin", name: "Tenant Admin" }, + { key: "supervisor", name: "Supervisor" }, + { key: "agent", name: "Agente" }, +];