From d44ef7348c8e6490640f0b7f5c1f908a282e8981 Mon Sep 17 00:00:00 2001 From: Sean O'Connor Date: Thu, 10 Sep 2026 11:53:59 -0400 Subject: [PATCH] Polish invitation resend and upload recovery --- .../src/app/dashboard/people/group-people.tsx | 14 +++-- apps/web/src/app/e/[slug]/guest-upload.tsx | 43 ++++++++++----- apps/web/src/app/invitations/[token]/page.tsx | 2 +- .../app/invitations/[token]/redeem-button.tsx | 11 +++- apps/web/src/components/banner-upload.tsx | 9 +++- apps/web/src/components/confirm-action.tsx | 17 ++++++ apps/web/src/components/pending-invites.tsx | 12 ++++- apps/web/src/lib/upload.ts | 5 +- apps/web/src/server/api/routers/banners.ts | 9 ++++ apps/web/src/server/api/routers/group.ts | 19 +++++++ apps/web/src/server/api/routers/photos.ts | 18 ++++++- .../server/invite-journey.integration.test.ts | 53 +++++++++++++++++++ apps/web/src/server/invites.ts | 1 + .../web/src/server/polish.integration.test.ts | 14 ++++- .../src/server/publishing.integration.test.ts | 4 ++ 15 files changed, 208 insertions(+), 23 deletions(-) create mode 100644 apps/web/src/components/confirm-action.tsx create mode 100644 apps/web/src/server/invite-journey.integration.test.ts diff --git a/apps/web/src/app/dashboard/people/group-people.tsx b/apps/web/src/app/dashboard/people/group-people.tsx index 76e0be7..5fdfc3a 100644 --- a/apps/web/src/app/dashboard/people/group-people.tsx +++ b/apps/web/src/app/dashboard/people/group-people.tsx @@ -7,6 +7,7 @@ import { toast } from "sonner"; import type { GroupRole } from "@album/contracts"; import { api } from "@/trpc/react"; import { PendingInvites } from "@/components/pending-invites"; +import { useConfirmAction } from "@/components/confirm-action"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Badge } from "@/components/ui/badge"; @@ -26,6 +27,8 @@ export function GroupPeople({ canManage: boolean; }) { const utils = api.useUtils(); + const confirmation = useConfirmAction(); + const [search, setSearch] = useState(""); const members = api.group.members.useQuery({ groupId }); const [email, setEmail] = useState(""); const [role, setRole] = useState("member"); @@ -66,15 +69,18 @@ export function GroupPeople({ + {confirmation.dialog} + setSearch(event.target.value)} /> + {members.data && !members.data.some(member => `${member.name} ${member.email}`.toLowerCase().includes(search.toLowerCase())) ?

No matching members.

: null}
    - {(members.data ?? []).map((member) => ( -
  • + {(members.data ?? []).filter(member => `${member.name} ${member.email}`.toLowerCase().includes(search.toLowerCase())).map((member) => ( +
  • {member.name}

    {member.email}

    - {canManage ? confirmation.ask(`Change ${member.name}'s group role to ${value}?`, () => setMember.mutate({ groupId, userId: member.userId, role: value as GroupRole }))}> MemberOwner : {member.role === "owner" ? "Owner" : "Member"}} @@ -83,7 +89,7 @@ export function GroupPeople({ disabled={remove.isPending || setMember.isPending} variant="ghost" onClick={() => - window.confirm("Remove this member from the group and all its events?") && remove.mutate({ groupId, userId: member.userId }) + confirmation.ask("Remove this member from the group and all its events?", () => remove.mutate({ groupId, userId: member.userId })) } > Remove diff --git a/apps/web/src/app/e/[slug]/guest-upload.tsx b/apps/web/src/app/e/[slug]/guest-upload.tsx index b203124..946cb1e 100644 --- a/apps/web/src/app/e/[slug]/guest-upload.tsx +++ b/apps/web/src/app/e/[slug]/guest-upload.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { toast } from "sonner"; import { ChevronDownIcon, SendIcon, UploadIcon } from "lucide-react"; import { useRouter } from "next/navigation"; @@ -19,6 +19,9 @@ import { Progress } from "@/components/ui/progress"; import { Spinner } from "@/components/ui/spinner"; type QueueItem = { + file?: File; + created?: { photoId: string; eventId: string; uploadUrl: string }; + uploaded?: boolean; id: string; name: string; progress: number; @@ -53,6 +56,8 @@ export function GuestUpload({ const startSubmission = api.guest.startSubmission.useMutation(); const createPhoto = api.photos.create.useMutation(); const completePhoto = api.photos.complete.useMutation(); + const retryUpload = api.photos.retryUpload.useMutation(); + const uploading = useRef(false); const utils = api.useUtils(); useEffect(() => { @@ -70,6 +75,12 @@ export function GuestUpload({ () => queue.some((item) => item.status === "queued" || item.status === "uploading"), [queue], ); + useEffect(() => { + if (!busy) return; + const warn = (event: BeforeUnloadEvent) => { event.preventDefault(); event.returnValue = ""; }; + window.addEventListener("beforeunload", warn); + return () => window.removeEventListener("beforeunload", warn); + }, [busy]); async function sendNote(detailsOnly = false) { try { @@ -84,29 +95,32 @@ export function GuestUpload({ } catch (error) { toast.error(error instanceof Error ? error.message : "Could not send note"); } } - async function uploadFiles(files: File[]) { + async function uploadFiles(files: File[], retryItems?: QueueItem[]) { const accepted = files.filter(isAllowedPhoto); - if (!uploadEnabled || busy) return; + if (!uploadEnabled || uploading.current) return; if (accepted.length !== files.length) { toast.error("Some files were skipped. Use JPEG, PNG, WebP, or HEIC under 25 MB."); } if (!accepted.length) return; - const items: QueueItem[] = accepted.map((file) => ({ + uploading.current = true; + const items: QueueItem[] = retryItems ?? accepted.map((file) => ({ + file, id: crypto.randomUUID(), name: file.name, progress: 0, status: "queued", })); - setQueue((current) => [...items, ...current]); + for (const item of items) { item.status = "queued"; item.error = undefined; } + setQueue((current) => retryItems ? current.map(entry => items.find(item => item.id === entry.id) ?? entry) : [...items, ...current]); const trimmedName = name.trim(); const trimmedEmail = email.trim(); const trimmedNote = note.trim(); + try { localStorage.setItem(guestKey(slug, "name"), trimmedName); localStorage.setItem(guestKey(slug, "email"), trimmedEmail); localStorage.setItem(guestKey(slug, "note"), trimmedNote); localStorage.setItem(guestKey(slug, "notify"), String(notify)); - try { await ensureGuest.mutateAsync({ eventSlug: slug, displayName: trimmedName || undefined, @@ -127,25 +141,29 @@ export function GuestUpload({ ), ); try { - const created = await createPhoto.mutateAsync({ + const created = item.created ?? await createPhoto.mutateAsync({ eventSlug: slug, submissionId: submission.submissionId, contentType, fileName: file.name, byteSize: file.size, }); - await putWithProgress(created.uploadUrl, file, contentType, (progress) => { + const previous = item.created; + item.created = created; + const uploadUrl = previous && !item.uploaded ? (await retryUpload.mutateAsync({ photoId: created.photoId, eventId: created.eventId })).uploadUrl : created.uploadUrl; + if (!item.uploaded && uploadUrl) await putWithProgress(uploadUrl, file, contentType, (progress) => { setQueue((current) => current.map((entry) => entry.id === item.id ? { ...entry, progress } : entry, ), ); }); + item.uploaded = true; await completePhoto.mutateAsync({ photoId: created.photoId }); setQueue((current) => current.map((entry) => entry.id === item.id - ? { ...entry, status: "done", progress: 100 } + ? { ...entry, status: "done", progress: 100, file: undefined } : entry, ), ); @@ -155,7 +173,7 @@ export function GuestUpload({ setQueue((current) => current.map((entry) => entry.id === item.id - ? { ...entry, status: "error", error: message } + ? { ...entry, created: item.created, uploaded: item.uploaded, status: "error", error: message } : entry, ), ); @@ -172,7 +190,7 @@ export function GuestUpload({ : entry, ), ); - } + } finally { uploading.current = false; } } if (!uploadEnabled && !notesEnabled) { @@ -244,12 +262,13 @@ export function GuestUpload({
  • {item.name} - {item.status} + {item.status === "done" ? "Uploaded · processing for gallery" : item.status === "uploading" ? `${item.progress}% uploaded` : item.status === "error" ? "Needs retry" : "Waiting"}
    {item.error ? (

    {item.error}

    ) : null} + {item.status === "error" && item.file ? : null}
  • ))}
diff --git a/apps/web/src/app/invitations/[token]/page.tsx b/apps/web/src/app/invitations/[token]/page.tsx index 81e95b0..5eec1bf 100644 --- a/apps/web/src/app/invitations/[token]/page.tsx +++ b/apps/web/src/app/invitations/[token]/page.tsx @@ -54,7 +54,7 @@ export default async function InvitationPage({

This invitation grants team access, not a guest RSVP. Your assigned role controls what you can view and manage.

- {session ? : <> + {session ? : <> } diff --git a/apps/web/src/app/invitations/[token]/redeem-button.tsx b/apps/web/src/app/invitations/[token]/redeem-button.tsx index 54330b2..55d8215 100644 --- a/apps/web/src/app/invitations/[token]/redeem-button.tsx +++ b/apps/web/src/app/invitations/[token]/redeem-button.tsx @@ -5,8 +5,11 @@ import { toast } from "sonner"; import { api } from "@/trpc/react"; import { Button } from "@/components/ui/button"; import { Spinner } from "@/components/ui/spinner"; +import { useState } from "react"; +import { authClient } from "@/lib/auth-client"; -export function RedeemInviteButton({ token }: { token: string }) { +export function RedeemInviteButton({ token, unverifiedEmail }: { token: string; unverifiedEmail?: string }) { + const [sending, setSending] = useState(false); const router = useRouter(); const redeem = api.invites.redeem.useMutation({ onSuccess: (result) => { @@ -21,6 +24,12 @@ export function RedeemInviteButton({ token }: { token: string }) { onError: (error) => toast.error(error.message), }); + if (unverifiedEmail) return

Verify your email before accepting. Open the verification link in your inbox, then return here.

; return ( + : null} ); } diff --git a/apps/web/src/components/confirm-action.tsx b/apps/web/src/components/confirm-action.tsx new file mode 100644 index 0000000..2269381 --- /dev/null +++ b/apps/web/src/components/confirm-action.tsx @@ -0,0 +1,17 @@ +"use client"; + +import { useState } from "react"; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; + +export function useConfirmAction() { + const [action, setAction] = useState<{ description: string; run: () => void } | null>(null); + return { + ask: (description: string, run: () => void) => setAction({ description, run }), + dialog: { if (!open) setAction(null); }}> + Confirm change{action?.description} + + + , + }; +} diff --git a/apps/web/src/components/pending-invites.tsx b/apps/web/src/components/pending-invites.tsx index 07b9ac7..01c2196 100644 --- a/apps/web/src/components/pending-invites.tsx +++ b/apps/web/src/components/pending-invites.tsx @@ -3,21 +3,27 @@ import { api } from "@/trpc/react"; import { Button } from "@/components/ui/button"; import { toast } from "sonner"; +import { useConfirmAction } from "@/components/confirm-action"; import { Badge } from "@/components/ui/badge"; 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"; 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 revoke = api.group.revokeInvite.useMutation({ onSuccess: async () => { toast.success("Invite revoked"); await utils.group.pendingInvites.invalidate(); }, onError: error => toast.error(error.message), }); const invites = api.group.pendingInvites.useQuery({ groupId, eventId }, { refetchInterval: 30000 }); return
+ {confirmation.dialog}

Pending invites

{invites.data ? {invites.data.length} : null} @@ -36,7 +42,11 @@ 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 ? : null} + {canRevoke && invite.kind === "email" ? : null}
)}}
; diff --git a/apps/web/src/lib/upload.ts b/apps/web/src/lib/upload.ts index 1f13e1e..21ebf75 100644 --- a/apps/web/src/lib/upload.ts +++ b/apps/web/src/lib/upload.ts @@ -28,6 +28,7 @@ function putWithProgress( return new Promise((resolve, reject) => { const request = new XMLHttpRequest(); request.open("PUT", url); + request.timeout = 120000; request.setRequestHeader("Content-Type", contentType); request.upload.onprogress = (event) => { if (event.lengthComputable) { @@ -38,7 +39,9 @@ function putWithProgress( if (request.status >= 200 && request.status < 300) resolve(); else reject(new Error(`Upload failed (${request.status})`)); }; - request.onerror = () => reject(new Error("Upload failed")); + request.onerror = () => reject(new Error("Connection lost. Check your connection and retry this file.")); + request.ontimeout = () => reject(new Error("Upload timed out. Try a stronger connection, then retry this file.")); + request.onabort = () => reject(new Error("Upload interrupted. Retry this file.")); request.send(file); }); } diff --git a/apps/web/src/server/api/routers/banners.ts b/apps/web/src/server/api/routers/banners.ts index 387f9e9..26674e2 100644 --- a/apps/web/src/server/api/routers/banners.ts +++ b/apps/web/src/server/api/routers/banners.ts @@ -14,6 +14,15 @@ async function authorize(userId: string, eventId: string) { } export const bannersRouter = createTRPCRouter({ + retry: protectedProcedure.input(bannerInputSchema).mutation(async ({ ctx, input }) => { + await authorize(ctx.session.user.id, input.eventId); + const limit = await consumeRateLimit({ namespace: "banner-retry", identifier: `${ctx.session.user.id}:${input.bannerId}`, limit: 3, windowMs: 600000 }); + if (!limit.allowed) throw new TRPCError({ code: "TOO_MANY_REQUESTS", message: "Repeated processing failures. Try another image or contact support." }); + const rows = await getDb().update(eventBanners).set({ status: "pending", updatedAt: new Date() }) + .where(and(eq(eventBanners.id, input.bannerId), eq(eventBanners.eventId, input.eventId), eq(eventBanners.status, "failed"))).returning({ id: eventBanners.id }); + if (!rows.length) throw new TRPCError({ code: "BAD_REQUEST", message: "This banner is not failed. Check its status again." }); + return { ok: true }; + }), create: protectedProcedure.input(createBannerInputSchema).mutation(async ({ ctx, input }) => { await authorize(ctx.session.user.id, input.eventId); const limit = await consumeRateLimit({ namespace: "banner-upload", identifier: ctx.session.user.id, limit: 20, windowMs: 600_000 }); diff --git a/apps/web/src/server/api/routers/group.ts b/apps/web/src/server/api/routers/group.ts index 2e70d56..b9ea3b0 100644 --- a/apps/web/src/server/api/routers/group.ts +++ b/apps/web/src/server/api/routers/group.ts @@ -34,6 +34,8 @@ import { sendStaffInviteEmail } from "@album/email"; import { publicAppOrigin } from "@/server/public-app-url"; import { GROUP_COOKIE, serializeCookie } from "@/server/cookies"; import { invites } from "@album/database"; +import { assertInviteRedeemable } from "@/server/invites"; +import { consumeRateLimit } from "@/server/rate-limit"; async function validateInviteGrants(userId: string, input: { groupId?: string; eventId?: string; eventRole?: string; @@ -55,6 +57,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 }) => { + 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 }); + if (!limit.allowed) throw new TRPCError({ code: "TOO_MANY_REQUESTS", message: "Wait one minute before resending this invite." }); + await getDb().transaction(async tx => { + const [invite] = await tx.select().from(invites).where(and(eq(invites.id, input.inviteId), eq(invites.groupId, input.groupId))).limit(1).for("update"); + 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))); + 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 }); + }); + return { ok: true }; + }), rename: protectedProcedure .input(z.object({ groupId: z.string().uuid(), name: z.string().trim().min(1).max(100) })) .mutation(async ({ ctx, input }) => { diff --git a/apps/web/src/server/api/routers/photos.ts b/apps/web/src/server/api/routers/photos.ts index dd3902a..acd89ba 100644 --- a/apps/web/src/server/api/routers/photos.ts +++ b/apps/web/src/server/api/routers/photos.ts @@ -1,4 +1,5 @@ import { TRPCError } from "@trpc/server"; +import { z } from "zod"; import { and, eq } from "drizzle-orm"; import { events, getDb, photoJobs, photos, submissions } from "@album/database"; import { @@ -18,6 +19,21 @@ import { guests } from "@album/database"; import { effectiveEvent } from "@/lib/event-lifecycle"; export const photosRouter = createTRPCRouter({ + retryUpload: publicProcedure.input(z.object({ eventId: z.string().uuid(), photoId: z.string().uuid() })).mutation(async ({ ctx, input }) => { + const [photo] = await getDb().select().from(photos).where(and(eq(photos.id, input.photoId), eq(photos.eventId, input.eventId))).limit(1); + const token = ctx.guestTokenForEvent(input.eventId); + const [owner] = photo && token ? await getDb().select({ id: guests.id }).from(guests) + .innerJoin(submissions, and(eq(submissions.guestId, guests.id), eq(submissions.eventId, input.eventId))) + .where(and(eq(submissions.id, photo.submissionId), eq(guests.eventId, input.eventId), eq(guests.tokenHash, hashToken(token)))).limit(1) : []; + if (!photo || !owner) throw new TRPCError({ code: "FORBIDDEN", message: "Guest session does not match this upload." }); + if (photo.processingStatus !== "uploading") return { uploadUrl: null }; + const [stored] = await getDb().select().from(events).where(eq(events.id, input.eventId)).limit(1); + const event = stored ? effectiveEvent(stored) : null; + if (!event || event.status !== "published" || !event.uploadEnabled) throw new TRPCError({ code: "FORBIDDEN", message: "Uploads are closed for this event." }); + const limit = await consumeRateLimit({ namespace: `upload-retry:${input.eventId}`, identifier: ctx.clientIdentifier, limit: 40, windowMs: 600000 }); + if (!limit.allowed) throw new TRPCError({ code: "TOO_MANY_REQUESTS", message: "Too many retries. Try again shortly." }); + return { uploadUrl: await createPresignedPutUrl({ key: photo.originalKey, contentType: photo.contentType }) }; + }), create: publicProcedure .input(createPhotoInputSchema) .mutation(async ({ ctx, input }) => { @@ -105,7 +121,7 @@ export const photosRouter = createTRPCRouter({ key, contentType: input.contentType, }); - return { photoId: photo.id, uploadUrl }; + return { photoId: photo.id, eventId: event.id, uploadUrl }; }), complete: publicProcedure diff --git a/apps/web/src/server/invite-journey.integration.test.ts b/apps/web/src/server/invite-journey.integration.test.ts new file mode 100644 index 0000000..aea8662 --- /dev/null +++ b/apps/web/src/server/invite-journey.integration.test.ts @@ -0,0 +1,53 @@ +import { test, expect } from "bun:test"; +import { eq } from "drizzle-orm"; +import { getDb, user, groups, events, invites, auditEvents } from "@album/database"; +import { hashToken } from "./tokens"; +import { redeemInviteForUser } from "./invites"; + +test.skipIf(process.env.INVITE_JOURNEY_INTEGRATION !== "1")("fresh account verifies via Mailpit and accepts the correct event invitation", async () => { + if (process.env.EMAIL_PROVIDER !== "mailpit" || !["localhost", "127.0.0.1"].includes(new URL(process.env.DATABASE_URL!).hostname)) throw new Error("Local database and Mailpit required"); + const db = getDb(); + const id = crypto.randomUUID(); + const email = `journey-${id}@manyangles.test`; + const password = `Test-${crypto.randomUUID()}!`; + const token = crypto.randomUUID(); + const origin = "http://localhost:3000"; + let userId: string | undefined; + let groupId: string | undefined; + let messageId: string | undefined; + try { + const signup = await fetch(`${origin}/api/auth/sign-up/email`, { method: "POST", headers: { "Content-Type": "application/json", Origin: origin }, body: JSON.stringify({ name: "Invite journey test", email, password, callbackURL: `/invitations/${token}` }) }); + expect(signup.ok).toBe(true); + const data = await signup.json() as { user: { id: string } }; + userId = data.user.id; + const [group] = await db.insert(groups).values({ name: "Journey test", slug: id, createdByUserId: userId }).returning(); + groupId = group!.id; + const [event] = await db.insert(events).values({ groupId, title: "Journey test event", slug: id }).returning(); + await db.insert(invites).values({ groupId, eventId: event!.id, kind: "email", email, tokenHash: hashToken(token), eventRole: "manager" }); + const landing = await (await fetch(`${origin}/invitations/${token}`)).text(); + expect(landing).toContain("Help out with Journey test event"); + expect(landing).toContain("Create account to accept"); + await expect(redeemInviteForUser({ token, userId })).rejects.toThrow("Verify your email"); + const search = await (await fetch(`http://localhost:8027/api/v1/search?query=${encodeURIComponent(`to:${email}`)}`)).json() as { messages: { ID: string }[] }; + messageId = search.messages[0]?.ID; + expect(messageId).toBeDefined(); + const message = await (await fetch(`http://localhost:8027/api/v1/message/${messageId}`)).json() as { Text: string }; + const verification = message.Text.match(/https?:\/\/[^\s]+\/api\/auth\/verify-email[^\s]*/)?.[0]; + expect(verification).toBeDefined(); + expect(new URL(verification!).origin).toBe(origin); + const verified = await fetch(verification!, { redirect: "manual" }); + expect(verified.status).toBeLessThan(400); + expect(verified.headers.get("location")).toContain(`/invitations/${token}`); + const [account] = await db.select().from(user).where(eq(user.id, userId)); + expect(account!.emailVerified).toBe(true); + const login = await fetch(`${origin}/api/auth/sign-in/email`, { method: "POST", headers: { "Content-Type": "application/json", Origin: origin }, body: JSON.stringify({ email, password }) }); + expect(login.ok).toBe(true); + const result = await redeemInviteForUser({ token, userId }); + expect(result.eventId).toBe(event!.id); + await expect(redeemInviteForUser({ token, userId })).rejects.toThrow(); + } finally { + if (groupId) { await db.delete(auditEvents).where(eq(auditEvents.groupId, groupId)); await db.delete(groups).where(eq(groups.id, groupId)); } + if (userId) await db.delete(user).where(eq(user.id, userId)); + if (messageId) await fetch(`http://localhost:8027/api/v1/messages`, { method: "DELETE", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ IDs: [messageId] }) }); + } +}, 30000); diff --git a/apps/web/src/server/invites.ts b/apps/web/src/server/invites.ts index 76c13d4..5833099 100644 --- a/apps/web/src/server/invites.ts +++ b/apps/web/src/server/invites.ts @@ -99,6 +99,7 @@ async function redeemLockedInvite(input: { token: string; userId: string }, db: if (!actor) { throw new TRPCError({ code: "UNAUTHORIZED" }); } + if (!actor.emailVerified) throw new TRPCError({ code: "FORBIDDEN", message: "Verify your email address before accepting this invitation." }); if (invite.kind === "email" && (!invite.email || invite.email.trim().toLowerCase() !== actor.email.trim().toLowerCase())) { throw new TRPCError({ code: "FORBIDDEN", message: "Sign in with the email address this invitation was sent to." }); } diff --git a/apps/web/src/server/polish.integration.test.ts b/apps/web/src/server/polish.integration.test.ts index 0594534..f3fc30f 100644 --- a/apps/web/src/server/polish.integration.test.ts +++ b/apps/web/src/server/polish.integration.test.ts @@ -1,7 +1,8 @@ import { expect, test } from "bun:test"; import sharp from "sharp"; import { and, eq, inArray } from "drizzle-orm"; -import { getDb, user, groups, events, eventMemberships, groupMemberships, invites, eventSigns, auditEvents } from "@album/database"; +import { getDb, user, groups, events, eventMemberships, groupMemberships, invites, eventSigns, auditEvents, eventBanners } from "@album/database"; +import { bannersRouter } from "./api/routers/banners"; import { deletePrefix } from "@album/storage"; import { hashToken } from "./tokens"; import { redeemInviteForUser } from "./invites"; @@ -12,6 +13,7 @@ import type { TrpcContext } from "./api/trpc"; test.skipIf(process.env.POLISH_INTEGRATION !== "1")("invitation isolation, atomic redemption, and saved sign roundtrip/conflicts", async () => { for (const key of ["DATABASE_URL", "S3_ENDPOINT"]) if (!["localhost", "127.0.0.1"].includes(new URL(process.env[key]!).hostname)) throw new Error("Local services required"); + if (process.env.EMAIL_PROVIDER !== "mailpit") throw new Error("Mailpit required"); const db = getDb(); const id = crypto.randomUUID(); const people = await db.insert(user).values([0, 1].map(n => ({ id: `${id}-${n}`, name: "Polish test", email: `${id}-${n}@manyangles.test`, emailVerified: true }))).returning(); @@ -21,6 +23,10 @@ test.skipIf(process.env.POLISH_INTEGRATION !== "1")("invitation isolation, atomi try { await db.insert(groupMemberships).values({ groupId: group!.id, userId: people[0]!.id, role: "owner" }); await db.insert(eventMemberships).values({ eventId: event!.id, userId: people[0]!.id, role: "owner" }); + const [failedBanner] = await db.insert(eventBanners).values({ eventId: event!.id, originalKey: `events/${event!.id}/banners/test-missing`, contentType: "image/jpeg", byteSize: 10, status: "failed" }).returning(); + await expect(bannersRouter.createCaller(ctx(1)).retry({ eventId: event!.id, bannerId: failedBanner!.id })).rejects.toThrow(); + expect(await bannersRouter.createCaller(ctx(0)).retry({ eventId: event!.id, bannerId: failedBanner!.id })).toEqual({ ok: true }); + await db.delete(eventBanners).where(and(eq(eventBanners.eventId, event!.id), eq(eventBanners.id, failedBanner!.id))); const token = crypto.randomUUID(); await db.insert(invites).values({ kind: "email", email: people[0]!.email.toUpperCase(), tokenHash: hashToken(token), groupId: group!.id, maxUses: 1 }); await expect(redeemInviteForUser({ token, userId: people[1]!.id })).rejects.toThrow("email address"); @@ -57,6 +63,12 @@ test.skipIf(process.env.POLISH_INTEGRATION !== "1")("invitation isolation, atomi expect(await workspace.pendingInvites({ groupId: group!.id })).toHaveLength(0); 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(); + 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); + await expect(workspace.resendInvite({ groupId: group!.id, inviteId: resendTarget!.id })).rejects.toThrow("Wait one minute"); 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/apps/web/src/server/publishing.integration.test.ts b/apps/web/src/server/publishing.integration.test.ts index af02006..70db8d4 100644 --- a/apps/web/src/server/publishing.integration.test.ts +++ b/apps/web/src/server/publishing.integration.test.ts @@ -62,12 +62,16 @@ test.skipIf(process.env.PUBLISHING_INTEGRATION !== "1")("standalone notes, appro original = await getObjectBuffer(source!.key); } const created = await photoCaller.create({ eventSlug: event.slug, submissionId: submission.submissionId, contentType: "image/jpeg", fileName: "test.jpg", byteSize: original?.length ?? 100 }); + expect((await photoCaller.retryUpload({ eventId: event.id, photoId: created.photoId })).uploadUrl).toBeTruthy(); + await expect(photosRouter.createCaller({ ...ctx, guestTokenForEvent: () => null }).retryUpload({ eventId: event.id, photoId: created.photoId })).rejects.toThrow("Guest session"); + await expect(photoCaller.retryUpload({ eventId: crypto.randomUUID(), photoId: created.photoId })).rejects.toThrow(); await expect(photosRouter.createCaller({ ...ctx, guestTokenForEvent: () => null }).complete({ photoId: created.photoId })).rejects.toThrow("Guest session"); expect(await publicEvent.gallery(event.slug)).toEqual([]); if (original) { uploadedPhotoId = created.photoId; expect((await fetch(created.uploadUrl, { method: "PUT", headers: { "Content-Type": "image/jpeg" }, body: new Uint8Array(original) })).ok).toBe(true); await Promise.all([photoCaller.complete({ photoId: created.photoId }), photoCaller.complete({ photoId: created.photoId })]); + expect((await photoCaller.retryUpload({ eventId: event.id, photoId: created.photoId })).uploadUrl).toBeNull(); expect(await db.select({ id: photoJobs.id }).from(photoJobs).where(eq(photoJobs.photoId, created.photoId))).toHaveLength(1); let ready = false; for (let attempt = 0; attempt < 45; attempt++) {