Separate invite resend and regeneration and add copy link

This commit is contained in:
2026-09-10 12:01:15 -04:00
parent d44ef7348c
commit a7541104ef
7 changed files with 97 additions and 12 deletions
+35 -5
View File
@@ -1,5 +1,5 @@
import { TRPCError } from "@trpc/server";
import { and, desc, eq, gt, isNull, lt, or } from "drizzle-orm";
import { and, desc, eq, gt, isNull, lt, or, sql } from "drizzle-orm";
import {
auditEvents,
eventMemberships,
@@ -29,6 +29,7 @@ import { resolveGroupQuota } from "@/server/entitlements";
import { countGroupOwners } from "@/server/membership";
import { writeAudit } from "@/server/audit";
import { hashToken, newToken } from "@/server/tokens";
import { decryptInviteToken, encryptInviteToken } from "@/server/invite-token";
import { createInviteCode } from "@/server/invites";
import { sendStaffInviteEmail } from "@album/email";
import { publicAppOrigin } from "@/server/public-app-url";
@@ -57,7 +58,23 @@ async function validateInviteGrants(userId: string, input: {
}
export const groupRouter = createTRPCRouter({
resendInvite: protectedProcedure.input(z.object({ groupId: z.string().uuid(), inviteId: z.string().uuid() })).mutation(async ({ ctx, input }) => {
copyInviteLink: protectedProcedure.input(z.object({ groupId: z.string().uuid(), inviteId: z.string().uuid() })).mutation(async ({ ctx, input }) => {
const { access } = await loadGroupAccess(ctx.session.user.id, input.groupId, await getPlatformRole(ctx.session.user.id));
requireGroupPermission(access.permissions, GROUP_PERMISSIONS.PEOPLE_MANAGE);
const [invite] = await getDb().select().from(invites).where(and(eq(invites.id, input.inviteId), eq(invites.groupId, input.groupId))).limit(1);
if (!invite || invite.kind !== "email") throw new TRPCError({ code: "NOT_FOUND" });
assertInviteRedeemable(invite);
await validateInviteGrants(ctx.session.user.id, { groupId: input.groupId, eventId: invite.eventId ?? undefined, eventRole: invite.eventRole ?? undefined, grantUnlimitedEvents: invite.grantUnlimitedEvents, grantEventLimit: invite.grantEventLimit, grantComplimentary: invite.grantComplimentary });
if (!invite.tokenEncrypted) throw new TRPCError({ code: "BAD_REQUEST", message: "This older invitation needs an explicit regeneration before its link can be copied." });
try {
const token = decryptInviteToken(invite.tokenEncrypted);
if (hashToken(token) !== invite.tokenHash) throw new Error("Token mismatch");
return { url: `${publicAppOrigin()}/invitations/${token}` };
} catch {
throw new TRPCError({ code: "BAD_REQUEST", message: "The saved link cannot be recovered. Regenerate this invitation explicitly." });
}
}),
resendInvite: protectedProcedure.input(z.object({ groupId: z.string().uuid(), inviteId: z.string().uuid(), regenerate: z.boolean().default(false) })).mutation(async ({ ctx, input }) => {
const { access } = await loadGroupAccess(ctx.session.user.id, input.groupId, await getPlatformRole(ctx.session.user.id));
requireGroupPermission(access.permissions, GROUP_PERMISSIONS.PEOPLE_MANAGE);
const limit = await consumeRateLimit({ namespace: "invite-resend", identifier: `${input.groupId}:${input.inviteId}`, limit: 1, windowMs: 60000 });
@@ -67,10 +84,21 @@ export const groupRouter = createTRPCRouter({
if (!invite || invite.kind !== "email" || !invite.email) throw new TRPCError({ code: "NOT_FOUND" });
assertInviteRedeemable(invite);
await validateInviteGrants(ctx.session.user.id, { groupId: input.groupId, eventId: invite.eventId ?? undefined, eventRole: invite.eventRole ?? undefined, grantUnlimitedEvents: invite.grantUnlimitedEvents, grantEventLimit: invite.grantEventLimit, grantComplimentary: invite.grantComplimentary });
const token = newToken();
await tx.update(invites).set({ tokenHash: hashToken(token), updatedAt: new Date() }).where(and(eq(invites.id, invite.id), eq(invites.groupId, input.groupId)));
let token: string;
if (input.regenerate) {
token = newToken();
await tx.update(invites).set({ tokenHash: hashToken(token), tokenEncrypted: encryptInviteToken(token), updatedAt: new Date() }).where(and(eq(invites.id, invite.id), eq(invites.groupId, input.groupId)));
} else {
if (!invite.tokenEncrypted) throw new TRPCError({ code: "BAD_REQUEST", message: "This older invitation needs one explicit regeneration before it can be resent." });
try {
token = decryptInviteToken(invite.tokenEncrypted);
if (hashToken(token) !== invite.tokenHash) throw new Error("Token mismatch");
} catch {
throw new TRPCError({ code: "BAD_REQUEST", message: "The saved link cannot be recovered. Regenerate this invitation explicitly." });
}
}
await sendStaffInviteEmail({ to: invite.email, inviterName: ctx.session.user.name, inviteUrl: `${publicAppOrigin()}/invitations/${token}` });
await tx.insert(auditEvents).values({ groupId: input.groupId, eventId: invite.eventId, actorUserId: ctx.session.user.id, action: "invite.resend", subjectType: "invite", subjectId: invite.id });
await tx.insert(auditEvents).values({ groupId: input.groupId, eventId: invite.eventId, actorUserId: ctx.session.user.id, action: input.regenerate ? "invite.regenerate" : "invite.resend", subjectType: "invite", subjectId: invite.id });
});
return { ok: true };
}),
@@ -119,6 +147,7 @@ export const groupRouter = createTRPCRouter({
eventId: invites.eventId, eventTitle: events.title,
reusable: invites.reusable, usedCount: invites.usedCount, maxUses: invites.maxUses,
createdAt: invites.createdAt, expiresAt: invites.expiresAt,
canResend: sql<boolean>`${invites.tokenEncrypted} is not null`,
}).from(invites).leftJoin(events, and(eq(events.id, invites.eventId), eq(events.groupId, input.groupId)))
.where(and(
eq(invites.groupId, input.groupId),
@@ -322,6 +351,7 @@ export const groupRouter = createTRPCRouter({
kind: "email",
email: input.email,
tokenHash: hashToken(token),
tokenEncrypted: encryptInviteToken(token),
groupId: input.groupId,
eventId: input.eventId ?? null,
groupRole: input.groupRole ?? "member",
+22
View File
@@ -0,0 +1,22 @@
import { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:crypto";
function key() {
const secret = process.env.BETTER_AUTH_SECRET;
if (!secret) throw new Error("BETTER_AUTH_SECRET is required");
return createHash("sha256").update(`manyangles:invite-token:v1:${secret}`).digest();
}
export function encryptInviteToken(token: string) {
const iv = randomBytes(12);
const cipher = createCipheriv("aes-256-gcm", key(), iv);
const encrypted = Buffer.concat([cipher.update(token, "utf8"), cipher.final()]);
return ["v1", iv.toString("base64url"), cipher.getAuthTag().toString("base64url"), encrypted.toString("base64url")].join(".");
}
export function decryptInviteToken(value: string) {
const [version, iv, tag, encrypted] = value.split(".");
if (version !== "v1" || !iv || !tag || !encrypted) throw new Error("Invalid encrypted invitation");
const decipher = createDecipheriv("aes-256-gcm", key(), Buffer.from(iv, "base64url"));
decipher.setAuthTag(Buffer.from(tag, "base64url"));
return Buffer.concat([decipher.update(Buffer.from(encrypted, "base64url")), decipher.final()]).toString("utf8");
}
+12 -2
View File
@@ -5,6 +5,7 @@ import { getDb, user, groups, events, eventMemberships, groupMemberships, invite
import { bannersRouter } from "./api/routers/banners";
import { deletePrefix } from "@album/storage";
import { hashToken } from "./tokens";
import { encryptInviteToken } from "./invite-token";
import { redeemInviteForUser } from "./invites";
import { signsRouter } from "./api/routers/signs";
import { groupRouter } from "./api/routers/group";
@@ -64,11 +65,20 @@ test.skipIf(process.env.POLISH_INTEGRATION !== "1")("invitation isolation, atomi
const [revoked] = await db.select().from(invites).where(and(eq(invites.groupId, group!.id), eq(invites.id, pending!.id)));
expect(revoked!.status).toBe("revoked");
const resendToken = crypto.randomUUID();
const [resendTarget] = await db.insert(invites).values({ groupId: group!.id, kind: "email", email: people[1]!.email, tokenHash: hashToken(resendToken) }).returning();
const [resendTarget] = await db.insert(invites).values({ groupId: group!.id, kind: "email", email: people[1]!.email, tokenHash: hashToken(resendToken), tokenEncrypted: encryptInviteToken(resendToken) }).returning();
await expect(groupRouter.createCaller(ctx(1)).resendInvite({ groupId: group!.id, inviteId: resendTarget!.id })).rejects.toThrow();
await workspace.resendInvite({ groupId: group!.id, inviteId: resendTarget!.id });
expect(await db.select().from(invites).where(and(eq(invites.groupId, group!.id), eq(invites.tokenHash, hashToken(resendToken))))).toHaveLength(0);
const copied = await workspace.copyInviteLink({ groupId: group!.id, inviteId: resendTarget!.id });
expect(new URL(copied.url).pathname).toBe(`/invitations/${resendToken}`);
await expect(groupRouter.createCaller(ctx(1)).copyInviteLink({ groupId: group!.id, inviteId: resendTarget!.id })).rejects.toThrow();
expect(await db.select().from(invites).where(and(eq(invites.groupId, group!.id), eq(invites.tokenHash, hashToken(resendToken))))).toHaveLength(1);
const rotateToken = crypto.randomUUID();
const [rotateTarget] = await db.insert(invites).values({ groupId: group!.id, kind: "email", email: people[1]!.email, tokenHash: hashToken(rotateToken) }).returning();
await workspace.resendInvite({ groupId: group!.id, inviteId: rotateTarget!.id, regenerate: true });
expect(await db.select().from(invites).where(and(eq(invites.groupId, group!.id), eq(invites.tokenHash, hashToken(rotateToken))))).toHaveLength(0);
await expect(workspace.resendInvite({ groupId: group!.id, inviteId: resendTarget!.id })).rejects.toThrow("Wait one minute");
await workspace.revokeInvite({ groupId: group!.id, inviteId: resendTarget!.id });
await expect(workspace.copyInviteLink({ groupId: group!.id, inviteId: resendTarget!.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();