diff --git a/apps/web/src/components/pending-invites.tsx b/apps/web/src/components/pending-invites.tsx index 01c2196..7fcc184 100644 --- a/apps/web/src/components/pending-invites.tsx +++ b/apps/web/src/components/pending-invites.tsx @@ -9,14 +9,32 @@ import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert"; import { Empty, EmptyHeader, EmptyTitle, EmptyDescription } from "@/components/ui/empty"; import { Skeleton } from "@/components/ui/skeleton"; import { Spinner } from "@/components/ui/spinner"; -import { Mail } from "lucide-react"; +import { Copy, Mail, RefreshCw } from "lucide-react"; const label = (role: string) => role.charAt(0).toUpperCase() + role.slice(1); export function PendingInvites({ groupId, eventId, canRevoke = false }: { groupId: string; eventId?: string; canRevoke?: boolean }) { const utils = api.useUtils(); const confirmation = useConfirmAction(); - const resend = api.group.resendInvite.useMutation({ onSuccess: () => toast.success("Invitation resent. Previous link replaced."), onError: error => toast.error(error.message) }); + const copyLink = api.group.copyInviteLink.useMutation(); + async function copyInvite(inviteId: string) { + try { + // Start the clipboard operation in the click gesture, including on Safari. + if (typeof ClipboardItem !== "undefined" && navigator.clipboard?.write) { + const content = copyLink.mutateAsync({ groupId, inviteId }).then(({ url }) => new Blob([url], { type: "text/plain" })); + await navigator.clipboard.write([new ClipboardItem({ "text/plain": content })]); + } else { + const { url } = await copyLink.mutateAsync({ groupId, inviteId }); + await navigator.clipboard.writeText(url); + } + toast.success("Invitation link copied"); + } catch { + toast.error("Could not copy the link. Check clipboard permissions and try again."); + } finally { + copyLink.reset(); + } + } + const resend = api.group.resendInvite.useMutation({ onSuccess: async (_, input) => { toast.success(input.regenerate ? "New invitation link emailed. Previous link disabled." : "Invitation resent with the same link."); await utils.group.pendingInvites.invalidate(); }, onError: error => toast.error(error.message) }); const revoke = api.group.revokeInvite.useMutation({ onSuccess: async () => { toast.success("Invite revoked"); await utils.group.pendingInvites.invalidate(); }, onError: error => toast.error(error.message), @@ -42,11 +60,13 @@ export function PendingInvites({ groupId, eventId, canRevoke = false }: { groupI {invite.groupRole ? Workspace {label(invite.groupRole)} : null} {invite.eventRole ? Event {label(invite.eventRole)} : null} {invite.kind === "email" ? "Awaiting acceptance" : "Active code"} - {canRevoke ? : null} - {canRevoke && invite.kind === "email" ? : null} + {canRevoke ? : null} + {canRevoke && invite.kind === "email" ? : null} + {canRevoke && invite.kind === "email" ? : null} )}} ; diff --git a/apps/web/src/server/api/routers/group.ts b/apps/web/src/server/api/routers/group.ts index b9ea3b0..a95f503 100644 --- a/apps/web/src/server/api/routers/group.ts +++ b/apps/web/src/server/api/routers/group.ts @@ -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`${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", diff --git a/apps/web/src/server/invite-token.ts b/apps/web/src/server/invite-token.ts new file mode 100644 index 0000000..176ebdb --- /dev/null +++ b/apps/web/src/server/invite-token.ts @@ -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"); +} diff --git a/apps/web/src/server/polish.integration.test.ts b/apps/web/src/server/polish.integration.test.ts index f3fc30f..3e172df 100644 --- a/apps/web/src/server/polish.integration.test.ts +++ b/apps/web/src/server/polish.integration.test.ts @@ -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(); diff --git a/packages/database/drizzle/0012_invite_token.sql b/packages/database/drizzle/0012_invite_token.sql new file mode 100644 index 0000000..18af0ea --- /dev/null +++ b/packages/database/drizzle/0012_invite_token.sql @@ -0,0 +1 @@ +ALTER TABLE "invites" ADD COLUMN "token_encrypted" text; diff --git a/packages/database/drizzle/meta/_journal.json b/packages/database/drizzle/meta/_journal.json index 0851589..1fa73ce 100644 --- a/packages/database/drizzle/meta/_journal.json +++ b/packages/database/drizzle/meta/_journal.json @@ -79,6 +79,7 @@ "tag": "0010_banner_crop", "breakpoints": true }, - { "idx": 11, "version": "7", "when": 1789086000000, "tag": "0011_event_signs", "breakpoints": true } + { "idx": 11, "version": "7", "when": 1789086000000, "tag": "0011_event_signs", "breakpoints": true }, + { "idx": 12, "version": "7", "when": 1789086100000, "tag": "0012_invite_token", "breakpoints": true } ] } diff --git a/packages/database/src/schema.ts b/packages/database/src/schema.ts index da5aaaf..c760090 100644 --- a/packages/database/src/schema.ts +++ b/packages/database/src/schema.ts @@ -304,6 +304,7 @@ export const invites = pgTable( status: inviteStatus("status").notNull().default("pending"), email: text("email"), tokenHash: text("token_hash").notNull(), + tokenEncrypted: text("token_encrypted"), reusable: boolean("reusable").notNull().default(false), maxUses: integer("max_uses").notNull().default(1), usedCount: integer("used_count").notNull().default(0),