From 2a3910190f86d82bc4dfead1df4990e88eb5485c Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 13 Apr 2026 07:49:21 -0500 Subject: [PATCH] feat: paginate company invite history --- packages/shared/src/index.ts | 1 + packages/shared/src/types/access.ts | 5 + packages/shared/src/types/index.ts | 1 + packages/shared/src/validators/access.ts | 2 + .../src/__tests__/invite-list-route.test.ts | 164 ++++++++++++++++++ server/src/routes/access.ts | 100 ++++++++--- ui/src/api/access.ts | 28 ++- ui/src/lib/queryKeys.ts | 4 +- ui/src/pages/CompanyInvites.test.tsx | 113 ++++++------ ui/src/pages/CompanyInvites.tsx | 155 ++++++++++------- 10 files changed, 423 insertions(+), 150 deletions(-) create mode 100644 server/src/__tests__/invite-list-route.test.ts diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 96a1bd21e53..809768d2ae8 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -311,6 +311,7 @@ export type { CompanyMemberRecord, CompanyMembersResponse, CompanyMembership, + CompanyInviteListResponse, CompanyInviteRecord, PrincipalPermissionGrant, Invite, diff --git a/packages/shared/src/types/access.ts b/packages/shared/src/types/access.ts index 2cf72abc320..25f605ba515 100644 --- a/packages/shared/src/types/access.ts +++ b/packages/shared/src/types/access.ts @@ -85,6 +85,11 @@ export interface CompanyInviteRecord extends Invite { relatedJoinRequestId: string | null; } +export interface CompanyInviteListResponse { + invites: CompanyInviteRecord[]; + nextOffset: number | null; +} + export interface JoinRequest { id: string; inviteId: string; diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 29e48892e24..3948c4952c1 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -171,6 +171,7 @@ export type { CompanyMemberRecord, CompanyMembersResponse, CompanyMembership, + CompanyInviteListResponse, CompanyInviteRecord, PrincipalPermissionGrant, Invite, diff --git a/packages/shared/src/validators/access.ts b/packages/shared/src/validators/access.ts index 12eb0cf5504..a6e07f9daaa 100644 --- a/packages/shared/src/validators/access.ts +++ b/packages/shared/src/validators/access.ts @@ -52,6 +52,8 @@ export type ListJoinRequestsQuery = z.infer; export const listCompanyInvitesQuerySchema = z.object({ state: z.enum(["active", "revoked", "accepted", "expired"]).optional(), + limit: z.coerce.number().int().min(1).max(100).optional().default(20), + offset: z.coerce.number().int().min(0).optional().default(0), }); export type ListCompanyInvitesQuery = z.infer; diff --git a/server/src/__tests__/invite-list-route.test.ts b/server/src/__tests__/invite-list-route.test.ts new file mode 100644 index 00000000000..30b52b79659 --- /dev/null +++ b/server/src/__tests__/invite-list-route.test.ts @@ -0,0 +1,164 @@ +import { randomUUID } from "node:crypto"; +import express from "express"; +import request from "supertest"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { companies, createDb, invites, joinRequests } from "@paperclipai/db"; +import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js"; +import { accessRoutes } from "../routes/access.js"; +import { errorHandler } from "../middleware/index.js"; + +vi.mock("../services/index.js", () => ({ + accessService: () => ({ + isInstanceAdmin: vi.fn(), + canUser: vi.fn(), + hasPermission: vi.fn(), + }), + agentService: () => ({ + getById: vi.fn(), + }), + boardAuthService: () => ({ + createChallenge: vi.fn(), + resolveBoardAccess: vi.fn(), + assertCurrentBoardKey: vi.fn(), + revokeBoardApiKey: vi.fn(), + }), + deduplicateAgentName: vi.fn(), + logActivity: vi.fn(), + notifyHireApproved: vi.fn(), +})); + +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; + +if (!embeddedPostgresSupport.supported) { + console.warn( + `Skipping embedded Postgres invite list route tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`, + ); +} + +describeEmbeddedPostgres("GET /companies/:companyId/invites", () => { + let db!: ReturnType; + let tempDb: Awaited> | null = null; + let companyId!: string; + + beforeAll(async () => { + tempDb = await startEmbeddedPostgresTestDatabase("paperclip-invite-list-route-"); + db = createDb(tempDb.connectionString); + }, 20_000); + + beforeEach(async () => { + companyId = randomUUID(); + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + }); + + afterEach(async () => { + await db.delete(joinRequests); + await db.delete(invites); + await db.delete(companies); + }); + + afterAll(async () => { + await tempDb?.cleanup(); + }); + + function createApp(currentCompanyId: string) { + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + (req as any).actor = { + type: "board", + source: "local_implicit", + userId: null, + companyIds: [currentCompanyId], + }; + next(); + }); + app.use( + "/api", + accessRoutes(db, { + deploymentMode: "local_trusted", + deploymentExposure: "private", + bindHost: "127.0.0.1", + allowedHostnames: [], + }), + ); + app.use(errorHandler); + return app; + } + + it("returns invite history in descending pages with nextOffset", async () => { + const inviteOneId = randomUUID(); + const inviteTwoId = randomUUID(); + const inviteThreeId = randomUUID(); + + await db.insert(invites).values([ + { + id: inviteOneId, + companyId, + inviteType: "company_join", + tokenHash: "invite-token-1", + allowedJoinTypes: "human", + defaultsPayload: { humanRole: "viewer" }, + expiresAt: new Date("2026-04-20T00:00:00.000Z"), + createdAt: new Date("2026-04-10T00:00:00.000Z"), + updatedAt: new Date("2026-04-10T00:00:00.000Z"), + }, + { + id: inviteTwoId, + companyId, + inviteType: "company_join", + tokenHash: "invite-token-2", + allowedJoinTypes: "human", + defaultsPayload: { humanRole: "operator" }, + expiresAt: new Date("2026-04-21T00:00:00.000Z"), + createdAt: new Date("2026-04-11T00:00:00.000Z"), + updatedAt: new Date("2026-04-11T00:00:00.000Z"), + }, + { + id: inviteThreeId, + companyId, + inviteType: "company_join", + tokenHash: "invite-token-3", + allowedJoinTypes: "human", + defaultsPayload: { humanRole: "admin" }, + expiresAt: new Date("2026-04-22T00:00:00.000Z"), + createdAt: new Date("2026-04-12T00:00:00.000Z"), + updatedAt: new Date("2026-04-12T00:00:00.000Z"), + }, + ]); + + await db.insert(joinRequests).values({ + id: randomUUID(), + inviteId: inviteThreeId, + companyId, + requestType: "human", + status: "pending_approval", + requestIp: "127.0.0.1", + requestEmailSnapshot: "person@example.com", + createdAt: new Date("2026-04-12T00:05:00.000Z"), + updatedAt: new Date("2026-04-12T00:05:00.000Z"), + }); + + const app = createApp(companyId); + + const firstPage = await request(app).get(`/api/companies/${companyId}/invites?limit=2`); + + expect(firstPage.status).toBe(200); + expect(firstPage.body.invites).toHaveLength(2); + expect(firstPage.body.invites.map((invite: { id: string }) => invite.id)).toEqual([inviteThreeId, inviteTwoId]); + expect(firstPage.body.invites[0].relatedJoinRequestId).toBeTruthy(); + expect(firstPage.body.nextOffset).toBe(2); + + const secondPage = await request(app).get(`/api/companies/${companyId}/invites?limit=2&offset=2`); + + expect(secondPage.status).toBe(200); + expect(secondPage.body.invites).toHaveLength(1); + expect(secondPage.body.invites[0].id).toBe(inviteOneId); + expect(secondPage.body.nextOffset).toBeNull(); + }); +}); diff --git a/server/src/routes/access.ts b/server/src/routes/access.ts index d4af585997d..4e1bbd81973 100644 --- a/server/src/routes/access.ts +++ b/server/src/routes/access.ts @@ -9,7 +9,7 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import { Router } from "express"; import type { Request } from "express"; -import { and, desc, eq, inArray, isNull } from "drizzle-orm"; +import { and, desc, eq, gt, inArray, isNotNull, isNull, lte } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; import { assets, @@ -1033,25 +1033,74 @@ async function loadCompanyMemberRecords(db: Db, companyId: string) { })); } -async function loadCompanyInviteRecords(db: Db, companyId: string) { +function inviteStateWhereClause( + state: "active" | "accepted" | "expired" | "revoked" | undefined, +) { + const now = new Date(); + switch (state) { + case "active": + return and( + isNull(invites.revokedAt), + isNull(invites.acceptedAt), + gt(invites.expiresAt, now), + ); + case "accepted": + return isNotNull(invites.acceptedAt); + case "expired": + return and( + isNull(invites.revokedAt), + isNull(invites.acceptedAt), + lte(invites.expiresAt, now), + ); + case "revoked": + return isNotNull(invites.revokedAt); + default: + return undefined; + } +} + +async function loadCompanyInviteRecords( + db: Db, + companyId: string, + options: { + state?: "active" | "accepted" | "expired" | "revoked"; + limit: number; + offset: number; + }, +) { + const whereClause = inviteStateWhereClause(options.state); const rows = await db .select() .from(invites) - .where(eq(invites.companyId, companyId)) - .orderBy(desc(invites.createdAt)); + .where(whereClause ? and(eq(invites.companyId, companyId), whereClause) : eq(invites.companyId, companyId)) + .orderBy(desc(invites.createdAt)) + .limit(options.limit + 1) + .offset(options.offset); + const hasMore = rows.length > options.limit; + const visibleRows = hasMore ? rows.slice(0, options.limit) : rows; const userIds = [ ...new Set( - rows + visibleRows .map((invite) => invite.invitedByUserId) .filter((value): value is string => Boolean(value)), ), ]; const [userMap, joinRows, companyName] = await Promise.all([ loadUsersById(db, userIds), - db - .select({ id: joinRequests.id, inviteId: joinRequests.inviteId }) - .from(joinRequests) - .where(eq(joinRequests.companyId, companyId)), + visibleRows.length + ? db + .select({ id: joinRequests.id, inviteId: joinRequests.inviteId }) + .from(joinRequests) + .where( + and( + eq(joinRequests.companyId, companyId), + inArray( + joinRequests.inviteId, + visibleRows.map((invite) => invite.id), + ), + ), + ) + : Promise.resolve([]), db .select({ name: companies.name }) .from(companies) @@ -1062,17 +1111,20 @@ async function loadCompanyInviteRecords(db: Db, companyId: string) { joinRows.map((row: { inviteId: string; id: string }) => [row.inviteId, row.id]), ); - return rows.map((invite) => ({ - ...invite, - companyName, - humanRole: extractInviteHumanRole(invite), - inviteMessage: extractInviteMessage(invite), - state: inviteState(invite), - invitedByUser: invite.invitedByUserId - ? userMap.get(invite.invitedByUserId) ?? null - : null, - relatedJoinRequestId: joinRequestIdByInviteId.get(invite.id) ?? null, - })); + return { + invites: visibleRows.map((invite) => ({ + ...invite, + companyName, + humanRole: extractInviteHumanRole(invite), + inviteMessage: extractInviteMessage(invite), + state: inviteState(invite), + invitedByUser: invite.invitedByUserId + ? userMap.get(invite.invitedByUserId) ?? null + : null, + relatedJoinRequestId: joinRequestIdByInviteId.get(invite.id) ?? null, + })), + nextOffset: hasMore ? options.offset + options.limit : null, + }; } async function loadJoinRequestRecords(db: Db, companyId: string) { @@ -3110,12 +3162,8 @@ export function accessRoutes( const companyId = req.params.companyId as string; await assertCompanyPermission(req, companyId, "users:invite"); const query = listCompanyInvitesQuerySchema.parse(req.query); - const invitesForCompany = await loadCompanyInviteRecords(db, companyId); - res.json( - invitesForCompany.filter((invite) => - query.state ? invite.state === query.state : true, - ), - ); + const invitesForCompany = await loadCompanyInviteRecords(db, companyId, query); + res.json(invitesForCompany); }); router.get("/companies/:companyId/join-requests", async (req, res) => { diff --git a/ui/src/api/access.ts b/ui/src/api/access.ts index dce8fe6dbc5..23844538e51 100644 --- a/ui/src/api/access.ts +++ b/ui/src/api/access.ts @@ -156,6 +156,11 @@ export type CompanyInviteRecord = { relatedJoinRequestId: string | null; }; +export type CompanyInviteListResponse = { + invites: CompanyInviteRecord[]; + nextOffset: number | null; +}; + export type CompanyJoinRequest = JoinRequest & { requesterUser: { id: string; email: string | null; name: string | null; image: string | null } | null; approvedByUser: { id: string; email: string | null; name: string | null; image: string | null } | null; @@ -216,6 +221,19 @@ export type CurrentBoardAccess = { keyId: string | null; }; +function buildInviteListQuery(options: { + state?: "active" | "revoked" | "accepted" | "expired"; + limit?: number; + offset?: number; +}) { + const params = new URLSearchParams(); + if (options.state) params.set("state", options.state); + if (options.limit) params.set("limit", String(options.limit)); + if (options.offset) params.set("offset", String(options.offset)); + const query = params.toString(); + return query ? `?${query}` : ""; +} + export const accessApi = { createCompanyInvite: ( companyId: string, @@ -251,10 +269,14 @@ export const accessApi = { listInvites: ( companyId: string, - state?: "active" | "revoked" | "accepted" | "expired", + options: { + state?: "active" | "revoked" | "accepted" | "expired"; + limit?: number; + offset?: number; + } = {}, ) => - api.get( - `/companies/${companyId}/invites${state ? `?state=${state}` : ""}`, + api.get( + `/companies/${companyId}/invites${buildInviteListQuery(options)}`, ), revokeInvite: (inviteId: string) => api.post(`/invites/${inviteId}/revoke`, {}), diff --git a/ui/src/lib/queryKeys.ts b/ui/src/lib/queryKeys.ts index 26e47f2184e..100b210bd0d 100644 --- a/ui/src/lib/queryKeys.ts +++ b/ui/src/lib/queryKeys.ts @@ -88,8 +88,8 @@ export const queryKeys = { issues: (approvalId: string) => ["approvals", "issues", approvalId] as const, }, access: { - invites: (companyId: string, state: string = "all") => - ["access", "invites", companyId, state] as const, + invites: (companyId: string, state: string = "all", limit: number = 20) => + ["access", "invites", companyId, state, limit] as const, joinRequests: (companyId: string, status: string = "pending_approval") => ["access", "join-requests", companyId, status] as const, companyMembers: (companyId: string) => ["access", "company-members", companyId] as const, diff --git a/ui/src/pages/CompanyInvites.test.tsx b/ui/src/pages/CompanyInvites.test.tsx index 989addce750..130d6ca0221 100644 --- a/ui/src/pages/CompanyInvites.test.tsx +++ b/ui/src/pages/CompanyInvites.test.tsx @@ -16,7 +16,7 @@ const clipboardWriteTextMock = vi.hoisted(() => vi.fn()); vi.mock("@/api/access", () => ({ accessApi: { - listInvites: (companyId: string) => listInvitesMock(companyId), + listInvites: (companyId: string, options?: unknown) => listInvitesMock(companyId, options), createCompanyInvite: (companyId: string, input: unknown) => createCompanyInviteMock(companyId, input), revokeInvite: (inviteId: string) => revokeInviteMock(inviteId), @@ -50,63 +50,47 @@ async function flushReact() { describe("CompanyInvites", () => { let container: HTMLDivElement; + const inviteHistory = Array.from({ length: 25 }, (_, index) => { + const inviteNumber = 25 - index; + const isActive = inviteNumber === 25; + return { + id: `invite-${inviteNumber}`, + companyId: "company-1", + inviteType: "company_join", + tokenHash: `hash-${inviteNumber}`, + allowedJoinTypes: "human", + defaultsPayload: null, + expiresAt: "2026-04-20T00:00:00.000Z", + invitedByUserId: "user-1", + revokedAt: null, + acceptedAt: isActive ? null : "2026-04-11T00:00:00.000Z", + createdAt: `2026-04-${String(inviteNumber).padStart(2, "0")}T00:00:00.000Z`, + updatedAt: `2026-04-${String(inviteNumber).padStart(2, "0")}T00:00:00.000Z`, + companyName: "Paperclip", + humanRole: isActive ? "operator" : "viewer", + inviteMessage: null, + state: isActive ? "active" : "accepted", + invitedByUser: { + id: "user-1", + name: `Board User ${inviteNumber}`, + email: `board${inviteNumber}@paperclip.local`, + image: null, + }, + relatedJoinRequestId: isActive ? "join-1" : null, + }; + }); beforeEach(() => { container = document.createElement("div"); document.body.appendChild(container); - listInvitesMock.mockResolvedValue([ - { - id: "invite-1", - companyId: "company-1", - inviteType: "company_join", - tokenHash: "hash-1", - allowedJoinTypes: "human", - defaultsPayload: null, - expiresAt: "2026-04-20T00:00:00.000Z", - invitedByUserId: "user-1", - revokedAt: null, - acceptedAt: null, - createdAt: "2026-04-10T00:00:00.000Z", - updatedAt: "2026-04-10T00:00:00.000Z", - companyName: "Paperclip", - humanRole: "operator", - inviteMessage: null, - state: "active", - invitedByUser: { - id: "user-1", - name: "Board User", - email: "board@paperclip.local", - image: null, - }, - relatedJoinRequestId: "join-1", - }, - { - id: "invite-2", - companyId: "company-1", - inviteType: "company_join", - tokenHash: "hash-2", - allowedJoinTypes: "human", - defaultsPayload: null, - expiresAt: "2026-04-20T00:00:00.000Z", - invitedByUserId: "user-1", - revokedAt: null, - acceptedAt: "2026-04-11T00:00:00.000Z", - createdAt: "2026-04-09T00:00:00.000Z", - updatedAt: "2026-04-11T00:00:00.000Z", - companyName: "Paperclip", - humanRole: "viewer", - inviteMessage: null, - state: "accepted", - invitedByUser: { - id: "user-1", - name: "Board User", - email: "board@paperclip.local", - image: null, - }, - relatedJoinRequestId: null, - }, - ]); + listInvitesMock.mockImplementation((_companyId: string, options?: { limit?: number; offset?: number }) => { + const limit = options?.limit ?? 20; + const offset = options?.offset ?? 0; + const invites = inviteHistory.slice(offset, offset + limit); + const nextOffset = offset + invites.length < inviteHistory.length ? offset + invites.length : null; + return Promise.resolve({ invites, nextOffset }); + }); createCompanyInviteMock.mockResolvedValue({ inviteUrl: "https://paperclip.local/invite/new-token", @@ -151,8 +135,11 @@ describe("CompanyInvites", () => { expect(container.textContent).toContain("Company Invites"); expect(container.textContent).toContain("Create invite"); expect(container.textContent).toContain("Invite history"); - expect(container.textContent).toContain("Board User"); + expect(container.textContent).toContain("Board User 25"); + expect(container.textContent).toContain("Board User 6"); + expect(container.textContent).not.toContain("Board User 5"); expect(container.textContent).toContain("Review request"); + expect(container.textContent).toContain("View more"); expect(container.textContent).not.toContain("Human or agent"); expect(container.textContent).not.toContain("Invite message"); expect(container.textContent).not.toContain("Latest generated invite"); @@ -165,6 +152,22 @@ describe("CompanyInvites", () => { expect(container.textContent).toContain("Each invite link is single-use."); expect(container.textContent).toContain("Can create agents, invite users, assign tasks, and approve join requests."); expect(container.textContent).toContain("Everything in Admin, plus managing members and permission grants."); + expect(listInvitesMock).toHaveBeenCalledWith("company-1", { limit: 20, offset: 0 }); + + const viewMoreButton = Array.from(container.querySelectorAll("button")).find( + (button) => button.textContent === "View more", + ); + + await act(async () => { + viewMoreButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await flushReact(); + await flushReact(); + + expect(listInvitesMock).toHaveBeenCalledWith("company-1", { limit: 20, offset: 20 }); + expect(container.textContent).toContain("Board User 5"); + expect(container.textContent).toContain("Board User 1"); + expect(container.textContent).not.toContain("View more"); await act(async () => { const viewerRadio = container.querySelector('input[type="radio"][value="viewer"]') as HTMLInputElement | null; @@ -218,7 +221,7 @@ describe("CompanyInvites", () => { }); await flushReact(); - expect(revokeInviteMock).toHaveBeenCalledWith("invite-1"); + expect(revokeInviteMock).toHaveBeenCalledWith("invite-25"); await act(async () => { root.unmount(); diff --git a/ui/src/pages/CompanyInvites.tsx b/ui/src/pages/CompanyInvites.tsx index 16ebc87e741..efdcef08c22 100644 --- a/ui/src/pages/CompanyInvites.tsx +++ b/ui/src/pages/CompanyInvites.tsx @@ -1,5 +1,5 @@ -import { useEffect, useState } from "react"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useEffect, useMemo, useState } from "react"; +import { useInfiniteQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { Check, ExternalLink, MailPlus } from "lucide-react"; import { accessApi } from "@/api/access"; import { ApiError } from "@/api/client"; @@ -37,6 +37,8 @@ const inviteRoleOptions = [ }, ] as const; +const INVITE_HISTORY_PAGE_SIZE = 20; + export function CompanyInvites() { const { selectedCompany, selectedCompanyId } = useCompany(); const { setBreadcrumbs } = useBreadcrumbs(); @@ -80,11 +82,22 @@ export function CompanyInvites() { ]); }, [selectedCompany?.name, setBreadcrumbs]); - const invitesQuery = useQuery({ - queryKey: queryKeys.access.invites(selectedCompanyId ?? "", "all"), - queryFn: () => accessApi.listInvites(selectedCompanyId!), + const inviteHistoryQueryKey = queryKeys.access.invites(selectedCompanyId ?? "", "all", INVITE_HISTORY_PAGE_SIZE); + const invitesQuery = useInfiniteQuery({ + queryKey: inviteHistoryQueryKey, + queryFn: ({ pageParam }) => + accessApi.listInvites(selectedCompanyId!, { + limit: INVITE_HISTORY_PAGE_SIZE, + offset: pageParam, + }), enabled: !!selectedCompanyId, + initialPageParam: 0, + getNextPageParam: (lastPage) => lastPage.nextOffset ?? undefined, }); + const inviteHistory = useMemo( + () => invitesQuery.data?.pages.flatMap((page) => page.invites) ?? [], + [invitesQuery.data?.pages], + ); const createInviteMutation = useMutation({ mutationFn: () => @@ -98,7 +111,7 @@ export function CompanyInvites() { setLatestInviteCopied(false); const copied = await copyInviteUrl(invite.inviteUrl); - await queryClient.invalidateQueries({ queryKey: queryKeys.access.invites(selectedCompanyId!, "all") }); + await queryClient.invalidateQueries({ queryKey: inviteHistoryQueryKey }); pushToast({ title: "Invite created", body: copied ? "Invite ready below and copied to clipboard." : "Invite ready below.", @@ -117,7 +130,7 @@ export function CompanyInvites() { const revokeMutation = useMutation({ mutationFn: (inviteId: string) => accessApi.revokeInvite(inviteId), onSuccess: async () => { - await queryClient.invalidateQueries({ queryKey: queryKeys.access.invites(selectedCompanyId!, "all") }); + await queryClient.invalidateQueries({ queryKey: inviteHistoryQueryKey }); pushToast({ title: "Invite revoked", tone: "success" }); }, onError: (error) => { @@ -265,68 +278,82 @@ export function CompanyInvites() { - {(invitesQuery.data ?? []).length === 0 ? ( + {inviteHistory.length === 0 ? (
No invites have been created for this company yet.
) : ( -
- - - - - - - - - - - - - {invitesQuery.data!.map((invite) => ( - - - - - - - +
+
+
StateRoleInvited byCreatedJoin requestAction
- - {formatInviteState(invite.state)} - - {invite.humanRole ?? "—"} -
{invite.invitedByUser?.name || invite.invitedByUser?.email || "Unknown inviter"}
- {invite.invitedByUser?.email && invite.invitedByUser.name ? ( -
{invite.invitedByUser.email}
- ) : null} -
- {new Date(invite.createdAt).toLocaleString()} - - {invite.relatedJoinRequestId ? ( - - Review request - - ) : ( - — - )} - - {invite.state === "active" ? ( - - ) : ( - Inactive - )} -
+ + + + + + + + - ))} - -
StateRoleInvited byCreatedJoin requestAction
+ + + {inviteHistory.map((invite) => ( + + + + {formatInviteState(invite.state)} + + + {invite.humanRole ?? "—"} + +
{invite.invitedByUser?.name || invite.invitedByUser?.email || "Unknown inviter"}
+ {invite.invitedByUser?.email && invite.invitedByUser.name ? ( +
{invite.invitedByUser.email}
+ ) : null} + + + {new Date(invite.createdAt).toLocaleString()} + + + {invite.relatedJoinRequestId ? ( + + Review request + + ) : ( + — + )} + + + {invite.state === "active" ? ( + + ) : ( + Inactive + )} + + + ))} + + +
+ {invitesQuery.hasNextPage ? ( +
+ +
+ ) : null} )}