Merge branch 'pr664' into reconciliacao/664

# Conflicts:
#	app/actions/team/acceptInvite.ts
#	lib/audit/actions.ts
#	supabase/baseline.sql
This commit is contained in:
Pessoa
2026-09-11 11:46:08 -03:00
26 changed files with 1636 additions and 18 deletions
@@ -0,0 +1,13 @@
---
impacto: capacidade_nova
secao: adicionado
titulo: A aba Membros mostra os convites enviados, com status e ações
---
A tela **Equipe › Membros** ganhou uma seção **Convites**. Antes, um convite pendente só aparecia numa lista efêmera dentro do modal "Convidar membros", que sumia ao fechar — não havia onde ver se um convite foi enviado, se o e-mail saiu, se expirou ou se foi ignorado.
Agora cada convite mostra e-mail, papel e perfil de interface; o status (**Pendente / Aceito / Expirado / Revogado**); a data de envio e a de expiração; e quem enviou o convite. Quando o e-mail **não saiu** — instalação sem serviço de e-mail configurado, por exemplo — a linha avisa e oferece o link do convite para copiar ali mesmo, em vez de o admin achar que enviou.
Administradores podem **reenviar**, **copiar o link** e **revogar** cada convite; gerentes veem a lista. Revogar passa a impedir o aceite mesmo com o link ainda dentro da validade.
Tudo escopado por organização (RLS). Nada muda para quem já roda: a atualização cria a tabela `team_invites` sozinha, sem edição de `.env` nem de compose.
+5 -5
View File
@@ -48,11 +48,11 @@ export async function acceptInviteAction(token: string): Promise<AcceptInviteRes
return { ok: false, error: "email_mismatch", expectedEmail: payload.email };
}
// Org/papel/convidador vêm exclusivamente do token assinado; usuário do JWT.
// O efeito mora em `lib/auth/aplicar-convite.ts` porque `/auth/confirm`
// também precisa dele: quem confirma o e-mail vindo de um convite já provou
// tudo o que este botão prova, e ficava parado numa tela pedindo mais um
// clique.
// Org, papel e convidador vêm EXCLUSIVAMENTE do token assinado; usuário do JWT.
//
// A linha de `team_invites` (a revogação e o fechamento do convite) é tratada
// dentro de `aplicarConvite`, e não aqui: `/auth/confirm` chama a MESMA função
// e precisa das duas coisas. Ver o cabeçalho de `lib/auth/aplicar-convite.ts`.
const resultado = await aplicarConvite({ userId: user.id, payload });
if (!resultado.ok) return { ok: false, error: resultado.motivo };
+32 -8
View File
@@ -1,13 +1,17 @@
import { requireSupportWrite } from "@/lib/impersonate/support";
import { issueInvite } from "@/lib/auth/issue-invite";
import { emitirConvite } from "@/lib/team/convites";
import { isServiceRoleConfigured } from "@/lib/audit";
/**
* POST /api/v1/team/invite — bulk-invite up to 20 emails.
*
* Pragmatic MVP: invitations are stateless HMAC tokens (no team_invites table).
* If a user with that email already has an active membership in the org, we
* skip with reason `already_member`. Otherwise we sign a 24h token containing
* a fresh invite_id (uuid) + email + org_id + role and email the link.
* O que viaja no e-mail é um token HMAC stateless (`lib/auth/invite-token.ts`).
* Quando o service-role está configurado, cada convite também vira uma linha em
* `team_invites` (migration 0238) — é o que a tela de Equipe lista e o que
* torna a revogação possível. Reconvidar um e-mail com convite pendente RENOVA
* a linha. Sem service-role, degrada para só-token (nada some, só não persiste).
*
* Se o e-mail já tem membership ATIVA na org, pula com `already_member`.
*
* Membership row is created at /accept-invite time (Server Action) — that's
* also when audit emits `member.accepted`. Here we audit `member.invited`.
@@ -90,8 +94,8 @@ export async function POST(req: NextRequest): Promise<Response> {
continue;
}
sent.push(
await issueInvite({
if (admin) {
const { convite, accept_url, email_dispatched } = await emitirConvite(admin, {
email,
role: inv.role,
interfaceSettings: inv.interface_settings,
@@ -100,8 +104,28 @@ export async function POST(req: NextRequest): Promise<Response> {
inviterId: authUser.id,
inviterName,
requestId,
}),
);
});
sent.push({
email,
invite_id: convite.id,
expires_at: convite.expires_at,
email_dispatched,
accept_url,
});
} else {
sent.push(
await issueInvite({
email,
role: inv.role,
interfaceSettings: inv.interface_settings,
organizationId: activeOrg.orgId,
orgName: activeOrg.name,
inviterId: authUser.id,
inviterName,
requestId,
}),
);
}
}
return ok({ sent, failed }, { status: 201, requestId });
@@ -0,0 +1,91 @@
/**
* POST /api/v1/team/invites/[id]/resend — reenvia um convite pendente.
*
* admin-only. Re-assina o token com o mesmo `invite_id`, dispara o e-mail de
* novo e renova a validade (24h a partir de agora). Audita `member.invited`
* pelo admin que clicou — é uma nova emissão do mesmo convite.
*/
import { randomUUID } from "node:crypto";
import { z } from "zod";
import type { NextRequest } from "next/server";
import { requireSupportWrite } from "@/lib/impersonate/support";
import { ok, fail } from "@/lib/api/wrappers";
import { requireRole } from "@/lib/auth/require-role";
import { isServiceRoleConfigured } from "@/lib/audit";
import { createAdminClient } from "@/lib/supabase/admin";
import { traduzir } from "@/lib/i18n/dicionario";
import { reenviarConvite, statusConvite, type ConviteDeTime } from "@/lib/team/convites";
export const dynamic = "force-dynamic";
export async function POST(
_req: NextRequest,
ctx: { params: Promise<{ id: string }> },
): Promise<Response> {
const supportDenied = await requireSupportWrite();
if (supportDenied) return supportDenied;
const requestId = randomUUID();
const { id } = await ctx.params;
const authz = await requireRole("admin", { requestId, resource: "team" });
if (!authz.ok) return authz.response;
const t = (texto: string) => traduzir(texto, authz.user.idioma);
const { user: authUser, org: activeOrg } = authz;
if (!z.string().uuid().safeParse(id).success) {
return fail("invalid_request", t("Convite inválido."), 400, { requestId });
}
if (!isServiceRoleConfigured()) {
return fail("unavailable", t("Envio de e-mail não configurado nesta instalação."), 503, {
requestId,
});
}
const admin = createAdminClient();
const { data: row, error: readErr } = await admin
.from("team_invites")
.select(
"id, organization_id, email, role, interface_settings, invited_by, inviter_name, email_dispatched, created_at, last_sent_at, resend_count, expires_at, accepted_at, revoked_at",
)
.eq("organization_id", activeOrg.orgId)
.eq("id", id)
.maybeSingle();
if (readErr) return fail("internal_error", readErr.message, 500, { requestId });
if (!row) return fail("not_found", t("Convite não encontrado."), 404, { requestId });
const convite = row as ConviteDeTime;
const status = statusConvite(convite);
if (status === "aceito") {
return fail("state_conflict", t("Este convite já foi aceito."), 409, { requestId });
}
if (status === "revogado") {
return fail("state_conflict", t("Este convite foi revogado. Envie um novo."), 409, {
requestId,
});
}
const resultado = await reenviarConvite(admin, {
convite,
orgName: activeOrg.name,
actorId: authUser.id,
actorName: authUser.full_name ?? authUser.email ?? "Um colega",
requestId,
});
if (!resultado) {
return fail("state_conflict", t("O convite mudou. Atualize e tente de novo."), 409, {
requestId,
});
}
return ok(
{
...resultado.convite,
status: statusConvite(resultado.convite),
accept_url: resultado.accept_url,
email_dispatched: resultado.email_dispatched,
},
{ requestId },
);
}
@@ -0,0 +1,78 @@
/**
* POST /api/v1/team/invites/[id]/revoke — cancela um convite pendente.
*
* admin-only. O token continua com assinatura e validade boas, mas o aceite
* (`app/actions/team/acceptInvite.ts`) checa esta linha e recusa. É o único
* jeito de cancelar um convite stateless.
*
* Idempotente: revogar de novo devolve 200 com `already_revoked: true`.
*/
import { randomUUID } from "node:crypto";
import { z } from "zod";
import type { NextRequest } from "next/server";
import { requireSupportWrite } from "@/lib/impersonate/support";
import { ok, fail } from "@/lib/api/wrappers";
import { audit } from "@/lib/audit";
import { requireRole } from "@/lib/auth/require-role";
import { createClient } from "@/lib/supabase/server";
import { traduzir } from "@/lib/i18n/dicionario";
export const dynamic = "force-dynamic";
export async function POST(
_req: NextRequest,
ctx: { params: Promise<{ id: string }> },
): Promise<Response> {
const supportDenied = await requireSupportWrite();
if (supportDenied) return supportDenied;
const requestId = randomUUID();
const { id } = await ctx.params;
const authz = await requireRole("admin", { requestId, resource: "team" });
if (!authz.ok) return authz.response;
const t = (texto: string) => traduzir(texto, authz.user.idioma);
const { user: authUser, org: activeOrg } = authz;
if (!z.string().uuid().safeParse(id).success) {
return fail("invalid_request", t("Convite inválido."), 400, { requestId });
}
const supabase = await createClient();
const { data: row, error: readErr } = await supabase
.from("team_invites")
.select("id, email, role, accepted_at, revoked_at")
.eq("organization_id", activeOrg.orgId)
.eq("id", id)
.maybeSingle();
if (readErr) return fail("internal_error", readErr.message, 500, { requestId });
if (!row) return fail("not_found", t("Convite não encontrado."), 404, { requestId });
if (row.accepted_at) {
return fail("state_conflict", t("Este convite já foi aceito."), 409, { requestId });
}
if (row.revoked_at) {
return ok({ id, already_revoked: true }, { requestId });
}
const nowIso = new Date().toISOString();
const { error: updErr } = await supabase
.from("team_invites")
.update({ revoked_at: nowIso, revoked_by: authUser.id })
.eq("id", id)
.is("accepted_at", null)
.is("revoked_at", null);
if (updErr) return fail("internal_error", updErr.message, 500, { requestId });
await audit({
action: "member.invite_revoked",
actorUserId: authUser.id,
organizationId: activeOrg.orgId,
resourceType: "membership",
resourceId: id,
requestId,
metadata: { email: row.email as string, role: row.role as string },
});
return ok({ id, revoked_at: nowIso }, { requestId });
}
+55
View File
@@ -0,0 +1,55 @@
/**
* GET /api/v1/team/invites — convites da organização ativa (migration 0238).
*
* O que a aba "Membros" da tela de Equipe não mostrava: convite enviado,
* pendente, expirado ou revogado — e se o e-mail chegou a sair. Manager+ lê
* (RLS `team_invites_select`); as ações (reenviar / revogar) são admin.
*
* `status` é derivado aqui, não vem do banco. `accept_url` só acompanha convite
* em aberto (pendente/expirado) — é o link copiável que o admin usa quando o
* e-mail não saiu.
*/
import { randomUUID } from "node:crypto";
import type { NextRequest } from "next/server";
import { ok, fail } from "@/lib/api/wrappers";
import { requireRole } from "@/lib/auth/require-role";
import { createClient } from "@/lib/supabase/server";
import {
conviteEstaEmAberto,
linkDeAceite,
statusConvite,
type ConviteDeTime,
} from "@/lib/team/convites";
export const dynamic = "force-dynamic";
export async function GET(_req: NextRequest): Promise<Response> {
const requestId = randomUUID();
const authz = await requireRole("manager", { requestId, resource: "team" });
if (!authz.ok) return authz.response;
const { org: activeOrg } = authz;
const supabase = await createClient();
const { data: rows, error } = await supabase
.from("team_invites")
.select(
"id, organization_id, email, role, interface_settings, invited_by, inviter_name, email_dispatched, created_at, last_sent_at, resend_count, expires_at, accepted_at, revoked_at",
)
.eq("organization_id", activeOrg.orgId)
.order("created_at", { ascending: false });
if (error) return fail("internal_error", error.message, 500, { requestId });
const now = Date.now();
const invites = ((rows ?? []) as ConviteDeTime[]).map((row) => {
const status = statusConvite(row, now);
return {
...row,
status,
accept_url: conviteEstaEmAberto(row, now) ? linkDeAceite(row) : null,
};
});
return ok(invites, { requestId });
}
@@ -0,0 +1,251 @@
"use client";
import { useState } from "react";
import { toast } from "sonner";
import { useT } from "@/hooks/i18n/useT";
import { useTagDeIdioma } from "@/hooks/i18n/useLocaleDeData";
import { useTeamInvites, type TeamInvite } from "@/hooks/team/useTeamInvites";
import { useResendInvite } from "@/hooks/team/useResendInvite";
import { useRevokeInvite } from "@/hooks/team/useRevokeInvite";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { ArrowsClockwise, Copy, DotsThree, Warning } from "@/lib/ui/icons";
import { copyToClipboard } from "@/lib/clipboard";
import type { StatusConvite } from "@/lib/team/convite-status";
interface Props {
/** admin: mostra as ações de reenviar/revogar. Manager só lê. */
canManage: boolean;
}
const BADGE_VARIANT: Record<StatusConvite, "default" | "outline" | "secondary" | "destructive"> = {
aceito: "default",
pendente: "outline",
expirado: "secondary",
revogado: "destructive",
};
export function TeamInvitesClient({ canManage }: Props) {
const t = useT();
const tagDoIdioma = useTagDeIdioma();
const { data, isLoading, isError } = useTeamInvites();
const resend = useResendInvite();
const revoke = useRevokeInvite();
const [revokeDialog, setRevokeDialog] = useState<TeamInvite | null>(null);
const STATUS_LABEL: Record<StatusConvite, string> = {
aceito: t("Aceito"),
pendente: t("Pendente"),
expirado: t("Expirado"),
revogado: t("Revogado"),
};
async function copyLink(url: string) {
// Helper do repo (funciona em http://IP, self-host sem TLS), nunca a API crua.
if (await copyToClipboard(url)) toast.success(t("Link do convite copiado."));
else toast.error(t("Não foi possível copiar. Copie da barra do navegador."));
}
if (isLoading) {
return <p className="text-sm text-muted-foreground">{t("Carregando…")}</p>;
}
if (isError) {
return <p className="text-sm text-destructive">{t("Erro ao carregar convites.")}</p>;
}
const invites = data?.data ?? [];
return (
<section className="flex flex-col gap-3">
<div>
<h2 className="text-sm font-semibold">{t("Convites")}</h2>
<p className="text-xs text-muted-foreground">
{t("Convites enviados e seu status. Um convite aceito vira membro na lista acima.")}
</p>
</div>
{invites.length === 0 ? (
<p className="text-sm text-muted-foreground">{t("Nenhum convite enviado.")}</p>
) : (
<div className="overflow-x-auto rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("E-mail")}</TableHead>
<TableHead>Role</TableHead>
<TableHead>{t("Interface")}</TableHead>
<TableHead>{t("Status")}</TableHead>
<TableHead>{t("E-mail enviado")}</TableHead>
<TableHead>{t("Enviado em")}</TableHead>
<TableHead>{t("Expira em")}</TableHead>
<TableHead>{t("Convidado por")}</TableHead>
{canManage ? <TableHead className="w-[60px]" /> : null}
</TableRow>
</TableHeader>
<TableBody>
{invites.map((inv) => {
const emAberto = inv.status === "pendente" || inv.status === "expirado";
return (
<TableRow key={inv.id}>
<TableCell className="font-medium">{inv.email}</TableCell>
<TableCell>
<Badge variant="secondary">{inv.role}</Badge>
</TableCell>
<TableCell className="text-sm">
{inv.interface_settings?.destinos
? t("Personalizada")
: inv.interface_settings?.preset === "simplificada"
? t("Simplificada")
: t("Completa")}
</TableCell>
<TableCell>
<Badge variant={BADGE_VARIANT[inv.status]}>{STATUS_LABEL[inv.status]}</Badge>
</TableCell>
<TableCell>
{inv.email_dispatched ? (
<span className="text-sm text-muted-foreground">{t("Sim")}</span>
) : (
<div className="flex flex-col gap-1">
<span className="flex items-center gap-1 text-sm text-destructive">
<Warning size={14} weight="fill" />
{t("Não saiu")}
</span>
{emAberto && inv.accept_url ? (
<Button
variant="outline"
size="sm"
className="h-7 w-fit gap-1 text-xs"
onClick={() => copyLink(inv.accept_url!)}
>
<Copy size={13} />
{t("Copiar link")}
</Button>
) : null}
</div>
)}
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{new Date(inv.last_sent_at).toLocaleString(tagDoIdioma)}
{inv.resend_count > 0 ? (
<span className="ml-1 text-xs">
({t("reenviado")} {inv.resend_count}×)
</span>
) : null}
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{inv.status === "aceito"
? "—"
: new Date(inv.expires_at).toLocaleString(tagDoIdioma)}
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{inv.inviter_name ?? "—"}
</TableCell>
{canManage ? (
<TableCell>
{emAberto ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" aria-label={t("Ações")}>
<DotsThree size={20} />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
disabled={resend.isPending}
onClick={async () => {
try {
await resend.mutateAsync(inv.id);
toast.success(t("Convite reenviado."));
} catch {
/* showApiError já disparou */
}
}}
>
<ArrowsClockwise size={16} />
{t("Reenviar")}
</DropdownMenuItem>
{inv.accept_url ? (
<DropdownMenuItem onClick={() => copyLink(inv.accept_url!)}>
<Copy size={16} />
{t("Copiar link")}
</DropdownMenuItem>
) : null}
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={() => setRevokeDialog(inv)}
>
{t("Revogar")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : null}
</TableCell>
) : null}
</TableRow>
);
})}
</TableBody>
</Table>
</div>
)}
<Dialog open={!!revokeDialog} onOpenChange={(o) => !o && setRevokeDialog(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("Revogar convite")}</DialogTitle>
<DialogDescription>
{revokeDialog?.email}{" "}
{t(
"não poderá mais usar este convite para entrar. Você pode enviar um novo depois.",
)}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="ghost" onClick={() => setRevokeDialog(null)}>
{t("Cancelar")}
</Button>
<Button
variant="destructive"
disabled={revoke.isPending}
onClick={async () => {
if (!revokeDialog) return;
try {
await revoke.mutateAsync(revokeDialog.id);
toast.success(t("Convite revogado."));
setRevokeDialog(null);
} catch {
/* showApiError já disparou */
}
}}
>
{t("Revogar")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</section>
);
}
+9 -1
View File
@@ -6,6 +6,7 @@ import { ROLE_RANK } from "@/lib/auth/types";
import { Button } from "@/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { TeamMembersClient } from "./_components/TeamMembersClient";
import { TeamInvitesClient } from "./_components/TeamInvitesClient";
import { AttendantsClient } from "./_components/AttendantsClient";
export const dynamic = "force-dynamic";
@@ -63,8 +64,15 @@ export default async function TeamPage({
<TabsTrigger value="members">{t("Membros")}</TabsTrigger>
<TabsTrigger value="attendants">{t("Atendimento")}</TabsTrigger>
</TabsList>
<TabsContent value="members" className="mt-4">
<TabsContent value="members" className="mt-4 flex flex-col gap-8">
<TeamMembersClient currentUserId={user.id} canManage={isAdmin} />
{/*
Convites pendentes vivem AQUI, na mesma aba de quem já entrou —
antes só apareciam numa lista efêmera dentro do modal "Convidar
membros", que sumia ao fechar. Manager+ vê; só admin reenvia/revoga
(as rotas são admin-only). Ver `docs/testing/user-journey-map.md`.
*/}
{isManager ? <TeamInvitesClient canManage={isAdmin} /> : null}
</TabsContent>
<TabsContent value="attendants" className="mt-4">
{isManager ? (
@@ -92,7 +92,23 @@
"col": 3,
"type": "ui",
"label": "Admin → Auditoria",
"sublabel": "criação, convite, aceite, troca; nenhum token"
"sublabel": "criação, convite, aceite, troca, revogação de convite; nenhum token"
},
{
"id": "registro-convite",
"lane": "db",
"col": 3,
"type": "database",
"label": "team_invites",
"sublabel": "linha por convite pendente; id = invite_id do token; status derivado; RLS manager+/admin"
},
{
"id": "tela-convites",
"lane": "humano",
"col": 4,
"type": "ui",
"label": "Equipe › Membros → Convites",
"sublabel": "status, e-mail despachado, link copiável; reenviar/revogar (admin)"
},
{
"id": "context",
@@ -152,6 +168,27 @@
"from": "convite",
"to": "aceite"
},
{
"from": "convite",
"to": "registro-convite"
},
{
"from": "aceite",
"to": "registro-convite"
},
{
"from": "registro-convite",
"to": "tela-convites"
},
{
"from": "tela-convites",
"to": "convite",
"label": "reenviar"
},
{
"from": "tela-convites",
"to": "audit"
},
{
"from": "criar",
"to": "audit"
+5
View File
@@ -168,6 +168,11 @@ fonte só (`lib/onboarding/passos.ts`) — eram três listas que discordavam. Ga
| J5.7 | Revogar atendente | perde acesso na hora (próxima navegação) |
| J5.8 | Revogar último admin | bloqueado com explicação |
| J5.9 | Link de convite expirado/adulterado | tela clara, sem stack |
| J5.10 `[P0]` | Convite pendente aparece na aba **Membros** | seção "Convites" lista e-mail, papel, interface, status (Pendente/Aceito/Expirado/Revogado), data de envio e de expiração, quem convidou · antes só existia numa lista efêmera dentro do modal "Convidar membros" · `lib/team/convite-status.test.ts` + `tests/e2e/invite-lifecycle.spec.ts` casos 13–16 |
| J5.11 `[P0]` | E-mail do convite **não saiu** (VPS sem Resend) | a linha mostra "Não saiu" + botão **Copiar link** ali mesmo (usa `team_invites.email_dispatched`, antes só legível no `api_audit_log`) — o admin não fica achando que enviou |
| J5.12 | Admin **revoga** um convite pendente | `POST /api/v1/team/invites/[id]/revoke` marca `revoked_at`; o aceite passa a recusar o token mesmo dentro da validade; audita `member.invite_revoked` |
| J5.13 | Admin **reenvia** um convite | `POST /api/v1/team/invites/[id]/resend` re-assina o mesmo `invite_id`, renova 24h, audita `member.invited`; reconvidar o mesmo e-mail pendente pela tela de convite RENOVA a linha (índice único parcial) |
| J5.14 | Manager vê a lista, mas não as ações | leitura é `team_invites_select` (manager+); reenviar/revogar são admin-only (403) |
## J6 — Webhooks: receber, automatizar, provar `[P0]`
+17
View File
@@ -0,0 +1,17 @@
"use client";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { apiClient } from "@/lib/api/client";
import { showApiError } from "@/components/feedback/ApiErrorToast";
import type { TeamInvite } from "./useTeamInvites";
export function useResendInvite() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (id: string) =>
apiClient.post<{ data: TeamInvite }>(`/api/v1/team/invites/${id}/resend`, {}),
onError: showApiError,
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["team"] });
},
});
}
+19
View File
@@ -0,0 +1,19 @@
"use client";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { apiClient } from "@/lib/api/client";
import { showApiError } from "@/components/feedback/ApiErrorToast";
export function useRevokeInvite() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (id: string) =>
apiClient.post<{ data: { id: string; revoked_at?: string; already_revoked?: boolean } }>(
`/api/v1/team/invites/${id}/revoke`,
{},
),
onError: showApiError,
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["team"] });
},
});
}
+33
View File
@@ -0,0 +1,33 @@
"use client";
import type { InterfaceSettings } from "@/lib/navigation/interface";
import { useQuery } from "@tanstack/react-query";
import { apiClient } from "@/lib/api/client";
import type { StatusConvite } from "@/lib/team/convite-status";
export interface TeamInvite {
id: string;
organization_id: string;
email: string;
role: string;
interface_settings: InterfaceSettings;
invited_by: string | null;
inviter_name: string | null;
email_dispatched: boolean;
created_at: string;
last_sent_at: string;
resend_count: number;
expires_at: string;
accepted_at: string | null;
revoked_at: string | null;
status: StatusConvite;
accept_url: string | null;
}
export function useTeamInvites(opts?: { enabled?: boolean }) {
return useQuery({
queryKey: ["team", "invites"],
queryFn: async () => apiClient.get<{ data: TeamInvite[] }>("/api/v1/team/invites"),
staleTime: 30_000,
enabled: opts?.enabled ?? true,
});
}
+5
View File
@@ -72,6 +72,11 @@ export const AUDIT_ACTIONS = [
// pergunta "quem devolveu o acesso desta pessoa, e quando?" só tem resposta
// aqui — a coluna `revoked_at` volta a NULL e não guarda histórico.
"member.reactivated",
// Um convite PENDENTE cancelado na tela de Equipe (migration 0238). Distinto
// de `member.revoked` (tira acesso de quem já entrou): aqui ninguém chegou a
// ser membro. O REENVIO de um convite audita como `member.invited` — é uma
// nova emissão do mesmo convite.
"member.invite_revoked",
"token.created",
"token.revoked",
"profile.updated",
+42 -3
View File
@@ -18,10 +18,27 @@ import type { InvitePayload } from "@/lib/auth/invite-token";
* entra num CRM vazio, e essa foi a experiência medida de dois convidados
* reais.
*
* Aqui NÃO se decide se o convite vale — quem decide é quem chama, e os dois
* A VALIDADE DO TOKEN não se decide aqui — quem decide é quem chama, e os dois
* chamam `verifyInviteToken` antes (o segundo por dentro de
* `decidirConviteDoSignup`, que ainda confere o e-mail contra o que o provedor
* confirmou). Este módulo é o efeito, não a autoridade.
* confirmou).
*
* A LINHA DE `team_invites` (migration 0238), sim, é tratada aqui — e precisa
* ser, nos dois sentidos:
*
* - **Revogação.** Um convite cancelado na tela de Equipe mantém assinatura e
* validade boas no token; o que diz que ele morreu é a linha. Deixar essa
* checagem no botão de aceite (onde ela nasceu, no PR #664) daria acesso a
* um convite revogado a quem chegasse pelo OUTRO caminho — confirmar o
* e-mail —, que é justamente o caminho de quem ainda não tem conta, ou seja,
* o caso comum.
* - **Fechamento.** Sem gravar `accepted_at`, o convite de quem entrou pelo
* e-mail fica listado como **Pendente para sempre** na aba Membros, e o
* administrador reenvia ou revoga um convite que já foi aceito.
*
* Convite sem linha (emitido antes desta migration, ou instalação cujo envio
* não tinha service-role) segue o fluxo: a checagem de revogação de MEMBERSHIP
* dentro de `fn_accept_team_invite` continua valendo.
*/
export type ResultadoDoConvite =
@@ -35,9 +52,20 @@ export async function aplicarConvite(params: {
}): Promise<ResultadoDoConvite> {
const { userId, payload, requestId } = params;
const admin = createAdminClient();
// Convite REVOGADO na tela de Equipe. Sem linha, segue — ver o cabeçalho.
const { data: linhaDoConvite } = await admin
.from("team_invites")
.select("revoked_at")
.eq("id", payload.invite_id)
.eq("organization_id", payload.organization_id)
.maybeSingle();
if (linhaDoConvite?.revoked_at) return { ok: false, motivo: "invalid_or_expired" };
// Org, papel e convidador vêm EXCLUSIVAMENTE do token assinado; o usuário,
// de quem chamou. Nada aqui vem de body de requisição.
const { data: resultado, error } = await createAdminClient().rpc("fn_accept_team_invite", {
const { data: resultado, error } = await admin.rpc("fn_accept_team_invite", {
p_interface_settings: payload.interface_settings ?? { preset: "completa" },
p_user: userId,
p_org: payload.organization_id,
@@ -68,6 +96,17 @@ export async function aplicarConvite(params: {
});
}
// Fecha o convite na aba Membros. Idempotente: `is("accepted_at", null)` faz
// o replay do mesmo token não mexer em nada, e `is("revoked_at", null)` impede
// que uma corrida marque como aceito um convite cancelado no mesmo instante.
await admin
.from("team_invites")
.update({ accepted_at: new Date().toISOString(), accepted_by: userId })
.eq("id", payload.invite_id)
.eq("organization_id", payload.organization_id)
.is("accepted_at", null)
.is("revoked_at", null);
// Sem isto a pessoa entra sem organização escolhida e o app não sabe qual
// mostrar — o mesmo motivo pelo qual o botão de aceite sempre gravou aqui.
(await cookies()).set("active_org", payload.organization_id, {
+24
View File
@@ -8189,6 +8189,30 @@ export const DICIONARIO: Traducoes = {
"Este ponto usa o modelo definido na versão publicada do agente; a escolha do painel não se aplica.": {
es: "Este punto usa el modelo definido en la versión publicada del agente; la elección del panel no se aplica.",
},
// ─── app/app/team/_components/TeamInvitesClient.tsx (lista de convites) ───
"Convites": { es: "Invitaciones" },
"Convites enviados e seu status. Um convite aceito vira membro na lista acima.": {
es: "Invitaciones enviadas y su estado. Una invitación aceptada se vuelve miembro en la lista de arriba.",
},
"Nenhum convite enviado.": { es: "Ninguna invitación enviada." },
"Erro ao carregar convites.": { es: "Error al cargar las invitaciones." },
"E-mail enviado": { es: "Correo enviado" },
"Enviado em": { es: "Enviado el" },
"Convidado por": { es: "Invitado por" },
"Expirado": { es: "Expirada" },
"Não saiu": { es: "No salió" },
"reenviado": { es: "reenviado" },
"Revogar convite": { es: "Revocar invitación" },
"não poderá mais usar este convite para entrar. Você pode enviar um novo depois.": {
es: "ya no podrá usar esta invitación para entrar. Puedes enviar una nueva después.",
},
"Link do convite copiado.": { es: "Enlace de la invitación copiado." },
"Não foi possível copiar. Copie da barra do navegador.": {
es: "No se pudo copiar. Cópialo desde la barra del navegador.",
},
"Convite reenviado.": { es: "Invitación reenviada." },
"Convite revogado.": { es: "Invitación revocada." },
};
/**
+86
View File
@@ -0,0 +1,86 @@
import { describe, expect, it } from "vitest";
import { conviteEstaEmAberto, statusConvite } from "./convite-status";
const HORA = 3_600_000;
const agora = Date.parse("2026-09-09T12:00:00.000Z");
describe("statusConvite", () => {
it("revogado ganha de tudo, mesmo com validade no futuro", () => {
expect(
statusConvite(
{
revoked_at: "2026-09-09T11:00:00.000Z",
accepted_at: null,
expires_at: "2026-09-10T12:00:00.000Z",
},
agora,
),
).toBe("revogado");
});
it("aceito ganha de expirado (aceitou dentro do prazo, prazo passou depois)", () => {
expect(
statusConvite(
{
revoked_at: null,
accepted_at: "2026-09-08T12:00:00.000Z",
expires_at: "2026-09-09T00:00:00.000Z",
},
agora,
),
).toBe("aceito");
});
it("sem aceite nem revogação, o relógio decide", () => {
expect(
statusConvite(
{ revoked_at: null, accepted_at: null, expires_at: new Date(agora + HORA).toISOString() },
agora,
),
).toBe("pendente");
expect(
statusConvite(
{ revoked_at: null, accepted_at: null, expires_at: new Date(agora - HORA).toISOString() },
agora,
),
).toBe("expirado");
});
it("no instante exato da expiração já conta como expirado", () => {
expect(
statusConvite(
{ revoked_at: null, accepted_at: null, expires_at: new Date(agora).toISOString() },
agora,
),
).toBe("expirado");
});
});
describe("conviteEstaEmAberto", () => {
it("pendente e expirado estão em aberto; aceito e revogado não", () => {
const base = { revoked_at: null, accepted_at: null };
expect(
conviteEstaEmAberto({ ...base, expires_at: new Date(agora + HORA).toISOString() }, agora),
).toBe(true);
expect(
conviteEstaEmAberto({ ...base, expires_at: new Date(agora - HORA).toISOString() }, agora),
).toBe(true);
expect(
conviteEstaEmAberto(
{ revoked_at: null, accepted_at: "2026-09-08T12:00:00.000Z", expires_at: "x" },
agora,
),
).toBe(false);
expect(
conviteEstaEmAberto(
{
revoked_at: "2026-09-08T12:00:00.000Z",
accepted_at: null,
expires_at: new Date(agora + HORA).toISOString(),
},
agora,
),
).toBe(false);
});
});
+28
View File
@@ -0,0 +1,28 @@
/**
* A regra de STATUS de um convite de time, isolada e SEM dependência de
* servidor — para o client (`TeamInvitesClient`) e os testes importarem sem
* arrastar `issueInvite`/`resend`/`audit` para o bundle.
*
* Status é derivado, nunca coluna (doutrina DIRC: Calcular): `revoked_at` e
* `accepted_at` são fatos, `expires_at` é o relógio.
*/
export type StatusConvite = "pendente" | "aceito" | "expirado" | "revogado";
export interface CamposDeStatus {
accepted_at: string | null;
revoked_at: string | null;
expires_at: string;
}
export function statusConvite(row: CamposDeStatus, now: number = Date.now()): StatusConvite {
if (row.revoked_at) return "revogado";
if (row.accepted_at) return "aceito";
if (Date.parse(row.expires_at) <= now) return "expirado";
return "pendente";
}
/** Revogar e reenviar só fazem sentido enquanto o convite está em aberto. */
export function conviteEstaEmAberto(row: CamposDeStatus, now?: number): boolean {
const s = statusConvite(row, now);
return s === "pendente" || s === "expirado";
}
+228
View File
@@ -0,0 +1,228 @@
/**
* Convite de time como REGISTRO, não só como token.
*
* O token HMAC (`lib/auth/invite-token.ts`) continua sendo o que viaja no
* e-mail e o que o aceite verifica. `team_invites` (migration 0238) é a linha
* que espelha esse convite enquanto ele está pendente — é o que a tela de
* Equipe lista, o que diz se o e-mail saiu, e o que torna possível REVOGAR
* (cancelar um token stateless exige algo contra o que verificar no aceite).
*
* O `id` da linha É o `invite_id` que vai dentro do token. Reconvidar um e-mail
* que já tem convite pendente RENOVA a mesma linha (o índice único parcial
* garante no máximo um pendente por e-mail por organização).
*
* STATUS é derivado, nunca coluna: `statusConvite()` decide a partir de
* `revoked_at` / `accepted_at` / `expires_at`.
*/
import { randomUUID } from "node:crypto";
import type { SupabaseClient } from "@supabase/supabase-js";
import { env } from "@/lib/env";
import { issueInvite } from "@/lib/auth/issue-invite";
import { signInviteToken } from "@/lib/auth/invite-token";
import {
INTERFACE_COMPLETA,
interfaceSettingsSchema,
type InterfaceSettings,
} from "@/lib/navigation/interface";
import type { Role } from "@/lib/schemas/team";
import {
conviteEstaEmAberto,
statusConvite,
type CamposDeStatus,
type StatusConvite,
} from "@/lib/team/convite-status";
export { conviteEstaEmAberto, statusConvite };
export type { CamposDeStatus, StatusConvite };
export interface ConviteDeTime {
id: string;
organization_id: string;
email: string;
role: Role;
interface_settings: InterfaceSettings;
invited_by: string | null;
inviter_name: string | null;
email_dispatched: boolean;
created_at: string;
last_sent_at: string;
resend_count: number;
expires_at: string;
accepted_at: string | null;
revoked_at: string | null;
}
/**
* Reconstrói o link de aceite de uma linha pendente. O token é stateless — não
* o guardamos —, então re-assinamos com os campos da linha. Qualquer token
* válido com o mesmo `invite_id` serve; o aceite verifica assinatura, validade,
* e-mail e revogação.
*/
export function linkDeAceite(row: ConviteDeTime): string {
const token = signInviteToken({
invite_id: row.id,
email: row.email,
organization_id: row.organization_id,
role: row.role,
iat: Math.floor(Date.parse(row.last_sent_at) / 1000),
exp: Math.floor(Date.parse(row.expires_at) / 1000),
invited_by: row.invited_by ?? undefined,
interface_settings: row.interface_settings,
});
return `${env.NEXT_PUBLIC_APP_URL.replace(/\/$/, "")}/team/accept-invite/${token}`;
}
interface EmitirParams {
organizationId: string;
orgName: string;
email: string;
role: Role;
interfaceSettings?: InterfaceSettings;
inviterId: string;
inviterName: string;
requestId: string;
}
export interface ResultadoEmissao {
convite: ConviteDeTime;
accept_url: string;
email_dispatched: boolean;
/** true = renovou uma linha pendente que já existia (reenvio). */
renovado: boolean;
}
/**
* Emite (ou renova) um convite: assina o token, dispara o e-mail, audita
* `member.invited` (tudo dentro de `issueInvite`) e grava/atualiza a linha em
* `team_invites`. Requer o client service-role — a linha é escrita ignorando
* RLS e filtrando `organization_id` da fonte confiável (nunca do body).
*/
export async function emitirConvite(
admin: SupabaseClient,
params: EmitirParams,
): Promise<ResultadoEmissao> {
const email = params.email.trim().toLowerCase();
const interfaceSettings = interfaceSettingsSchema.parse(
params.interfaceSettings ?? INTERFACE_COMPLETA,
);
const { data: pendente } = await admin
.from("team_invites")
.select("id, resend_count")
.eq("organization_id", params.organizationId)
.eq("email", email)
.is("accepted_at", null)
.is("revoked_at", null)
.maybeSingle();
const inviteId = (pendente?.id as string | undefined) ?? randomUUID();
const issuedAt = Math.floor(Date.now() / 1000);
const emitido = await issueInvite({
email,
role: params.role,
interfaceSettings,
organizationId: params.organizationId,
orgName: params.orgName,
inviterId: params.inviterId,
inviterName: params.inviterName,
requestId: params.requestId,
inviteId,
issuedAt,
});
const nowIso = new Date().toISOString();
const base = {
organization_id: params.organizationId,
email,
role: params.role,
interface_settings: interfaceSettings,
invited_by: params.inviterId,
inviter_name: params.inviterName,
email_dispatched: emitido.email_dispatched,
last_sent_at: nowIso,
expires_at: emitido.expires_at,
};
const { data: row, error } = pendente
? await admin
.from("team_invites")
.update({ ...base, resend_count: (pendente.resend_count as number) + 1 })
.eq("id", inviteId)
.select("*")
.single()
: await admin
.from("team_invites")
.insert({ id: inviteId, ...base })
.select("*")
.single();
if (error) throw error;
return {
convite: row as ConviteDeTime,
accept_url: emitido.accept_url,
email_dispatched: emitido.email_dispatched,
renovado: !!pendente,
};
}
/**
* Reenvia um convite que já existe: re-assina o token com o `invite_id` da
* linha, dispara o e-mail de novo, audita `member.invited` (via `issueInvite`)
* pelo ator que clicou reenviar, e renova `expires_at` / `last_sent_at` /
* `resend_count`. Preserva `invited_by` — o convite continua sendo de quem o
* abriu; reenviar é só re-entrega.
*
* Retorna `null` se a linha deixou de estar em aberto entre a leitura e a
* escrita (corrida com um aceite ou uma revogação).
*/
export async function reenviarConvite(
admin: SupabaseClient,
params: {
convite: ConviteDeTime;
orgName: string;
actorId: string;
actorName: string;
requestId: string;
},
): Promise<ResultadoEmissao | null> {
const { convite } = params;
const emitido = await issueInvite({
email: convite.email,
role: convite.role,
interfaceSettings: convite.interface_settings,
organizationId: convite.organization_id,
orgName: params.orgName,
inviterId: params.actorId,
inviterName: params.actorName,
requestId: params.requestId,
inviteId: convite.id,
issuedAt: Math.floor(Date.now() / 1000),
});
const { data: row, error } = await admin
.from("team_invites")
.update({
email_dispatched: emitido.email_dispatched,
last_sent_at: new Date().toISOString(),
expires_at: emitido.expires_at,
resend_count: convite.resend_count + 1,
})
.eq("id", convite.id)
.is("accepted_at", null)
.is("revoked_at", null)
.select("*")
.maybeSingle();
if (error) throw error;
if (!row) return null;
return {
convite: row as ConviteDeTime,
accept_url: emitido.accept_url,
email_dispatched: emitido.email_dispatched,
renovado: true,
};
}
+99
View File
@@ -23271,6 +23271,105 @@ update public.channel_sessions
updated_at = now()
where provider = 'waha' and waha_session_name is not null
and length(waha_session_name) > 54 and phone_number is null and status <> 'WORKING';
-- ---- Convites de time persistidos (migration 0238) ----
--
-- Racional completo no cabeçalho da migration 0238. Em uma linha: o convite
-- pendente não existia no banco (token stateless + linha só no aceite), então a
-- tela de Equipe não o mostrava e REVOGAR era impossível. `team_invites` é o
-- registro; o `id` da linha é o `invite_id` do token.
--
-- Idempotente e auto-curativo: `if not exists` em tabela/índices, `drop ... if
-- exists` antes de cada policy e do trigger; dedup antes do índice único.
create table if not exists public.team_invites (
id uuid primary key default gen_random_uuid(),
organization_id uuid not null references public.organizations(id) on delete cascade,
email text not null,
role text not null,
interface_settings jsonb not null default '{"preset":"completa"}'::jsonb,
invited_by uuid references auth.users(id) on delete set null,
inviter_name text,
email_dispatched boolean not null default false,
created_at timestamptz not null default now(),
last_sent_at timestamptz not null default now(),
resend_count integer not null default 0,
expires_at timestamptz not null,
accepted_at timestamptz,
accepted_by uuid references auth.users(id) on delete set null,
revoked_at timestamptz,
revoked_by uuid references auth.users(id) on delete set null,
updated_at timestamptz not null default now(),
constraint team_invites_role_check check (role in ('viewer','agent','manager','admin')),
constraint team_invites_email_nao_vazio check (length(btrim(email)) > 0)
);
-- Clone com versão antiga desta tabela: garante as colunas que vieram depois.
alter table public.team_invites add column if not exists interface_settings jsonb not null default '{"preset":"completa"}'::jsonb;
alter table public.team_invites add column if not exists inviter_name text;
alter table public.team_invites add column if not exists email_dispatched boolean not null default false;
alter table public.team_invites add column if not exists last_sent_at timestamptz not null default now();
alter table public.team_invites add column if not exists resend_count integer not null default 0;
alter table public.team_invites add column if not exists accepted_by uuid references auth.users(id) on delete set null;
alter table public.team_invites add column if not exists revoked_at timestamptz;
alter table public.team_invites add column if not exists revoked_by uuid references auth.users(id) on delete set null;
alter table public.team_invites add column if not exists updated_at timestamptz not null default now();
create index if not exists team_invites_org_created_idx
on public.team_invites (organization_id, created_at desc);
-- Antes do índice único: resolve dados que o violem (clone bugado) mantendo o
-- pendente mais recente e revogando os demais.
with ranked as (
select id,
row_number() over (
partition by organization_id, lower(email)
order by created_at desc, id desc
) as rn
from public.team_invites
where accepted_at is null and revoked_at is null
)
update public.team_invites t
set revoked_at = now(), updated_at = now()
from ranked
where t.id = ranked.id and ranked.rn > 1;
create unique index if not exists team_invites_um_pendente_por_email_idx
on public.team_invites (organization_id, lower(email))
where accepted_at is null and revoked_at is null;
alter table public.team_invites enable row level security;
drop policy if exists team_invites_select on public.team_invites;
create policy team_invites_select on public.team_invites
for select using (
public.fn_is_platform_admin()
or ((organization_id in (select public.fn_user_org_ids()))
and public.fn_role_at_least(organization_id, 'manager'))
);
drop policy if exists team_invites_write on public.team_invites;
create policy team_invites_write on public.team_invites
using (
public.fn_is_platform_admin()
or ((organization_id in (select public.fn_user_org_ids()))
and public.fn_role_at_least(organization_id, 'admin'))
)
with check (
public.fn_is_platform_admin()
or ((organization_id in (select public.fn_user_org_ids()))
and public.fn_role_at_least(organization_id, 'admin'))
);
revoke all on public.team_invites from anon;
grant select, insert, update, delete on public.team_invites to authenticated;
grant all on public.team_invites to service_role;
drop trigger if exists trg_team_invites_updated_at on public.team_invites;
create trigger trg_team_invites_updated_at
before update on public.team_invites
for each row execute function public.fn_set_updated_at();
comment on table public.team_invites is
'Convite de time PENDENTE e seu histórico. O id da linha = invite_id do token HMAC; o aceite casa os dois e recusa convite revogado. Status é derivado, não coluna.';
notify pgrst,'reload schema';
@@ -0,0 +1,127 @@
-- Convite de time deixa de ser só um token HMAC sem lastro.
--
-- Até aqui o convite pendente não existia em lugar nenhum do banco: o token é
-- stateless (`lib/auth/invite-token.ts`), a linha em `user_organizations` só
-- nasce no aceite (`fn_accept_team_invite`), e o único rastro de "convidei
-- alguém" era uma linha `member.invited` no `api_audit_log`. Consequência:
-- - a tela de Equipe não mostrava convite pendente nenhum;
-- - "o e-mail saiu?" só se respondia lendo `metadata.email_dispatched` do
-- audit, que a UI não lê;
-- - REVOGAR um convite era impossível — não há como cancelar um token
-- stateless sem um registro contra o qual verificar no aceite.
--
-- `team_invites` é esse registro. O `id` da linha é o `invite_id` que vai
-- dentro do token, então o aceite (server action) casa os dois e pode recusar
-- um convite revogado mesmo com o token ainda dentro da validade.
--
-- STATUS (pendente/aceito/expirado/revogado) NÃO é coluna — é derivado de
-- `accepted_at`/`revoked_at`/`expires_at` em `lib/team/convites.ts` (doutrina
-- DIRC: Calcular). Guardar o status seria um quarto campo para manter em sincronia
-- com os três que já contam a história.
--
-- Idempotente: `if not exists` em tabela e índices, `drop policy if exists`
-- antes de cada policy e do trigger — o `update.sh` de um clone reaplica o
-- apêndice do baseline inteiro sem erro.
create table if not exists public.team_invites (
id uuid primary key default gen_random_uuid(),
organization_id uuid not null references public.organizations(id) on delete cascade,
-- Guardado já em minúsculas pelo app (`issueInvite`). O índice único usa
-- `lower(email)` mesmo assim, para o caso de uma inserção fora desse caminho.
email text not null,
role text not null,
-- Mesma forma de `user_organizations.interface_settings` (migration 0221) — a
-- escolha de áreas viaja assinada no token e é aplicada no aceite; guardá-la
-- aqui é o que deixa a tela mostrar "Simplificada/Completa/Personalizada"
-- antes de a pessoa aceitar.
interface_settings jsonb not null default '{"preset":"completa"}'::jsonb,
-- FK de verdade para quem convidou (anti-pattern nº 1). `inviter_name` é um
-- SNAPSHOT do nome legível no momento do convite — o schema `auth` não é
-- acessível via PostgREST, e resolver cada `invited_by` pela GoTrue admin API
-- a cada carga da lista seria N chamadas de rede por render.
invited_by uuid references auth.users(id) on delete set null,
inviter_name text,
-- O e-mail REALMENTE saiu? `false` = a tela mostra o aviso e o link copiável
-- ali mesmo, em vez de o admin achar que enviou.
email_dispatched boolean not null default false,
created_at timestamptz not null default now(),
last_sent_at timestamptz not null default now(),
resend_count integer not null default 0,
expires_at timestamptz not null,
accepted_at timestamptz,
accepted_by uuid references auth.users(id) on delete set null,
revoked_at timestamptz,
revoked_by uuid references auth.users(id) on delete set null,
updated_at timestamptz not null default now(),
constraint team_invites_role_check check (role in ('viewer','agent','manager','admin')),
constraint team_invites_email_nao_vazio check (length(btrim(email)) > 0)
);
-- A consulta da tela: os convites de uma organização, mais novo primeiro.
create index if not exists team_invites_org_created_idx
on public.team_invites (organization_id, created_at desc);
-- No máximo UM convite em aberto por e-mail por organização. Reconvidar o mesmo
-- e-mail enquanto há um pendente vira RENOVAÇÃO da mesma linha (o app trata o
-- 23505). Aceito ou revogado libera o slot — reconvidar depois cria linha nova
-- e o histórico fica.
create unique index if not exists team_invites_um_pendente_por_email_idx
on public.team_invites (organization_id, lower(email))
where accepted_at is null and revoked_at is null;
alter table public.team_invites enable row level security;
-- Leitura: membro manager+ da organização (viewer/agent não administram time),
-- ou platform admin.
drop policy if exists team_invites_select on public.team_invites;
create policy team_invites_select on public.team_invites
for select using (
public.fn_is_platform_admin()
or ((organization_id in (select public.fn_user_org_ids()))
and public.fn_role_at_least(organization_id, 'manager'))
);
-- Escrita (emitir, renovar, revogar): admin da organização, ou platform admin.
-- O aceite roda como o CONVIDADO, que ainda não é membro — ele passa pelo
-- service_role (server action), que ignora RLS.
drop policy if exists team_invites_write on public.team_invites;
create policy team_invites_write on public.team_invites
using (
public.fn_is_platform_admin()
or ((organization_id in (select public.fn_user_org_ids()))
and public.fn_role_at_least(organization_id, 'admin'))
)
with check (
public.fn_is_platform_admin()
or ((organization_id in (select public.fn_user_org_ids()))
and public.fn_role_at_least(organization_id, 'admin'))
);
-- `ALTER DEFAULT PRIVILEGES ... GRANT ALL ON TABLES TO anon` do baseline alcança
-- TODA tabela criada depois dele — inclusive esta. Sem o revoke, os e-mails
-- convidados ficam legíveis pela anon key, que vai para o browser.
revoke all on public.team_invites from anon;
grant select, insert, update, delete on public.team_invites to authenticated;
grant all on public.team_invites to service_role;
drop trigger if exists trg_team_invites_updated_at on public.team_invites;
create trigger trg_team_invites_updated_at
before update on public.team_invites
for each row execute function public.fn_set_updated_at();
comment on table public.team_invites is
'Convite de time PENDENTE e seu histórico. O id da linha = invite_id do token HMAC; o aceite (app/actions/team/acceptInvite.ts) casa os dois e recusa convite revogado. Status é derivado, não coluna.';
comment on column public.team_invites.inviter_name is
'Snapshot do nome de quem convidou. auth.users não é acessível via PostgREST; resolver na hora seria N chamadas GoTrue por render.';
comment on column public.team_invites.email_dispatched is
'false = o envio de e-mail falhou (ou não há e-mail configurado). A tela mostra o aviso e o link copiável.';
+1
View File
@@ -287,3 +287,4 @@ To re-apply on a fresh Supabase project, replay the migrations in version order
| `20260907060000` | `0230_reserva_pre_go_live` | A reserva transacional de novos canais WAHA preserva o pré-go-live da plataforma; retry mantém política e identidade existentes. Forward-fix da integração, sem alterar 0228 aplicada. |
| `20260909190000` | `0232_nome_de_sessao_waha_cabe_no_teto_do_waha` | `fn_reserve_channel_connection` gerava `waha_session_name` de 69 chars (`org_<32>_<32>`); o WAHA latest-2026.7.2 valida `name` com @MaxLength(54) e todo `POST /api/sessions` de canal novo tomava 400 (`waha_create_400`). Prefixo da org encurta para 8 (`org_<8>_<32>` = 45), alinhado com a busca de canal de onboarding no mesmo corpo. Repara canais WAHA nunca pareados com nome fora do teto. Forward-fix da 0230. |
| `20260911160000` | `0238_convites_de_time_persistidos` | `team_invites`: o convite pendente passa a existir no banco (antes era só token stateless + linha no aceite). Habilita a lista de convites na tela de Equipe, o aviso de e-mail não despachado e a REVOGAÇÃO de convite (o id da linha = invite_id do token; o aceite recusa convite revogado). Status é derivado. RLS `team_invites_select` (manager+) / `team_invites_write` (admin). Baseline idempotente com dedup antes do índice único. |
+110
View File
@@ -393,6 +393,116 @@ test.describe("ciclo de vida do convite (ponta a ponta + adversarial)", () => {
// E cai no signup COMUM só depois do aviso — nunca em silêncio.
await expect(page.getByLabel("Nome da empresa")).toBeVisible();
});
/**
* ─── A LISTA DE CONVITES na aba Membros (migration 0238) ──────────────────
*
* Até aqui o convite pendente só existia numa lista efêmera dentro do modal
* "Convidar membros". Estes casos provam pela tela: o convite aparece com
* status, o e-mail que não saiu vira aviso + link copiável, e reenviar /
* revogar funcionam — com a revogação barrando o token ainda dentro do prazo.
*/
const FRESH_EMAIL = `convite.pendente.${randomUUID().slice(0, 8)}@deskcomm.test`;
test.beforeAll(async () => {
// Conta para o convidado do caso 16 (aceite do link revogado). Sufixo
// aleatório + banco fresco no CI = sem colisão; em rerun local, ignora o
// "já registrado".
const { error } = await svc.auth.admin.createUser({
email: FRESH_EMAIL,
password: base.password,
email_confirm: true,
});
if (error && !/already/i.test(error.message)) throw error;
});
test("13. convite pendente aparece na aba Membros, com status e quem convidou", async ({
browser,
}) => {
const ctx = await browser.newContext();
const page = await ctx.newPage();
await loginAdminTotp(page);
const { acceptUrl } = await issueInvite(page, FRESH_EMAIL, "agent");
expect(acceptUrl).toContain("/team/accept-invite/");
await page.goto("/app/team");
const row = page.getByRole("row", { name: new RegExp(FRESH_EMAIL, "i") });
await expect(row).toBeVisible();
await expect(row.getByText("Pendente")).toBeVisible();
// CI não configura Resend → o e-mail não sai, e a tela diz isso.
await expect(row.getByText(/Não saiu/i)).toBeVisible();
await expect(row.getByRole("button", { name: /Copiar link/i }).first()).toBeVisible();
await ctx.close();
});
test("14. admin reenvia pelo menu da linha", async ({ browser }) => {
const ctx = await browser.newContext();
const page = await ctx.newPage();
await loginAdminTotp(page);
await page.goto("/app/team");
const row = page.getByRole("row", { name: new RegExp(FRESH_EMAIL, "i") });
await row.getByRole("button", { name: /Ações/i }).click();
await page.getByRole("menuitem", { name: /Reenviar/i }).click();
await expect(page.getByText(/Convite reenviado/i)).toBeVisible();
const { data } = await svc
.from("team_invites")
.select("resend_count")
.eq("organization_id", inv.org_id)
.eq("email", FRESH_EMAIL)
.single();
expect((data as { resend_count: number }).resend_count).toBeGreaterThanOrEqual(1);
await ctx.close();
});
test("15. admin revoga; o token deixa de valer mesmo dentro do prazo", async ({ browser }) => {
const adminCtx = await browser.newContext();
const adminPage = await adminCtx.newPage();
await loginAdminTotp(adminPage);
// captura o link ANTES de revogar (a API de listagem devolve o accept_url)
const listRes = await adminPage.request.get("/api/v1/team/invites");
const lista = (await listRes.json()) as {
data: Array<{ email: string; accept_url: string | null }>;
};
const linkAntes = lista.data.find((i) => i.email === FRESH_EMAIL)?.accept_url ?? "";
expect(linkAntes).toContain("/team/accept-invite/");
await adminPage.goto("/app/team");
const row = adminPage.getByRole("row", { name: new RegExp(FRESH_EMAIL, "i") });
await row.getByRole("button", { name: /Ações/i }).click();
await adminPage.getByRole("menuitem", { name: /Revogar/i }).click();
await adminPage.getByRole("button", { name: /^Revogar$/ }).click();
await expect(adminPage.getByText(/Convite revogado/i)).toBeVisible();
const { data } = await svc
.from("team_invites")
.select("revoked_at")
.eq("organization_id", inv.org_id)
.eq("email", FRESH_EMAIL)
.single();
expect((data as { revoked_at: string | null }).revoked_at).not.toBeNull();
await adminCtx.close();
// o convidado tenta aceitar o link antigo → recusado, apesar de assinado e no prazo
const inviteeCtx = await browser.newContext();
const ip = await inviteeCtx.newPage();
await ip.goto("/login");
await ip.locator("#email").fill(FRESH_EMAIL);
await ip.locator("#password").fill(base.password);
await ip.getByRole("button", { name: /entrar/i }).click();
await expect
.poll(
async () =>
(await inviteeCtx.cookies()).some((c) => c.name.startsWith("sb-deskcomm-auth")),
{ timeout: 40_000 },
)
.toBe(true);
await ip.goto(new URL(linkAntes).pathname);
await ip.getByRole("button", { name: /Aceitar convite/i }).click();
await expect(ip.getByRole("heading", { name: /inválido ou expirado/i })).toBeVisible();
await inviteeCtx.close();
});
});
// helper: extrai só o path (relativo) do accept_url absoluto pro page.goto
@@ -0,0 +1,222 @@
import { execFileSync } from "node:child_process";
import { beforeAll, describe, expect, it } from "vitest";
/**
* OS CONVITES DE TIME NÃO VAZAM ENTRE ORGANIZAÇÕES — NEM PARA DENTRO DA PRÓPRIA.
*
* ═══ Por que um arquivo próprio, e não uma linha em rls-isolation.test.ts ═══
*
* O mesmo motivo de `historico-de-captacao-rls.test.ts`: aquele molde semeia um
* usuário `agent` por organização, e `team_invites_select` (migration 0238)
* exige `manager`. O controle positivo — "lê ≥1 linha própria" — falharia por
* acerto, e a "correção" natural seria afrouxar a policy para o padrão org-flat.
*
* ═══ O que este arquivo prova ═══
*
* `team_invites.email` é o e-mail de alguém que ainda nem entrou no sistema. A
* tabela nasce com CRUD inteiro para `authenticated` (o `ALTER DEFAULT
* PRIVILEGES` do baseline), então a única coisa entre um `viewer` do tenant A e
* o e-mail convidado pelo tenant B são as duas policies. As asserções:
* - manager de A lê os convites de A e ZERO de B (isolamento);
* - sem filtro de org, o total que A vê é exatamente o de A (sem porta dos fundos);
* - viewer e agent de A leem ZERO — o gate de papel de `team_invites_select`;
* - manager de A NÃO consegue revogar (write exige admin); admin de A consegue.
*
* `set role authenticated` + `request.jwt.claims` — o mesmo caminho da produção.
* Conectar como `postgres` mediria nada (rolbypassrls = t).
*/
const container = process.env.TEST_DB_CONTAINER;
if (!container) {
throw new Error(
"TEST_DB_CONTAINER not set — rode esta suíte via `pnpm test:db` (scripts/test-db.sh)",
);
}
const containerName: string = container;
function sql(script: string): string {
return execFileSync(
"docker",
[
"exec",
"-i",
containerName,
"psql",
"-U",
"postgres",
"-d",
"postgres",
"-v",
"ON_ERROR_STOP=1",
"-tA",
"-f",
"-",
],
{ input: script, encoding: "utf8" },
).trim();
}
function countAs(userId: string, countQuery: string): number {
const out = sql(`
set role authenticated;
select set_config('request.jwt.claims', '{"sub":"${userId}"}', false);
${countQuery}
`);
const last = out.split("\n").pop();
if (last === undefined || !/^\d+$/.test(last)) {
throw new Error(`saída inesperada do psql: ${out}`);
}
return Number(last);
}
const ORG_A = "d1d1d1d1-0000-4000-8000-00000000000a";
const ORG_B = "d1d1d1d1-0000-4000-8000-00000000000b";
const ADMIN_A = "d1d1d1d1-1111-4000-8000-00000000000a";
const MANAGER_A = "d1d1d1d1-1111-4000-8000-00000000000c";
const AGENT_A = "d1d1d1d1-1111-4000-8000-00000000000d";
const VIEWER_A = "d1d1d1d1-1111-4000-8000-00000000000e";
const ADMIN_B = "d1d1d1d1-1111-4000-8000-00000000000b";
beforeAll(() => {
sql(`
insert into auth.users (id, email) values
('${ADMIN_A}', 'convites-admin-a@invariant.test'),
('${MANAGER_A}', 'convites-mgr-a@invariant.test'),
('${AGENT_A}', 'convites-agent-a@invariant.test'),
('${VIEWER_A}', 'convites-viewer-a@invariant.test'),
('${ADMIN_B}', 'convites-admin-b@invariant.test')
on conflict (id) do nothing;
insert into public.organizations (id, slug, legal_name, display_name) values
('${ORG_A}', 'convites-inv-a', 'Convites Invariant A', 'Convites A'),
('${ORG_B}', 'convites-inv-b', 'Convites Invariant B', 'Convites B')
on conflict (id) do nothing;
insert into public.user_organizations (user_id, organization_id, role, accepted_at) values
('${ADMIN_A}', '${ORG_A}', 'admin', now()),
('${MANAGER_A}', '${ORG_A}', 'manager', now()),
('${AGENT_A}', '${ORG_A}', 'agent', now()),
('${VIEWER_A}', '${ORG_A}', 'viewer', now()),
('${ADMIN_B}', '${ORG_B}', 'admin', now())
on conflict do nothing;
insert into public.team_invites (organization_id, email, role, invited_by, expires_at)
select v.org, 'convidado-' || v.org::text || '@invariant.test', 'agent', null,
now() + interval '24 hours'
from (values ('${ORG_A}'::uuid), ('${ORG_B}'::uuid)) as v(org)
where not exists (
select 1 from public.team_invites t where t.organization_id = v.org
);
`);
});
describe("team_invites — isolamento e gate de papel", () => {
it("o manager da org A lê os convites da PRÓPRIA org (controle positivo)", () => {
expect(
countAs(
MANAGER_A,
`select count(*) from public.team_invites where organization_id = '${ORG_A}';`,
),
).toBeGreaterThan(0);
});
it("o manager da org A lê ZERO convites da org B", () => {
expect(
countAs(
MANAGER_A,
`select count(*) from public.team_invites where organization_id = '${ORG_B}';`,
),
).toBe(0);
});
it("sem filtro de organização, o total que A vê é exatamente o de A", () => {
const total = countAs(MANAGER_A, `select count(*) from public.team_invites;`);
const proprias = countAs(
MANAGER_A,
`select count(*) from public.team_invites where organization_id = '${ORG_A}';`,
);
expect(total).toBe(proprias);
});
it("o AGENT da própria org lê ZERO — team_invites_select exige manager+", () => {
expect(
countAs(
AGENT_A,
`select count(*) from public.team_invites where organization_id = '${ORG_A}';`,
),
).toBe(0);
});
it("o VIEWER da própria org lê ZERO", () => {
expect(
countAs(
VIEWER_A,
`select count(*) from public.team_invites where organization_id = '${ORG_A}';`,
),
).toBe(0);
});
it("o admin da org B lê os dele (controle positivo do outro lado)", () => {
expect(
countAs(
ADMIN_B,
`select count(*) from public.team_invites where organization_id = '${ORG_B}';`,
),
).toBeGreaterThan(0);
});
it("o MANAGER não consegue revogar — a escrita exige admin", () => {
sql(`
set role authenticated;
select set_config('request.jwt.claims', '{"sub":"${MANAGER_A}"}', false);
do $$
begin
update public.team_invites set revoked_at = now()
where organization_id = '${ORG_A}';
exception when others then null;
end
$$;
`);
const revogados = sql(`
reset role;
select count(*) from public.team_invites
where organization_id = '${ORG_A}' and revoked_at is not null;
`);
expect(revogados.split("\n").pop()).toBe("0");
});
it("o ADMIN consegue revogar o convite da própria org", () => {
sql(`
set role authenticated;
select set_config('request.jwt.claims', '{"sub":"${ADMIN_A}"}', false);
update public.team_invites set revoked_at = now(), revoked_by = '${ADMIN_A}'
where organization_id = '${ORG_A}';
`);
const revogados = sql(`
reset role;
select count(*) from public.team_invites
where organization_id = '${ORG_A}' and revoked_at is not null;
`);
expect(Number(revogados.split("\n").pop())).toBeGreaterThan(0);
});
it("o ADMIN da org A não revoga nada da org B", () => {
sql(`
set role authenticated;
select set_config('request.jwt.claims', '{"sub":"${ADMIN_A}"}', false);
do $$
begin
update public.team_invites set revoked_at = now()
where organization_id = '${ORG_B}';
exception when others then null;
end
$$;
`);
const revogados = sql(`
reset role;
select count(*) from public.team_invites
where organization_id = '${ORG_B}' and revoked_at is not null;
`);
expect(revogados.split("\n").pop()).toBe("0");
});
});
@@ -168,6 +168,15 @@ const PROVA_PROPRIA: readonly Excecao[] = [
"manager da org A NÃO lê linhas da org B (0 rows)\") prova isolamento " +
"com `countAs` real, além do self-read do agent.",
},
{
tabela: "team_invites",
razao:
"tests/invariants/convites-de-time-rls.test.ts — isolamento cross-tenant " +
"(manager A lê 0 de B, sem porta dos fundos) + gate de papel (agent/viewer " +
"leem 0; manager não revoga, só admin). Fora de TABLES de propósito: o " +
"usuário semeado em rls-isolation.test.ts é `agent`, e `team_invites_select` " +
"exige `manager` — o controle positivo falharia por ACERTO ali.",
},
// ─── As três do eixo de anúncios (migrations 0213/0214) ───
//
// ⚠️ PROVA DE OUTRO TIPO, e a diferença está escrita de propósito: as demais
@@ -261,6 +261,15 @@ const PARES: Array<{
arquivo: "lib/tarefas/tipos.ts",
simbolo: "SITUACOES_DA_TAREFA",
},
{
tabela: "team_invites",
coluna: "role",
// lib/schemas/team.ts → ROLES (tupla `as const`). O `z.enum(ROLES)` das
// rotas de convite e o CHECK da migration 0238 espelham a mesma lista;
// nasce com o par no mesmo commit da migration — a lição desta lista.
arquivo: "lib/schemas/team.ts",
simbolo: "ROLES",
},
];
/** Tira um nível de parênteses externos, se ele envolver a expressão inteira. */