fix(guests): invite wins over stored identity; clear server-rejected ones
Codex review round 2 on #1268. Four findings, all reachable only because the identity now persists. An explicit ?invite= now takes precedence. The redeem effect skipped when an identity already existed, which was harmless while identity died with the tab. Persisted, it means opening guest B's invite on a browser where guest A once visited restores A, never redeems B's invite, and files B's likes under A. A ref keeps it to one redemption per token. Guest-scoped caches are invalidated when the identity changes. my-feedback, gallery-photos and photo-feedback are keyed by slug and photo id, never by guest, so they outlived an identity change and showed the previous guest's likes while requests already carried the new token. Now reachable three ways: another tab, 'Not you?', and an invite redeemed over an existing identity. An identity the server has rejected is dropped. resolveGuest nulls req.guest for a soft-deleted or merged-away row even when the JWT is validly signed and unexpired, and the route answers GUEST_IDENTITY_REQUIRED — no client-side expiry check can catch that. Self-limiting when identity died with the tab; persisted, it would fail every like for up to 30 days while the footer still showed the guest's name. The write fallback now covers the real write, not just the probe. A one-byte probe fits in a nearly-full store that still rejects a JWT plus profile, which left the context believing it was signed in with nothing persisted.
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { guestsService, GuestIdentity } from '../services/guests.service';
|
||||
import {
|
||||
clearGuestIdentity,
|
||||
@@ -54,6 +55,7 @@ export const GuestIdentityProvider: React.FC<GuestIdentityProviderProps> = ({
|
||||
identityMode,
|
||||
children,
|
||||
}) => {
|
||||
const queryClient = useQueryClient();
|
||||
const [identity, setIdentity] = useState<GuestIdentity | null>(() => getGuestIdentity(slug));
|
||||
const [promptOpen, setPromptOpen] = useState(false);
|
||||
const [recoveryOpen, setRecoveryOpen] = useState(false);
|
||||
@@ -88,13 +90,41 @@ export const GuestIdentityProvider: React.FC<GuestIdentityProviderProps> = ({
|
||||
return () => window.removeEventListener('storage', onStorage);
|
||||
}, [slug]);
|
||||
|
||||
// Guest-scoped caches are keyed by slug and photo id, never by guest id, so
|
||||
// they survive an identity change and would keep showing the previous
|
||||
// guest's likes, favourites and ratings while requests already carry the new
|
||||
// token. Reachable three ways now: another tab signing in or out, "Not
|
||||
// you?", and a ?invite= redemption over an existing identity.
|
||||
const previousIdentityId = useRef<number | null>(identity?.id ?? null);
|
||||
useEffect(() => {
|
||||
const currentId = identity?.id ?? null;
|
||||
if (previousIdentityId.current === currentId) return;
|
||||
previousIdentityId.current = currentId;
|
||||
queryClient.invalidateQueries({ queryKey: ['my-feedback', slug] });
|
||||
queryClient.invalidateQueries({ queryKey: ['gallery-photos', slug] });
|
||||
// Every mounted photo, regardless of id.
|
||||
queryClient.invalidateQueries({ queryKey: ['photo-feedback', slug] });
|
||||
}, [identity, slug, queryClient]);
|
||||
|
||||
// When an invite token is present on the URL (?invite=xxx), redeem it once
|
||||
// on mount. The server returns a guest token we can persist.
|
||||
//
|
||||
// Deliberately NOT skipped when an identity already exists. It used to be,
|
||||
// which was harmless while identity died with the tab — but now that it
|
||||
// persists, opening guest B's invite link on a browser where guest A once
|
||||
// visited would restore A, skip the redemption entirely, and file B's likes
|
||||
// under A. An explicit invite is the strongest statement of who the visitor
|
||||
// is, so it wins over whatever the browser happens to be holding.
|
||||
//
|
||||
// The ref keeps it to one redemption per token: `identity` is no longer in
|
||||
// the dependency list precisely because redeeming sets it.
|
||||
const redeemedInviteRef = useRef<string | null>(null);
|
||||
useEffect(() => {
|
||||
if (identityMode !== 'guest' || identity) return;
|
||||
if (identityMode !== 'guest') return;
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const inviteToken = params.get('invite');
|
||||
if (!inviteToken) return;
|
||||
if (!inviteToken || redeemedInviteRef.current === inviteToken) return;
|
||||
redeemedInviteRef.current = inviteToken;
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
@@ -112,7 +142,7 @@ export const GuestIdentityProvider: React.FC<GuestIdentityProviderProps> = ({
|
||||
console.warn('Failed to redeem invite token', error);
|
||||
}
|
||||
})();
|
||||
}, [slug, identityMode, identity]);
|
||||
}, [slug, identityMode]);
|
||||
|
||||
const openPrompt = useCallback(() => setPromptOpen(true), []);
|
||||
const closePrompt = useCallback(() => {
|
||||
|
||||
Reference in New Issue
Block a user