mirror of
https://github.com/melgarafael/DeskcommCRM.git
synced 2026-10-02 01:28:34 +08:00
Merge branch 'pr664' into reconciliacao/664
# Conflicts: # app/actions/team/acceptInvite.ts # lib/audit/actions.ts # supabase/baseline.sql
This commit is contained in:
@@ -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.
|
||||
@@ -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 };
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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]`
|
||||
|
||||
|
||||
@@ -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"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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." },
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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.';
|
||||
@@ -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. |
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user