From b60f27b2dc65405df0cf8e28f73686d3d6cb06e9 Mon Sep 17 00:00:00 2001 From: Esdras Renan Date: Mon, 13 Oct 2025 15:17:11 -0300 Subject: [PATCH] Auto-expire revoked invites and allow reactivation --- src/app/admin/page.tsx | 9 +++- src/app/api/admin/invites/[id]/route.ts | 46 +++++++++++++++++++- src/components/admin/admin-users-manager.tsx | 44 +++++++++++++++++++ 3 files changed, 96 insertions(+), 3 deletions(-) diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 9a3fa52..203d318 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -68,7 +68,14 @@ async function loadInvites(): Promise { }) const now = new Date() - return invites.map((invite) => normalizeInvite(invite, now)) + const cutoff = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000) + return invites + .map((invite) => normalizeInvite(invite, now)) + .filter((invite) => { + if (invite.status !== "revoked") return true + if (!invite.revokedAt) return true + return new Date(invite.revokedAt) > cutoff + }) } export default async function AdminPage() { diff --git a/src/app/api/admin/invites/[id]/route.ts b/src/app/api/admin/invites/[id]/route.ts index a169888..67262c4 100644 --- a/src/app/api/admin/invites/[id]/route.ts +++ b/src/app/api/admin/invites/[id]/route.ts @@ -9,10 +9,15 @@ import { env } from "@/lib/env" import { prisma } from "@/lib/prisma" import { computeInviteStatus, normalizeInvite, type NormalizedInvite } from "@/server/invite-utils" -type RevokePayload = { +type InviteAction = "revoke" | "reactivate" + +type InvitePayload = { + action?: InviteAction reason?: string } +const REVOKE_RETENTION_MS = 7 * 24 * 60 * 60 * 1000 + async function syncInvite(invite: NormalizedInvite) { const convexUrl = env.NEXT_PUBLIC_CONVEX_URL if (!convexUrl) return @@ -43,7 +48,8 @@ export async function PATCH(request: Request, context: { params: Promise<{ id: s return NextResponse.json({ error: "Não autorizado" }, { status: 401 }) } - const body = (await request.json().catch(() => null)) as Partial | null + const body = (await request.json().catch(() => null)) as Partial | null + const action: InviteAction = body?.action === "reactivate" ? "reactivate" : "revoke" const reason = typeof body?.reason === "string" && body.reason.trim() ? body.reason.trim() : null const invite = await prisma.authInvite.findUnique({ @@ -62,6 +68,42 @@ export async function PATCH(request: Request, context: { params: Promise<{ id: s return NextResponse.json({ error: "Convite já aceito" }, { status: 400 }) } + if (action === "reactivate") { + if (status !== "revoked") { + return NextResponse.json({ error: "Convite não está revogado" }, { status: 400 }) + } + if (!invite.revokedAt) { + return NextResponse.json({ error: "Convite revogado sem data. Não é possível reativar." }, { status: 400 }) + } + const revokedAtMs = invite.revokedAt.getTime() + if (now.getTime() - revokedAtMs > REVOKE_RETENTION_MS) { + return NextResponse.json({ error: "Este convite foi revogado há mais de 7 dias" }, { status: 400 }) + } + + const updated = await prisma.authInvite.update({ + where: { id: invite.id }, + data: { + status: "pending", + revokedAt: null, + revokedById: null, + revokedReason: null, + }, + }) + + const event = await prisma.authInviteEvent.create({ + data: { + inviteId: invite.id, + type: "reactivated", + payload: Prisma.JsonNull, + actorId: session.user.id ?? null, + }, + }) + + const normalized = normalizeInvite({ ...updated, events: [...invite.events, event] }, now) + await syncInvite(normalized) + return NextResponse.json({ invite: normalized }) + } + if (status === "revoked") { const normalized = normalizeInvite(invite, now) await syncInvite(normalized) diff --git a/src/components/admin/admin-users-manager.tsx b/src/components/admin/admin-users-manager.tsx index ffc0fcc..cdf39e1 100644 --- a/src/components/admin/admin-users-manager.tsx +++ b/src/components/admin/admin-users-manager.tsx @@ -140,6 +140,13 @@ function extractMachineId(email: string): string | null { return match ? match[1] : null } +function canReactivateInvite(invite: AdminInvite): boolean { + if (invite.status !== "revoked" || !invite.revokedAt) return false + const revokedDate = new Date(invite.revokedAt) + const limit = Date.now() - 7 * 24 * 60 * 60 * 1000 + return revokedDate.getTime() > limit +} + export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, defaultTenantId }: Props) { const [users, setUsers] = useState(initialUsers) const [invites, setInvites] = useState(initialInvites) @@ -152,6 +159,7 @@ export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, d const [expiresInDays, setExpiresInDays] = useState("7") const [lastInviteLink, setLastInviteLink] = useState(null) const [revokingId, setRevokingId] = useState(null) + const [reactivatingId, setReactivatingId] = useState(null) const [isPending, startTransition] = useTransition() const [linkEmail, setLinkEmail] = useState("") @@ -315,6 +323,31 @@ export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, d } } + async function handleReactivate(invite: AdminInvite) { + if (!canReactivateInvite(invite)) return + setReactivatingId(invite.id) + try { + const response = await fetch(`/api/admin/invites/${invite.id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ action: "reactivate" }), + }) + if (!response.ok) { + const data = await response.json().catch(() => ({})) + throw new Error(data.error ?? "Falha ao reativar convite") + } + const data = (await response.json()) as { invite: AdminInvite } + const normalized = sanitizeInvite(data.invite) + setInvites((previous) => previous.map((item) => (item.id === normalized.id ? normalized : item))) + toast.success("Convite reativado") + } catch (error) { + const message = error instanceof Error ? error.message : "Não foi possível reativar" + toast.error(message) + } finally { + setReactivatingId(null) + } + } + async function handleAssignCompany(event: React.FormEvent) { event.preventDefault() const normalizedEmail = linkEmail.trim().toLowerCase() @@ -784,6 +817,17 @@ export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, d {revokingId === invite.id ? "Revogando..." : "Revogar"} ) : null} + {invite.status === "revoked" && canReactivateInvite(invite) ? ( + + ) : null}