From f680d0b21f1561429111872e57fe7ca2e7340d09 Mon Sep 17 00:00:00 2001 From: mcull Date: Mon, 29 Sep 2025 00:39:50 -0700 Subject: [PATCH 1/2] 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; From 606b7bab00e52eecc0ac62021d59e255939ab0c3 Mon Sep 17 00:00:00 2001 From: mcull Date: Mon, 29 Sep 2025 01:06:22 -0700 Subject: [PATCH 2/2] chore(debug): expand logging across invite consume, collection SSR gate, and join route for post-auth guest issue --- src/app/api/collections/[id]/join/route.ts | 5 +++- src/app/api/invite/consume/route.ts | 30 +++++++++++++++++++++- src/app/collection/[id]/page.tsx | 8 ++++++ 3 files changed, 41 insertions(+), 2 deletions(-) diff --git a/src/app/api/collections/[id]/join/route.ts b/src/app/api/collections/[id]/join/route.ts index de00c3b..1645dbd 100644 --- a/src/app/api/collections/[id]/join/route.ts +++ b/src/app/api/collections/[id]/join/route.ts @@ -12,6 +12,7 @@ export async function POST( const session = await getServerSession(authOptions); if (!session?.user) { + console.log('[collections/:id/join] unauthorized: no session'); return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); } @@ -109,6 +110,7 @@ export async function POST( } } if (!collection.isPublic && !inviteOk) { + console.log('[collections/:id/join] private and no valid invite'); return NextResponse.json( { error: 'This collection is private and requires an invitation' }, { status: 403 } @@ -143,6 +145,7 @@ export async function POST( } } else { // Create new membership + console.log('[collections/:id/join] creating membership'); await db.collectionMember.create({ data: { userId, @@ -214,7 +217,7 @@ export async function POST( res.cookies.set('invite_library', '', { path: '/', maxAge: 0 }); return res; } catch (error) { - console.error('Error joining collection:', error); + console.error('[collections/:id/join] error', error); return NextResponse.json( { error: 'Failed to join collection' }, { status: 500 } diff --git a/src/app/api/invite/consume/route.ts b/src/app/api/invite/consume/route.ts index dc5c0e4..1b2daf0 100644 --- a/src/app/api/invite/consume/route.ts +++ b/src/app/api/invite/consume/route.ts @@ -7,18 +7,27 @@ import { db } from '@/lib/db'; export async function POST(request: NextRequest) { try { const session = await getServerSession(authOptions); + console.log('[invite/consume] start', { + url: request.url, + hasSessionUser: !!session?.user, + hasCookieInviteToken: !!request.cookies.get('invite_token')?.value, + inviteLibrary: request.cookies.get('invite_library')?.value, + }); if (!session?.user) { + console.log('[invite/consume] no 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) { + console.log('[invite/consume] missing cookies'); return NextResponse.json({ redirect: null }, { status: 200 }); } const userId = (session.user as { id?: string } | undefined)?.id; if (!userId) { + console.log('[invite/consume] missing userId from session'); return NextResponse.json({ redirect: null }, { status: 200 }); } @@ -32,6 +41,10 @@ export async function POST(request: NextRequest) { }, select: { id: true, expiresAt: true }, }); + console.log('[invite/consume] invitation lookup', { + found: !!invitation, + expired: invitation ? new Date() > invitation.expiresAt : undefined, + }); // Build base response now so we can always clear cookies const res = NextResponse.json({ redirect: `/collection/${inviteLibrary}` }); @@ -39,6 +52,7 @@ export async function POST(request: NextRequest) { res.cookies.set('invite_library', '', { path: '/', maxAge: 0 }); if (!invitation || new Date() > invitation.expiresAt) { + console.log('[invite/consume] invalid or expired invite; returning'); return res; } @@ -48,6 +62,10 @@ export async function POST(request: NextRequest) { select: { id: true, isActive: true }, }); if (!existing) { + console.log('[invite/consume] creating membership', { + userId, + inviteLibrary, + }); await db.collectionMember.create({ data: { userId, @@ -57,20 +75,30 @@ export async function POST(request: NextRequest) { }, }); } else if (!existing.isActive) { + console.log('[invite/consume] reactivating membership', { + id: existing.id, + }); await db.collectionMember.update({ where: { id: existing.id }, data: { isActive: true }, }); + } else { + console.log('[invite/consume] membership already active'); } // Mark invite accepted + console.log('[invite/consume] marking invite accepted'); await db.invitation.updateMany({ where: { token: inviteToken, libraryId: inviteLibrary }, data: { status: 'ACCEPTED', acceptedAt: new Date(), receiverId: userId }, }); + console.log('[invite/consume] returning redirect', { + to: `/collection/${inviteLibrary}`, + }); return res; - } catch { + } catch (e) { + console.error('[invite/consume] error', e); return NextResponse.json({ redirect: null }, { status: 200 }); } } diff --git a/src/app/collection/[id]/page.tsx b/src/app/collection/[id]/page.tsx index b25a0f7..9499a1f 100644 --- a/src/app/collection/[id]/page.tsx +++ b/src/app/collection/[id]/page.tsx @@ -23,6 +23,14 @@ export default async function CollectionPage({ const inviteLibrary = cookieStore.get('invite_library')?.value; const { id: collectionIdForCheck } = await params; const allowGuest = inviteToken && inviteLibrary === collectionIdForCheck; + console.log('[collection page] auth gate', { + hasSessionUser: !!session?.user, + guestParam: guest, + hasInviteToken: !!inviteToken, + inviteLibrary, + collectionIdForCheck, + allowGuest, + }); if (!allowGuest) { redirect('/auth/signin'); }