From f2f40893c15d768438212f75219e245f1bc2e0d3 Mon Sep 17 00:00:00 2001 From: Paul Nothaft Date: Wed, 2 Sep 2026 09:42:40 +0200 Subject: [PATCH] fix(guests): drop a stored identity when a spent invite names someone else A guest coming back through their own already-redeemed link is the ordinary #1265 case, and the identity the device holds is theirs. The same link opened on a shared device that holds another guest's identity is not: the redemption 409s, ensureIdentity() falls through to the stored identity, and the visitor's likes are filed under the previous person. The two cases were indistinguishable client-side, so the 409/410 body now carries the invite's guest_id. On a mismatch the stored identity is cleared and the visitor is asked who they are. A response without guest_id keeps the previous behaviour. --- backend/src/routes/galleryGuests.js | 12 +++++++--- .../src/contexts/GuestIdentityContext.tsx | 22 ++++++++++++++++++- 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/backend/src/routes/galleryGuests.js b/backend/src/routes/galleryGuests.js index 567d281c..49feff21 100644 --- a/backend/src/routes/galleryGuests.js +++ b/backend/src/routes/galleryGuests.js @@ -350,8 +350,12 @@ router.post('/:slug/guest/redeem', verifyGalleryAccess, async (req, res) => { .where({ token: inviteToken, event_id: event.id }) .first(); if (!invite) return { error: 'not_found' }; - if (invite.revoked_at) return { error: 'revoked' }; - if (invite.redeemed_at) return { error: 'already_redeemed' }; + // Spent and revoked invites name their guest, so the client can tell + // "this guest came back through their own link" (keep the identity the + // device holds) from "someone else's link on a device that holds + // another guest's identity" (drop it) — see GuestIdentityContext. + if (invite.revoked_at) return { error: 'revoked', guestId: invite.guest_id }; + if (invite.redeemed_at) return { error: 'already_redeemed', guestId: invite.guest_id }; const guest = await trx('gallery_guests') .where({ id: invite.guest_id, is_deleted: false }) @@ -380,7 +384,9 @@ router.post('/:slug/guest/redeem', verifyGalleryAccess, async (req, res) => { already_redeemed: 409, guest_missing: 404, }; - return res.status(statusMap[result.error] || 400).json({ error: result.error }); + const body = { error: result.error }; + if (result.guestId != null) body.guest_id = Number(result.guestId); + return res.status(statusMap[result.error] || 400).json(body); } const token = signGuestToken({ diff --git a/frontend/src/contexts/GuestIdentityContext.tsx b/frontend/src/contexts/GuestIdentityContext.tsx index 2f841d71..20760f60 100644 --- a/frontend/src/contexts/GuestIdentityContext.tsx +++ b/frontend/src/contexts/GuestIdentityContext.tsx @@ -187,7 +187,27 @@ export const GuestIdentityProvider: React.FC = ({ const newUrl = window.location.pathname + (newSearch ? `?${newSearch}` : '') + window.location.hash; window.history.replaceState({}, '', newUrl); } catch (error) { - // Silently fail invalid invites; user will fall back to normal prompt. + // A spent (409) or revoked (410) invite is the normal way a guest comes + // back through their own emailed link, and the identity this device + // holds is then theirs — keep it. But the same link opened on a shared + // device that holds SOMEONE ELSE's identity must not quietly act as + // that someone: the server names the invite's guest on those two + // responses precisely so the two cases can be told apart. On a + // mismatch the stored identity is dropped, so the visitor is asked + // who they are instead of having their likes filed under the previous + // person. A response without guest_id (older backend) keeps today's + // behaviour. + const status = (error as { response?: { status?: number; data?: { guest_id?: unknown } } }) + .response; + const invitedGuestId = status?.data?.guest_id; + if ((status?.status === 409 || status?.status === 410) && typeof invitedGuestId === 'number') { + const stored = getGuestIdentity(slug); + if (stored && stored.id !== invitedGuestId) { + clearGuestIdentity(slug); + setIdentity(null); + } + } + // Otherwise fail silently; the visitor falls back to the normal prompt. // eslint-disable-next-line no-console console.warn('Failed to redeem invite token', error); } finally {