From f680d0b21f1561429111872e57fe7ca2e7340d09 Mon Sep 17 00:00:00 2001 From: mcull Date: Mon, 29 Sep 2025 00:39:50 -0700 Subject: [PATCH] fix(invite-flow): allow guest preview and consume invite after auth; remove unused client cookie helper --- src/app/api/invite/consume/route.ts | 76 +++++++++++++++++++++++++++++ src/app/auth/callback/page.tsx | 33 +++++++++---- src/app/collection/[id]/page.tsx | 10 ++-- 3 files changed, 104 insertions(+), 15 deletions(-) create mode 100644 src/app/api/invite/consume/route.ts diff --git a/src/app/api/invite/consume/route.ts b/src/app/api/invite/consume/route.ts new file mode 100644 index 0000000..dc5c0e4 --- /dev/null +++ b/src/app/api/invite/consume/route.ts @@ -0,0 +1,76 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; + +import { authOptions } from '@/lib/auth'; +import { db } from '@/lib/db'; + +export async function POST(request: NextRequest) { + try { + const session = await getServerSession(authOptions); + if (!session?.user) { + return NextResponse.json({ redirect: null }, { status: 200 }); + } + + const inviteToken = request.cookies.get('invite_token')?.value; + const inviteLibrary = request.cookies.get('invite_library')?.value; + if (!inviteToken || !inviteLibrary) { + return NextResponse.json({ redirect: null }, { status: 200 }); + } + + const userId = (session.user as { id?: string } | undefined)?.id; + if (!userId) { + return NextResponse.json({ redirect: null }, { status: 200 }); + } + + // Validate invite + const invitation = await db.invitation.findFirst({ + where: { + token: inviteToken, + libraryId: inviteLibrary, + type: 'library', + status: { in: ['PENDING', 'SENT'] }, + }, + select: { id: true, expiresAt: true }, + }); + + // Build base response now so we can always clear cookies + const res = NextResponse.json({ redirect: `/collection/${inviteLibrary}` }); + res.cookies.set('invite_token', '', { path: '/', maxAge: 0 }); + res.cookies.set('invite_library', '', { path: '/', maxAge: 0 }); + + if (!invitation || new Date() > invitation.expiresAt) { + return res; + } + + // Ensure membership + const existing = await db.collectionMember.findUnique({ + where: { userId_collectionId: { userId, collectionId: inviteLibrary } }, + select: { id: true, isActive: true }, + }); + if (!existing) { + await db.collectionMember.create({ + data: { + userId, + collectionId: inviteLibrary, + role: 'member', + isActive: true, + }, + }); + } else if (!existing.isActive) { + await db.collectionMember.update({ + where: { id: existing.id }, + data: { isActive: true }, + }); + } + + // Mark invite accepted + await db.invitation.updateMany({ + where: { token: inviteToken, libraryId: inviteLibrary }, + data: { status: 'ACCEPTED', acceptedAt: new Date(), receiverId: userId }, + }); + + return res; + } catch { + return NextResponse.json({ redirect: null }, { status: 200 }); + } +} diff --git a/src/app/auth/callback/page.tsx b/src/app/auth/callback/page.tsx index 7f789af..89d04c2 100644 --- a/src/app/auth/callback/page.tsx +++ b/src/app/auth/callback/page.tsx @@ -15,9 +15,7 @@ function AuthCallbackContent() { const invitationToken = searchParams.get('invitation'); const libraryId = searchParams.get('library'); - // Helper to read invite cookies (set by /invite/[token]) - const getCookie = (name: string) => - `; ${document.cookie}`.split(`; ${name}=`).pop()?.split(';')?.[0]; + // Client cookie reader no longer used; invite handling is server-side useEffect(() => { if (status === 'loading' || isRedirecting) return; @@ -62,13 +60,28 @@ function AuthCallbackContent() { } } - // Handle invite cookie flow (guest pass): redirect to collection - const inviteLibrary = getCookie('invite_library'); - const inviteToken = getCookie('invite_token'); - if (inviteLibrary && inviteToken) { - // Always prefer dropping into the invited collection over profile flows - router.replace(`/collection/${inviteLibrary}`); - return; + // Handle invite cookie flow server-side: create membership and decide destination + try { + const consumeRes = await fetch('/api/invite/consume', { + method: 'POST', + }); + if (consumeRes.ok) { + const body = await consumeRes + .json() + .catch(() => ({}) as { redirect?: string }); + if (body?.redirect) { + const dest = body.redirect as string; + if (user?.profileCompleted) { + router.replace(dest); + } else { + const returnTo = encodeURIComponent(dest); + router.replace(`/profile/create?returnTo=${returnTo}`); + } + return; + } + } + } catch { + // ignore and continue } // Normal flow based on profile completion diff --git a/src/app/collection/[id]/page.tsx b/src/app/collection/[id]/page.tsx index 6e1fa0a..b25a0f7 100644 --- a/src/app/collection/[id]/page.tsx +++ b/src/app/collection/[id]/page.tsx @@ -21,11 +21,11 @@ export default async function CollectionPage({ const cookieStore = await cookies(); const inviteToken = cookieStore.get('invite_token')?.value; const inviteLibrary = cookieStore.get('invite_library')?.value; - console.log('[collection page] redirecting to signin (no session)', { - hasInviteToken: !!inviteToken, - inviteLibrary, - }); - redirect('/auth/signin'); + const { id: collectionIdForCheck } = await params; + const allowGuest = inviteToken && inviteLibrary === collectionIdForCheck; + if (!allowGuest) { + redirect('/auth/signin'); + } } const { id: collectionId } = await params;