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:
43
TODO.md
43
TODO.md
@@ -1353,6 +1353,49 @@ secao 134-139, 168)
|
|||||||
definido na especificação além do nome, mesma pendência já
|
definido na especificação além do nome, mesma pendência já
|
||||||
documentada na PHASE 34
|
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
|
## Riscos conhecidos
|
||||||
|
|||||||
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 { 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 { JwtAuthGuard } from "../common/guards/jwt-auth.guard";
|
||||||
import { PermissionGuard } from "../common/guards/permission.guard";
|
import { PermissionGuard } from "../common/guards/permission.guard";
|
||||||
import { RequirePermission } from "../common/decorators/require-permission.decorator";
|
import { RequirePermission } from "../common/decorators/require-permission.decorator";
|
||||||
import { CurrentUser } from "../common/decorators/current-user.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
|
* Usuários do tenant ativo (agente.md secao 169 "Administração > Usuários/
|
||||||
* seletor de "Call Center > Agentes" (provisionar agente precisa de um
|
* Perfis"). Convite (secao 141) fecha a lacuna documentada desde a PHASE
|
||||||
* `userId` já existente, secao 45). Convite/criação de usuário continua
|
* 22/29: até aqui só dava pra criar usuário junto com o tenant inteiro
|
||||||
* só via script (mesma lacuna documentada na PHASE 22 pra tenant/plano).
|
* (Platform > Clientes > Tenants) ou via script — nenhuma forma de um
|
||||||
* Gated por `users.manage` (não `agents.manage`) porque listar identidade
|
* Tenant Admin adicionar um colega ao próprio tenant.
|
||||||
* 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.
|
|
||||||
*/
|
*/
|
||||||
@UseGuards(JwtAuthGuard, PermissionGuard)
|
@UseGuards(JwtAuthGuard, PermissionGuard)
|
||||||
@Controller("users")
|
@Controller("users")
|
||||||
@@ -33,9 +31,127 @@ export class UsersController {
|
|||||||
orderBy: { createdAt: "asc" },
|
orderBy: { createdAt: "asc" },
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
const activeMemberships = memberships.filter((m) => m.user.deletedAt == null);
|
||||||
|
|
||||||
return memberships
|
const userIds = activeMemberships.map((m) => m.user.id);
|
||||||
.filter((m) => m.user.deletedAt == null)
|
const roles = userIds.length
|
||||||
.map((m) => ({ id: m.user.id, email: m.user.email, name: m.user.name, status: m.user.status }));
|
? 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 { Module } from "@nestjs/common";
|
||||||
import { UsersController } from "./users.controller";
|
import { UsersController } from "./users.controller";
|
||||||
|
import { RolesController } from "./roles.controller";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
controllers: [UsersController],
|
controllers: [UsersController, RolesController],
|
||||||
})
|
})
|
||||||
export class UsersModule {}
|
export class UsersModule {}
|
||||||
|
|||||||
BIN
apps/frontend/.impeccable/review/admin-perfis-desktop.png
Normal file
BIN
apps/frontend/.impeccable/review/admin-perfis-desktop.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 127 KiB |
BIN
apps/frontend/.impeccable/review/admin-usuarios-desktop.png
Normal file
BIN
apps/frontend/.impeccable/review/admin-usuarios-desktop.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 91 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 117 KiB |
BIN
apps/frontend/.impeccable/review/admin-usuarios-list-desktop.png
Normal file
BIN
apps/frontend/.impeccable/review/admin-usuarios-list-desktop.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 99 KiB |
15
apps/frontend/src/app/app/administracao/perfis/page.tsx
Normal file
15
apps/frontend/src/app/app/administracao/perfis/page.tsx
Normal file
@@ -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<TenantRole[]>("/roles", session.accessToken);
|
||||||
|
return <PerfisView roles={roles} />;
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-lg font-semibold text-foreground">Perfis</h1>
|
||||||
|
<p className="mt-1 max-w-2xl text-sm text-muted-foreground">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{roles.map((role) => (
|
||||||
|
<Panel key={role.key}>
|
||||||
|
<div className="flex items-center gap-2 border-b border-border px-5 py-4">
|
||||||
|
<ShieldCheck className="h-4 w-4 text-muted-foreground" aria-hidden />
|
||||||
|
<h2 className="text-sm font-semibold text-foreground">{role.name}</h2>
|
||||||
|
<span className="text-xs text-muted-foreground">{role.permissionKeys.length} permission(ões)</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-1.5 p-5">
|
||||||
|
{role.permissionKeys.map((key) => (
|
||||||
|
<Pill key={key}>{key}</Pill>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
52
apps/frontend/src/app/app/administracao/usuarios/actions.ts
Normal file
52
apps/frontend/src/app/app/administracao/usuarios/actions.ts
Normal file
@@ -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<InviteUserResult>("/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<void>(`/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) };
|
||||||
|
}
|
||||||
|
}
|
||||||
11
apps/frontend/src/app/app/administracao/usuarios/page.tsx
Normal file
11
apps/frontend/src/app/app/administracao/usuarios/page.tsx
Normal file
@@ -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<TenantMember[]>("/users", session.accessToken);
|
||||||
|
const me = await apiFetch<{ id: string }>("/auth/me", session.accessToken);
|
||||||
|
return <UsuariosView members={members} currentUserId={me.id} />;
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-lg font-semibold text-foreground">Usuários</h1>
|
||||||
|
<p className="mt-1 max-w-2xl text-sm text-muted-foreground">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button type="button" onClick={() => setShowForm((s) => !s)}>
|
||||||
|
{showForm ? <X className="h-4 w-4" aria-hidden /> : <Plus className="h-4 w-4" aria-hidden />}
|
||||||
|
{showForm ? "Cancelar" : "Convidar usuário"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showForm && <InviteForm onDone={() => setShowForm(false)} />}
|
||||||
|
|
||||||
|
<Panel>
|
||||||
|
<PanelHeader title="Usuários deste tenant" description={`${members.length} usuário(s)`} />
|
||||||
|
<Table>
|
||||||
|
<THead>
|
||||||
|
<TR>
|
||||||
|
<TH>Nome</TH>
|
||||||
|
<TH>E-mail</TH>
|
||||||
|
<TH>Status</TH>
|
||||||
|
<TH>Papel</TH>
|
||||||
|
</TR>
|
||||||
|
</THead>
|
||||||
|
<TBody>
|
||||||
|
{members.map((m) => (
|
||||||
|
<MemberRow key={m.id} member={m} isSelf={m.id === currentUserId} />
|
||||||
|
))}
|
||||||
|
</TBody>
|
||||||
|
</Table>
|
||||||
|
</Panel>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string | null>(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 (
|
||||||
|
<Panel className="space-y-4 p-5">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-status-green/15 text-status-green">
|
||||||
|
<Check className="h-5 w-5" aria-hidden />
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold text-foreground">
|
||||||
|
{created.temporaryPassword ? "Usuário criado" : "Acesso concedido"}: {created.email}
|
||||||
|
</p>
|
||||||
|
{!created.temporaryPassword && (
|
||||||
|
<p className="text-sm text-muted-foreground">E-mail já existia na plataforma — senha dele não foi alterada.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{created.temporaryPassword && <SecretReveal label="Senha temporária" value={created.temporaryPassword} />}
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button type="button" size="sm" onClick={onDone}>
|
||||||
|
Fechar
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Panel className="p-5">
|
||||||
|
<form onSubmit={onSubmit} noValidate className="space-y-4">
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||||
|
<div>
|
||||||
|
<FieldLabel htmlFor="iu-name">Nome</FieldLabel>
|
||||||
|
<Input id="iu-name" value={name} onChange={(e) => setName(e.target.value)} disabled={pending} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<FieldLabel htmlFor="iu-email">E-mail</FieldLabel>
|
||||||
|
<Input id="iu-email" type="email" value={email} onChange={(e) => setEmail(e.target.value)} disabled={pending} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<FieldLabel htmlFor="iu-role">Papel</FieldLabel>
|
||||||
|
<Select id="iu-role" value={roleKey} onChange={(e) => setRoleKey(e.target.value)} disabled={pending}>
|
||||||
|
{TENANT_ROLE_OPTIONS.map((r) => (
|
||||||
|
<option key={r.key} value={r.key}>
|
||||||
|
{r.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{error && (
|
||||||
|
<p role="alert" className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button type="submit" disabled={pending}>
|
||||||
|
{pending ? "Convidando…" : "Convidar"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string | null>(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 (
|
||||||
|
<TR>
|
||||||
|
<TD>
|
||||||
|
<span className="flex items-center gap-2 font-medium text-foreground">
|
||||||
|
<UserIcon className="h-3.5 w-3.5 text-muted-foreground" aria-hidden />
|
||||||
|
{member.name}
|
||||||
|
</span>
|
||||||
|
</TD>
|
||||||
|
<TD className="text-muted-foreground">{member.email}</TD>
|
||||||
|
<TD>
|
||||||
|
<Pill tone={member.status === "ACTIVE" ? "accent" : "neutral"}>{member.status === "ACTIVE" ? "Ativo" : "Desabilitado"}</Pill>
|
||||||
|
</TD>
|
||||||
|
<TD>
|
||||||
|
{editing ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Select value={roleKey} onChange={(e) => setRoleKey(e.target.value)} className="h-8 w-40" disabled={pending}>
|
||||||
|
{TENANT_ROLE_OPTIONS.map((r) => (
|
||||||
|
<option key={r.key} value={r.key}>
|
||||||
|
{r.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
<Button type="button" size="sm" onClick={onSave} disabled={pending}>
|
||||||
|
{pending ? "…" : "Salvar"}
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="ghost" size="sm" onClick={() => setEditing(false)} disabled={pending}>
|
||||||
|
Cancelar
|
||||||
|
</Button>
|
||||||
|
{error && <span className="text-xs text-destructive">{error}</span>}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Pill>{member.role?.name ?? "sem papel"}</Pill>
|
||||||
|
{!isSelf && (
|
||||||
|
<Button type="button" variant="ghost" size="sm" onClick={() => setEditing(true)}>
|
||||||
|
Trocar
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TD>
|
||||||
|
</TR>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -162,6 +162,18 @@ export const TENANT_NAV: NavSection[] = [
|
|||||||
{
|
{
|
||||||
label: "Administração",
|
label: "Administração",
|
||||||
icon: ShieldCheck,
|
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" },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
18
apps/frontend/src/lib/admin-types.ts
Normal file
18
apps/frontend/src/lib/admin-types.ts
Normal file
@@ -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" },
|
||||||
|
];
|
||||||
Reference in New Issue
Block a user