Show pending account invites and polish sign editor recovery

This commit is contained in:
2026-09-10 10:10:07 -04:00
parent 6f400ac2cd
commit 74592718c1
11 changed files with 177 additions and 18 deletions
+29 -1
View File
@@ -1,5 +1,5 @@
import { TRPCError } from "@trpc/server";
import { and, desc, eq } from "drizzle-orm";
import { and, desc, eq, gt, isNull, lt, or } from "drizzle-orm";
import {
auditEvents,
eventMemberships,
@@ -55,6 +55,34 @@ async function validateInviteGrants(userId: string, input: {
}
export const groupRouter = createTRPCRouter({
pendingInvites: protectedProcedure
.input(z.object({ groupId: z.string().uuid(), eventId: z.string().uuid().optional() }))
.query(async ({ ctx, input }) => {
const platformRole = await getPlatformRole(ctx.session.user.id);
if (input.eventId) {
const { event, access } = await loadEventAccess(ctx.session.user.id, input.eventId, platformRole);
if (event.groupId !== input.groupId) throw new TRPCError({ code: "NOT_FOUND" });
requireEventPermission(access.permissions, EVENT_PERMISSIONS.PEOPLE_MANAGE);
} else {
const { access } = await loadGroupAccess(ctx.session.user.id, input.groupId, platformRole);
requireGroupPermission(access.permissions, GROUP_PERMISSIONS.PEOPLE_MANAGE);
}
return getDb().select({
id: invites.id, kind: invites.kind, email: invites.email,
groupRole: invites.groupRole, eventRole: invites.eventRole,
eventId: invites.eventId, eventTitle: events.title,
reusable: invites.reusable, usedCount: invites.usedCount, maxUses: invites.maxUses,
createdAt: invites.createdAt, expiresAt: invites.expiresAt,
}).from(invites).leftJoin(events, and(eq(events.id, invites.eventId), eq(events.groupId, input.groupId)))
.where(and(
eq(invites.groupId, input.groupId),
input.eventId ? eq(invites.eventId, input.eventId) : undefined,
eq(invites.status, "pending"),
or(isNull(invites.expiresAt), gt(invites.expiresAt, new Date())),
lt(invites.usedCount, invites.maxUses),
)).orderBy(desc(invites.createdAt));
}),
list: protectedProcedure.query(async ({ ctx }) => {
return getDb()
.select({
@@ -28,6 +28,18 @@ test.skipIf(process.env.POLISH_INTEGRATION !== "1")("invitation isolation, atomi
const [used] = await db.select().from(invites).where(eq(invites.tokenHash, hashToken(token)));
expect(used!.usedCount).toBe(1);
const workspace = groupRouter.createCaller(ctx(0));
const [pending] = await db.insert(invites).values({ kind: "email", email: people[1]!.email, tokenHash: hashToken(crypto.randomUUID()), groupId: group!.id, eventId: event!.id, eventRole: "manager" }).returning();
await db.insert(invites).values([
{ kind: "email", tokenHash: hashToken(crypto.randomUUID()), groupId: group!.id, expiresAt: new Date(0) },
{ kind: "email", tokenHash: hashToken(crypto.randomUUID()), groupId: group!.id, usedCount: 1, maxUses: 1 },
{ kind: "email", tokenHash: hashToken(crypto.randomUUID()), groupId: group!.id, status: "revoked" },
]);
const listed = await workspace.pendingInvites({ groupId: group!.id, eventId: event!.id });
expect(listed.map(invite => invite.id)).toEqual([pending!.id]);
expect(listed[0]).not.toHaveProperty("tokenHash");
expect(await workspace.pendingInvites({ groupId: group!.id })).toHaveLength(1);
await expect(groupRouter.createCaller(ctx(1)).pendingInvites({ groupId: group!.id })).rejects.toThrow();
await expect(workspace.pendingInvites({ groupId: crypto.randomUUID(), eventId: event!.id })).rejects.toThrow();
await expect(workspace.createCode({ groupId: group!.id, grantUnlimitedEvents: true })).rejects.toThrow("platform administrators");
const signs = signsRouter.createCaller(ctx(0));
await expect(signsRouter.createCaller(ctx(1)).prepare({ eventId: event!.id })).rejects.toThrow();