Merge pull request #1268 from PicPeak/fix/1265-guest-identity-persistence
fix(guests): keep guest identity across a tab close (#1265)
This commit is contained in:
@@ -829,6 +829,19 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
|
||||
>
|
||||
{t('gallery.footer.forgetMe', 'Forget me ({{name}})', { name: guestIdentity.identity.name })}
|
||||
</button>
|
||||
{/* Non-destructive counterpart to "Forget me". Identity now
|
||||
survives a tab close (#1265), so someone else on a shared
|
||||
device can be greeted by the previous visitor's name —
|
||||
and "Forget me" would delete that person's selections.
|
||||
This only clears the identity on this device. */}
|
||||
<span className="text-xs text-muted-theme">|</span>
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs text-muted-theme hover:text-theme transition-colors"
|
||||
onClick={() => guestIdentity.signOut()}
|
||||
>
|
||||
{t('gallery.footer.notYou', 'Not you?')}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
inferGallerySlugFromLocation,
|
||||
resolveSlugFromRequestUrl,
|
||||
} from '../utils/galleryAuthStorage';
|
||||
import { getGuestToken } from '../utils/guestIdentityStorage';
|
||||
import { clearGuestIdentity, getGuestToken } from '../utils/guestIdentityStorage';
|
||||
import { getApiBaseUrl } from '../utils/url';
|
||||
|
||||
// Maintenance mode callback
|
||||
@@ -181,6 +181,16 @@ api.interceptors.response.use(
|
||||
if (!isImageRequest && galleryMatch && galleryMatch[1]) {
|
||||
const gallerySlug = galleryMatch[1];
|
||||
sessionStorage.removeItem(`gallery_event_${gallerySlug}`);
|
||||
// A guest identity the server no longer accepts must not stay on
|
||||
// the device. The JWT can be perfectly valid and unexpired while
|
||||
// its row has been soft-deleted or merged away by an admin, so no
|
||||
// client-side expiry check catches it. This was self-limiting when
|
||||
// identity died with the tab; now it would persist for the full
|
||||
// 30-day TTL, silently failing every like while the footer still
|
||||
// shows the guest's name (#1265).
|
||||
if (error.response?.data?.code === 'GUEST_IDENTITY_REQUIRED') {
|
||||
clearGuestIdentity(gallerySlug);
|
||||
}
|
||||
}
|
||||
// Don't redirect - let the component handle the auth state
|
||||
} else if (galleryMatch) {
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
setActiveGallerySlug,
|
||||
storeGalleryToken,
|
||||
} from '../utils/galleryAuthStorage';
|
||||
import { clearGuestIdentity } from '../utils/guestIdentityStorage';
|
||||
import type { GalleryAccessLevel } from '../types';
|
||||
|
||||
interface GalleryEvent {
|
||||
@@ -350,6 +351,12 @@ export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ childr
|
||||
sessionStorage.removeItem(`gallery_event_${currentSlug}`);
|
||||
sessionStorage.removeItem(`gallery_access_level_${currentSlug}`);
|
||||
clearGalleryToken(currentSlug);
|
||||
// Logout is the "I am leaving this device" signal. Now that the guest
|
||||
// identity outlives the tab (#1265), leaving it behind would greet the
|
||||
// next person who enters the shared gallery password by this guest's
|
||||
// name — with "forget me", which erases THIS guest's selections
|
||||
// server-side, one click away. Local only; the server row is untouched.
|
||||
clearGuestIdentity(currentSlug);
|
||||
}
|
||||
authService.galleryLogout(currentSlug || undefined);
|
||||
setIsAuthenticated(false);
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
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 {
|
||||
GUEST_IDENTITY_CLEARED_EVENT,
|
||||
clearGuestIdentity,
|
||||
getGuestIdentity,
|
||||
storeGuestIdentity,
|
||||
@@ -23,6 +25,16 @@ interface GuestIdentityContextValue {
|
||||
recoverRequest: (email: string) => Promise<void>;
|
||||
recoverVerify: (email: string, code: string) => Promise<GuestIdentity>;
|
||||
forget: () => Promise<void>;
|
||||
/**
|
||||
* Drop the stored identity on THIS device without touching the server.
|
||||
*
|
||||
* Distinct from forget(), which soft-deletes the guest row and anonymizes
|
||||
* their feedback. Now that identity survives a tab close (#1265), a second
|
||||
* person on a shared computer can be greeted as whoever used it last — and
|
||||
* their only previous exit was forget(), which would erase that person's
|
||||
* name and selections. This is the non-destructive way out.
|
||||
*/
|
||||
signOut: () => void;
|
||||
/**
|
||||
* Used by feedback components. Returns the current identity, or opens the
|
||||
* prompt and waits until the user registers (or cancels, in which case it
|
||||
@@ -44,6 +56,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);
|
||||
@@ -57,15 +70,113 @@ export const GuestIdentityProvider: React.FC<GuestIdentityProviderProps> = ({
|
||||
setIdentity(getGuestIdentity(slug));
|
||||
}, [slug]);
|
||||
|
||||
// Keep tabs in step. The identity now lives in localStorage, which is shared
|
||||
// across tabs — where sessionStorage gave each tab its own copy. So "Not
|
||||
// you?" or a fresh registration in one tab silently changes the token the
|
||||
// axios interceptor sends from every other tab, while those tabs still show
|
||||
// the old name. Their likes would then be recorded against the new guest:
|
||||
// the same misattribution this change set out to stop.
|
||||
//
|
||||
// `storage` fires only in the OTHER tabs, which is exactly the audience that
|
||||
// needs to catch up. A null key means the whole store was cleared.
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
const adopt = () => {
|
||||
const next = getGuestIdentity(slug);
|
||||
setIdentity(next);
|
||||
if (next) {
|
||||
// A caller may be parked on the prompt waiting for ensureIdentity().
|
||||
// Another tab just answered the question, so complete them exactly as
|
||||
// register() does — otherwise the action hangs forever and submitting
|
||||
// the still-open prompt registers a second guest.
|
||||
setPromptOpen(false);
|
||||
setRecoveryOpen(false);
|
||||
pendingResolvers.current.forEach((r) => r(next));
|
||||
pendingResolvers.current = [];
|
||||
pendingRejecters.current = [];
|
||||
}
|
||||
};
|
||||
|
||||
const onStorage = (event: StorageEvent) => {
|
||||
if (event.key && event.key !== `guest_token_${slug}` && event.key !== `guest_identity_${slug}`) {
|
||||
return;
|
||||
}
|
||||
adopt();
|
||||
};
|
||||
// Fires in THIS tab, where `storage` does not — e.g. the axios interceptor
|
||||
// dropping an identity the server has rejected.
|
||||
const onLocalClear = (event: Event) => {
|
||||
const detail = (event as CustomEvent<{ slug?: string }>).detail;
|
||||
if (detail?.slug && detail.slug !== slug) return;
|
||||
adopt();
|
||||
};
|
||||
|
||||
window.addEventListener('storage', onStorage);
|
||||
window.addEventListener(GUEST_IDENTITY_CLEARED_EVENT, onLocalClear);
|
||||
return () => {
|
||||
window.removeEventListener('storage', onStorage);
|
||||
window.removeEventListener(GUEST_IDENTITY_CLEARED_EVENT, onLocalClear);
|
||||
};
|
||||
}, [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);
|
||||
// Invalidating queries is not enough on its own: six gallery layouts seed
|
||||
// their liked-photo set behind a `likedSeededRef` that is deliberately
|
||||
// mount-only ("so refetches don't clobber in-session optimistic toggles"),
|
||||
// and PhotoLightbox holds its own copy. A refetch therefore leaves the
|
||||
// previous guest's hearts on screen. Bumping this generation re-keys the
|
||||
// subtree so every such consumer is rebuilt.
|
||||
const [identityGeneration, setIdentityGeneration] = useState(0);
|
||||
useEffect(() => {
|
||||
const currentId = identity?.id ?? null;
|
||||
const previousId = previousIdentityId.current;
|
||||
if (previousId === 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] });
|
||||
|
||||
// Only on a SWITCH away from an established identity — signing in for the
|
||||
// first time (null -> A) must not remount, or registering from the prompt
|
||||
// would tear down the gallery under the very click that triggered it and
|
||||
// drop the pending action.
|
||||
if (previousId !== null) setIdentityGeneration((g) => g + 1);
|
||||
}, [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);
|
||||
// Redemption is async, and the gallery stays interactive while it runs. A
|
||||
// like clicked in that window would otherwise resolve against the persisted
|
||||
// identity and be filed under the wrong guest, permanently. ensureIdentity()
|
||||
// waits on this instead.
|
||||
const invitePromiseRef = useRef<Promise<void> | 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 () => {
|
||||
invitePromiseRef.current = (async () => {
|
||||
try {
|
||||
const response = await guestsService.redeemInvite(slug, inviteToken);
|
||||
storeGuestIdentity(slug, response.guest, response.token);
|
||||
@@ -76,12 +187,34 @@ export const GuestIdentityProvider: React.FC<GuestIdentityProviderProps> = ({
|
||||
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 {
|
||||
invitePromiseRef.current = null;
|
||||
}
|
||||
})();
|
||||
}, [slug, identityMode, identity]);
|
||||
}, [slug, identityMode]);
|
||||
|
||||
const openPrompt = useCallback(() => setPromptOpen(true), []);
|
||||
const closePrompt = useCallback(() => {
|
||||
@@ -144,7 +277,12 @@ export const GuestIdentityProvider: React.FC<GuestIdentityProviderProps> = ({
|
||||
setIdentity(null);
|
||||
}, [slug, identity]);
|
||||
|
||||
const ensureIdentity = useCallback((): Promise<GuestIdentity> => {
|
||||
const signOut = useCallback((): void => {
|
||||
clearGuestIdentity(slug);
|
||||
setIdentity(null);
|
||||
}, [slug]);
|
||||
|
||||
const ensureIdentity = useCallback(async (): Promise<GuestIdentity> => {
|
||||
if (identityMode !== 'guest') {
|
||||
// In simple mode, there is no per-person identity. Return a synthetic
|
||||
// "null" identity that callers will ignore.
|
||||
@@ -155,14 +293,26 @@ export const GuestIdentityProvider: React.FC<GuestIdentityProviderProps> = ({
|
||||
identifier: '',
|
||||
} as GuestIdentity);
|
||||
}
|
||||
if (identity) return Promise.resolve(identity);
|
||||
// An invite naming this visitor is mid-flight: wait for it rather than
|
||||
// answering with the identity the browser happened to be holding.
|
||||
if (invitePromiseRef.current) {
|
||||
try {
|
||||
await invitePromiseRef.current;
|
||||
} catch {
|
||||
// Redemption failed — fall through to the stored identity / prompt.
|
||||
}
|
||||
const redeemed = getGuestIdentity(slug);
|
||||
if (redeemed) return redeemed;
|
||||
}
|
||||
|
||||
if (identity) return identity;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
pendingResolvers.current.push(resolve);
|
||||
pendingRejecters.current.push(reject);
|
||||
setPromptOpen(true);
|
||||
});
|
||||
}, [identityMode, identity]);
|
||||
}, [identityMode, identity, slug]);
|
||||
|
||||
const isRequired = identityMode === 'guest' && !identity;
|
||||
|
||||
@@ -182,6 +332,7 @@ export const GuestIdentityProvider: React.FC<GuestIdentityProviderProps> = ({
|
||||
recoverRequest,
|
||||
recoverVerify,
|
||||
forget,
|
||||
signOut,
|
||||
ensureIdentity,
|
||||
}),
|
||||
[
|
||||
@@ -199,11 +350,18 @@ export const GuestIdentityProvider: React.FC<GuestIdentityProviderProps> = ({
|
||||
recoverRequest,
|
||||
recoverVerify,
|
||||
forget,
|
||||
signOut,
|
||||
ensureIdentity,
|
||||
]
|
||||
);
|
||||
|
||||
return <GuestIdentityContext.Provider value={value}>{children}</GuestIdentityContext.Provider>;
|
||||
return (
|
||||
<GuestIdentityContext.Provider value={value}>
|
||||
{/* Re-keyed on an identity switch so consumers holding local feedback
|
||||
state are rebuilt rather than showing the previous guest's. */}
|
||||
<React.Fragment key={identityGeneration}>{children}</React.Fragment>
|
||||
</GuestIdentityContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export function useGuestIdentity(): GuestIdentityContextValue {
|
||||
|
||||
@@ -1120,6 +1120,7 @@
|
||||
"footer": {
|
||||
"forgetMeConfirm": "Ihr Name und Ihre Auswahl werden aus dieser Galerie entfernt.",
|
||||
"forgetMe": "Mich vergessen ({{name}})",
|
||||
"notYou": "Nicht Sie?",
|
||||
"socials": "Soziale Netzwerke"
|
||||
},
|
||||
"photosCount_one": "{{count}} Foto",
|
||||
|
||||
@@ -614,6 +614,7 @@
|
||||
"footer": {
|
||||
"forgetMeConfirm": "Your name and selections will be removed from this gallery.",
|
||||
"forgetMe": "Forget me ({{name}})",
|
||||
"notYou": "Not you?",
|
||||
"socials": "Social media"
|
||||
},
|
||||
"photosCount_one": "{{count}} photo",
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
/**
|
||||
* Guest identity has to survive a tab close (#1265).
|
||||
*
|
||||
* It used to live in sessionStorage, so a guest who closed the tab and came
|
||||
* back through the same emailed link lost their identity. The `?invite=` token
|
||||
* in that link is single-use and already redeemed, so re-registering was the
|
||||
* only way back in — and that inserted a second gallery_guests row, orphaning
|
||||
* the likes they had already made.
|
||||
*
|
||||
* The first test here is the one that matters: it fails on the old
|
||||
* implementation, because closing a tab clears sessionStorage.
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
__resetStorageResolutionForTests,
|
||||
clearGuestIdentity,
|
||||
getGuestIdentity,
|
||||
getGuestToken,
|
||||
storeGuestIdentity,
|
||||
} from '../guestIdentityStorage';
|
||||
|
||||
const SLUG = 'wedding-summer-2026';
|
||||
const IDENTITY = { id: 42, name: 'Tina', email: '[email protected]', identifier: 'abc-123' };
|
||||
|
||||
/** A JWT-shaped token whose `exp` is `secondsFromNow` away. Signature is irrelevant. */
|
||||
function tokenExpiringIn(secondsFromNow: number): string {
|
||||
const claims = { exp: Math.floor(Date.now() / 1000) + secondsFromNow };
|
||||
const payload = btoa(JSON.stringify(claims)).replace(/\+/g, '-').replace(/\//g, '_');
|
||||
return `header.${payload}.signature`;
|
||||
}
|
||||
|
||||
const TOKEN = tokenExpiringIn(30 * 24 * 60 * 60);
|
||||
|
||||
/** A tab close clears sessionStorage and leaves localStorage alone. */
|
||||
function closeTab(): void {
|
||||
window.sessionStorage.clear();
|
||||
}
|
||||
|
||||
describe('guest identity persistence (#1265)', () => {
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
window.sessionStorage.clear();
|
||||
__resetStorageResolutionForTests();
|
||||
});
|
||||
|
||||
it('survives a tab close, so a returning guest is still recognised', () => {
|
||||
storeGuestIdentity(SLUG, IDENTITY as never, TOKEN);
|
||||
|
||||
closeTab();
|
||||
|
||||
expect(getGuestToken(SLUG)).toBe(TOKEN);
|
||||
expect(getGuestIdentity(SLUG)).toMatchObject({ id: 42, email: '[email protected]' });
|
||||
});
|
||||
|
||||
it('does not write the token to sessionStorage, where a tab close would drop it', () => {
|
||||
storeGuestIdentity(SLUG, IDENTITY as never, TOKEN);
|
||||
|
||||
expect(window.sessionStorage.getItem(`guest_token_${SLUG}`)).toBeNull();
|
||||
expect(window.localStorage.getItem(`guest_token_${SLUG}`)).toBe(TOKEN);
|
||||
});
|
||||
|
||||
it('keeps identities of different galleries independent', () => {
|
||||
storeGuestIdentity(SLUG, IDENTITY as never, TOKEN);
|
||||
storeGuestIdentity('other-gallery', { ...IDENTITY, id: 7, name: 'Sam' } as never, 'other.token');
|
||||
|
||||
expect(getGuestToken(SLUG)).toBe(TOKEN);
|
||||
expect(getGuestToken('other-gallery')).toBe('other.token');
|
||||
expect(getGuestIdentity('other-gallery')).toMatchObject({ id: 7 });
|
||||
});
|
||||
|
||||
describe('migration from the pre-#1265 sessionStorage location', () => {
|
||||
it('adopts an identity left in sessionStorage by the previous build', () => {
|
||||
// Exactly what the old implementation would have written.
|
||||
window.sessionStorage.setItem(`guest_token_${SLUG}`, TOKEN);
|
||||
window.sessionStorage.setItem(`guest_identity_${SLUG}`, JSON.stringify(IDENTITY));
|
||||
|
||||
expect(getGuestToken(SLUG)).toBe(TOKEN);
|
||||
|
||||
// Moved, not copied — otherwise a later "forget me" could be undone by
|
||||
// the stale copy being migrated back.
|
||||
expect(window.sessionStorage.getItem(`guest_token_${SLUG}`)).toBeNull();
|
||||
expect(window.localStorage.getItem(`guest_token_${SLUG}`)).toBe(TOKEN);
|
||||
});
|
||||
|
||||
it('lets a fresh registration win over a stale sessionStorage copy', () => {
|
||||
window.sessionStorage.setItem(`guest_token_${SLUG}`, 'stale.token');
|
||||
storeGuestIdentity(SLUG, IDENTITY as never, TOKEN);
|
||||
|
||||
expect(getGuestToken(SLUG)).toBe(TOKEN);
|
||||
});
|
||||
});
|
||||
|
||||
describe('an expired token must not look like a signed-in guest', () => {
|
||||
// Persisting the token makes "stored but expired" reachable for the first
|
||||
// time — sessionStorage almost never survived the 30-day TTL. Nothing else
|
||||
// clears it: the 401 handler in config/api.ts only drops
|
||||
// `gallery_event_<slug>`, so without this the visitor is shown as signed
|
||||
// in while every like silently 401s.
|
||||
it('drops an identity whose token has expired', () => {
|
||||
storeGuestIdentity(SLUG, IDENTITY as never, tokenExpiringIn(-60));
|
||||
|
||||
expect(getGuestToken(SLUG)).toBeNull();
|
||||
expect(getGuestIdentity(SLUG)).toBeNull();
|
||||
// Purged, not just hidden, so the next read does no work.
|
||||
expect(window.localStorage.getItem(`guest_token_${SLUG}`)).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps a token that is still valid', () => {
|
||||
storeGuestIdentity(SLUG, IDENTITY as never, tokenExpiringIn(3600));
|
||||
|
||||
expect(getGuestToken(SLUG)).not.toBeNull();
|
||||
expect(getGuestIdentity(SLUG)).toMatchObject({ id: 42 });
|
||||
});
|
||||
|
||||
it('leaves an unparseable token alone, so the server stays the authority', () => {
|
||||
storeGuestIdentity(SLUG, IDENTITY as never, 'not-a-jwt');
|
||||
|
||||
expect(getGuestToken(SLUG)).toBe('not-a-jwt');
|
||||
});
|
||||
});
|
||||
|
||||
it('does not reject registration when the store refuses writes', () => {
|
||||
// localStorage that reads fine but throws on write (quota exhausted).
|
||||
// Throwing here would surface as a failed registration *after* the server
|
||||
// created the guest — the visitor retries and gets a duplicate row.
|
||||
const real = window.localStorage;
|
||||
const throwing = {
|
||||
getItem: real.getItem.bind(real),
|
||||
removeItem: real.removeItem.bind(real),
|
||||
key: real.key.bind(real),
|
||||
get length() { return real.length; },
|
||||
clear: real.clear.bind(real),
|
||||
setItem: () => { throw new DOMException('QuotaExceededError'); },
|
||||
} as unknown as Storage;
|
||||
Object.defineProperty(window, 'localStorage', { value: throwing, configurable: true });
|
||||
__resetStorageResolutionForTests();
|
||||
|
||||
expect(() => storeGuestIdentity(SLUG, IDENTITY as never, TOKEN)).not.toThrow();
|
||||
// Fell back to sessionStorage rather than losing the identity entirely.
|
||||
expect(window.sessionStorage.getItem(`guest_token_${SLUG}`)).toBe(TOKEN);
|
||||
|
||||
Object.defineProperty(window, 'localStorage', { value: real, configurable: true });
|
||||
__resetStorageResolutionForTests();
|
||||
});
|
||||
|
||||
it('falls back to sessionStorage when the probe fits but the real write does not', () => {
|
||||
// The probe writes one byte; a JWT plus a profile is far larger, so a
|
||||
// nearly-full store passes the probe and still rejects the real write.
|
||||
// Without the retry the context believes it is signed in with nothing
|
||||
// persisted — which 401s at once and re-registers on reload.
|
||||
const real = window.localStorage;
|
||||
const probeOnly = {
|
||||
getItem: real.getItem.bind(real),
|
||||
removeItem: real.removeItem.bind(real),
|
||||
key: real.key.bind(real),
|
||||
get length() { return real.length; },
|
||||
clear: real.clear.bind(real),
|
||||
setItem: (k: string, _v: string) => {
|
||||
if (k === '__picpeak_probe__') return; // probe fits
|
||||
throw new DOMException('QuotaExceededError'); // the real payload does not
|
||||
},
|
||||
} as unknown as Storage;
|
||||
Object.defineProperty(window, 'localStorage', { value: probeOnly, configurable: true });
|
||||
__resetStorageResolutionForTests();
|
||||
|
||||
expect(() => storeGuestIdentity(SLUG, IDENTITY as never, TOKEN)).not.toThrow();
|
||||
expect(window.sessionStorage.getItem(`guest_token_${SLUG}`)).toBe(TOKEN);
|
||||
|
||||
Object.defineProperty(window, 'localStorage', { value: real, configurable: true });
|
||||
__resetStorageResolutionForTests();
|
||||
});
|
||||
|
||||
describe('the sessionStorage fallback has to be readable after a reload', () => {
|
||||
// Making a store that accepts the one-byte probe and nothing else, exactly
|
||||
// like a nearly-full localStorage.
|
||||
const installProbeOnlyLocalStorage = () => {
|
||||
const real = window.localStorage;
|
||||
const probeOnly = {
|
||||
getItem: real.getItem.bind(real),
|
||||
removeItem: real.removeItem.bind(real),
|
||||
key: real.key.bind(real),
|
||||
get length() { return real.length; },
|
||||
clear: real.clear.bind(real),
|
||||
setItem: (k: string, _v: string) => {
|
||||
if (k === '__picpeak_probe__') return;
|
||||
throw new DOMException('QuotaExceededError');
|
||||
},
|
||||
} as unknown as Storage;
|
||||
Object.defineProperty(window, 'localStorage', { value: probeOnly, configurable: true });
|
||||
__resetStorageResolutionForTests();
|
||||
return () => {
|
||||
Object.defineProperty(window, 'localStorage', { value: real, configurable: true });
|
||||
__resetStorageResolutionForTests();
|
||||
};
|
||||
};
|
||||
|
||||
it('still finds the identity on the next page load', () => {
|
||||
const restore = installProbeOnlyLocalStorage();
|
||||
storeGuestIdentity(SLUG, IDENTITY as never, TOKEN);
|
||||
|
||||
// A reload discards module state: storage is re-resolved, localStorage
|
||||
// passes the probe again, and the identity is sitting in sessionStorage.
|
||||
__resetStorageResolutionForTests();
|
||||
|
||||
expect(getGuestToken(SLUG)).toBe(TOKEN);
|
||||
expect(getGuestIdentity(SLUG)).toMatchObject({ id: 42 });
|
||||
restore();
|
||||
});
|
||||
|
||||
it('does not keep retrying the refused promotion on every read', () => {
|
||||
const restore = installProbeOnlyLocalStorage();
|
||||
storeGuestIdentity(SLUG, IDENTITY as never, TOKEN);
|
||||
__resetStorageResolutionForTests();
|
||||
|
||||
// First read: tries localStorage, is refused, remembers that.
|
||||
getGuestToken(SLUG);
|
||||
let writes = 0;
|
||||
const store = window.localStorage;
|
||||
const original = store.setItem;
|
||||
store.setItem = ((k: string, v: string) => { writes++; return original.call(store, k, v); }) as never;
|
||||
getGuestToken(SLUG);
|
||||
getGuestIdentity(SLUG);
|
||||
expect(writes).toBe(0);
|
||||
restore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('a store never holds a token without its profile', () => {
|
||||
// localStorage that takes the token key and refuses the profile key —
|
||||
// the partial acceptance a nearly-full store can produce. A token without
|
||||
// a profile means x-guest-token is sent while the provider prompts to
|
||||
// register: the second gallery_guests row, with two live tokens.
|
||||
const installTokenOnlyLocalStorage = () => {
|
||||
const real = window.localStorage;
|
||||
const tokenOnly = {
|
||||
getItem: real.getItem.bind(real),
|
||||
removeItem: real.removeItem.bind(real),
|
||||
key: real.key.bind(real),
|
||||
get length() { return real.length; },
|
||||
clear: real.clear.bind(real),
|
||||
setItem: (k: string, v: string) => {
|
||||
if (k.startsWith('guest_identity_')) throw new DOMException('QuotaExceededError');
|
||||
real.setItem(k, v);
|
||||
},
|
||||
} as unknown as Storage;
|
||||
Object.defineProperty(window, 'localStorage', { value: tokenOnly, configurable: true });
|
||||
__resetStorageResolutionForTests();
|
||||
return () => {
|
||||
Object.defineProperty(window, 'localStorage', { value: real, configurable: true });
|
||||
__resetStorageResolutionForTests();
|
||||
};
|
||||
};
|
||||
|
||||
it('on registration, falls back as a pair and leaves no token behind', () => {
|
||||
const restore = installTokenOnlyLocalStorage();
|
||||
storeGuestIdentity(SLUG, IDENTITY as never, TOKEN);
|
||||
|
||||
expect(window.localStorage.getItem(`guest_token_${SLUG}`)).toBeNull();
|
||||
expect(window.sessionStorage.getItem(`guest_token_${SLUG}`)).toBe(TOKEN);
|
||||
expect(getGuestToken(SLUG)).toBe(TOKEN);
|
||||
expect(getGuestIdentity(SLUG)).toMatchObject({ id: 42 });
|
||||
restore();
|
||||
});
|
||||
|
||||
it('on migration, leaves the legacy pair intact rather than moving half of it', () => {
|
||||
const restore = installTokenOnlyLocalStorage();
|
||||
window.sessionStorage.setItem(`guest_token_${SLUG}`, TOKEN);
|
||||
window.sessionStorage.setItem(`guest_identity_${SLUG}`, JSON.stringify(IDENTITY));
|
||||
|
||||
expect(getGuestToken(SLUG)).toBe(TOKEN);
|
||||
expect(getGuestIdentity(SLUG)).toMatchObject({ id: 42 });
|
||||
// Nothing half-moved: the token did not land in localStorage on its own.
|
||||
expect(window.localStorage.getItem(`guest_token_${SLUG}`)).toBeNull();
|
||||
expect(window.sessionStorage.getItem(`guest_token_${SLUG}`)).toBe(TOKEN);
|
||||
restore();
|
||||
});
|
||||
});
|
||||
|
||||
it('clears both stores, so a cleared identity cannot be migrated back', () => {
|
||||
// A guest who registered before the upgrade and again after it has a copy
|
||||
// in each store; "forget me" has to remove both.
|
||||
window.sessionStorage.setItem(`guest_token_${SLUG}`, 'legacy.token');
|
||||
window.sessionStorage.setItem(`guest_identity_${SLUG}`, JSON.stringify(IDENTITY));
|
||||
storeGuestIdentity(SLUG, IDENTITY as never, TOKEN);
|
||||
|
||||
clearGuestIdentity(SLUG);
|
||||
|
||||
expect(getGuestToken(SLUG)).toBeNull();
|
||||
expect(getGuestIdentity(SLUG)).toBeNull();
|
||||
expect(window.sessionStorage.getItem(`guest_token_${SLUG}`)).toBeNull();
|
||||
expect(window.localStorage.getItem(`guest_token_${SLUG}`)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,26 @@
|
||||
/**
|
||||
* Per-gallery guest identity persistence.
|
||||
*
|
||||
* Stores the guest JWT and profile in sessionStorage, keyed by gallery slug,
|
||||
* so multiple open tabs of the same gallery share identity but different
|
||||
* browser contexts (and different galleries in the same context) stay
|
||||
* independent.
|
||||
* Stores the guest JWT and profile in localStorage, keyed by gallery slug, so
|
||||
* a guest who closes the tab and comes back through the same emailed link is
|
||||
* still recognised as themselves.
|
||||
*
|
||||
* This used to be sessionStorage, which meant the practical lifetime of the
|
||||
* identity was "until this tab closes" — the GUEST_TOKEN_TTL of 30 days
|
||||
* (raised in #1216 precisely to stop identity churn) was almost never reached.
|
||||
* A returning guest got the registration prompt again, and because the
|
||||
* `?invite=` token in their link is single-use and already redeemed, they had
|
||||
* no way back to their own identity. Typing the same name again inserted a
|
||||
* second gallery_guests row, so their earlier likes belonged to an identity
|
||||
* they could no longer act as (#1265).
|
||||
*
|
||||
* Why this does not reopen the objection #1216 raised: deduplicating on a
|
||||
* typed email was rejected because anyone who knows an address could claim
|
||||
* that person's identity. This grants nothing to anyone — it only stops the
|
||||
* browser discarding a token it was already given. Note also that gallery
|
||||
* ACCESS lives in sessionStorage (galleryAuthStorage.ts) and is unaffected, so
|
||||
* a returning visitor still has to pass the gallery password before a stored
|
||||
* identity means anything.
|
||||
*/
|
||||
|
||||
import type { GuestIdentity } from '../services/guests.service';
|
||||
@@ -12,51 +28,317 @@ import type { GuestIdentity } from '../services/guests.service';
|
||||
const TOKEN_KEY_PREFIX = 'guest_token_';
|
||||
const IDENTITY_KEY_PREFIX = 'guest_identity_';
|
||||
|
||||
/** Same-tab counterpart to the native cross-tab `storage` event. */
|
||||
export const GUEST_IDENTITY_CLEARED_EVENT = 'picpeak:guest-identity-cleared';
|
||||
|
||||
const isBrowser = typeof window !== 'undefined';
|
||||
|
||||
/**
|
||||
* A store is only usable if it can be WRITTEN, not merely read.
|
||||
*
|
||||
* Guarding the property access alone is not enough: there are browsers and
|
||||
* states (quota exhausted, Safari private mode historically) where
|
||||
* `window.localStorage` resolves fine but `setItem` throws. That would sail
|
||||
* past a read-only guard, and then storeGuestIdentity() would throw *after*
|
||||
* the server had already created the guest — the registration would report
|
||||
* failure, the visitor would try again, and the retry would insert the second
|
||||
* gallery_guests row this whole change exists to prevent.
|
||||
*/
|
||||
function isUsable(storage: Storage): boolean {
|
||||
const probe = '__picpeak_probe__';
|
||||
try {
|
||||
storage.setItem(probe, '1');
|
||||
storage.removeItem(probe);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Resolved once. getGuestToken() runs on every API request through the axios
|
||||
// interceptor, and probing a write each time would be wasteful.
|
||||
let resolvedStorage: Storage | null | undefined;
|
||||
|
||||
const getStorage = (): Storage | null => {
|
||||
if (!isBrowser) return null;
|
||||
if (resolvedStorage !== undefined) return resolvedStorage;
|
||||
|
||||
for (const pick of [() => window.localStorage, () => window.sessionStorage]) {
|
||||
let candidate: Storage;
|
||||
try {
|
||||
candidate = pick();
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (candidate && isUsable(candidate)) {
|
||||
resolvedStorage = candidate;
|
||||
return resolvedStorage;
|
||||
}
|
||||
}
|
||||
// sessionStorage is the degraded fallback: identity lasts one tab, which is
|
||||
// the pre-#1265 behaviour rather than no identity at all.
|
||||
resolvedStorage = null;
|
||||
return resolvedStorage;
|
||||
};
|
||||
|
||||
/** Test seam — storage availability is resolved once per page load. */
|
||||
export function __resetStorageResolutionForTests(): void {
|
||||
resolvedStorage = undefined;
|
||||
promotionFailed.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Treat a token past its `exp` as absent.
|
||||
*
|
||||
* GUEST_TOKEN_TTL is 30 days, and until now sessionStorage almost never
|
||||
* survived long enough to reach it. Persisting the token makes "stored but
|
||||
* expired" a reachable state, and nothing clears it: the 401 handler in
|
||||
* config/api.ts only drops `gallery_event_<slug>`. Without this the visitor is
|
||||
* shown as signed in while every like silently 401s, and ensureIdentity()
|
||||
* short-circuits so they are never offered recovery.
|
||||
*
|
||||
* The signature is not verified here — that is the server's job. This only
|
||||
* reads the expiry so the client stops presenting an identity the backend has
|
||||
* already stopped honouring. A token we cannot parse is left alone rather than
|
||||
* discarded, so the server stays the authority on anything ambiguous.
|
||||
*/
|
||||
function isExpired(token: string): boolean {
|
||||
const payload = token.split('.')[1];
|
||||
if (!payload) return false;
|
||||
try {
|
||||
return window.sessionStorage;
|
||||
const base64 = payload.replace(/-/g, '+').replace(/_/g, '/');
|
||||
const claims = JSON.parse(atob(base64)) as { exp?: number };
|
||||
if (typeof claims.exp !== 'number') return false;
|
||||
return claims.exp * 1000 <= Date.now();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The secondary store: sessionStorage, whenever it is distinct from the store
|
||||
* reads are resolved against. Two things can live there — a pre-#1265
|
||||
* identity the previous build wrote, and the quota fallback below.
|
||||
*/
|
||||
function secondaryStore(primary: Storage): Storage | null {
|
||||
if (!isBrowser) return null;
|
||||
try {
|
||||
const candidate = window.sessionStorage;
|
||||
return candidate && candidate !== primary ? candidate : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the pair to one store, PROFILE FIRST. A store that is nearly full can
|
||||
* accept the first write and reject the second, and "token stored, profile
|
||||
* missing" is the one partial state that produces the duplicate-row bug this
|
||||
* file exists to stop: the interceptor sends x-guest-token while the provider
|
||||
* sees no identity, prompts, and registers a second guest. A profile without
|
||||
* a token is inert. Anything half-written is removed again before reporting
|
||||
* failure, so a store either holds the whole pair or none of it.
|
||||
*/
|
||||
function writePair(target: Storage, slug: string, identityRaw: string, token: string): boolean {
|
||||
const tokenKey = `${TOKEN_KEY_PREFIX}${slug}`;
|
||||
const identityKey = `${IDENTITY_KEY_PREFIX}${slug}`;
|
||||
try {
|
||||
target.setItem(identityKey, identityRaw);
|
||||
target.setItem(tokenKey, token);
|
||||
return true;
|
||||
} catch {
|
||||
try {
|
||||
target.removeItem(tokenKey);
|
||||
target.removeItem(identityKey);
|
||||
} catch {
|
||||
// Nothing more to do.
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Slugs whose pair could not be promoted into the primary store on this page
|
||||
// load. getGuestToken() runs on every API request; once the primary store has
|
||||
// refused the write there is no point retrying it per request.
|
||||
const promotionFailed = new Set<string>();
|
||||
|
||||
/**
|
||||
* Read the pair, looking in the primary store first and the secondary one
|
||||
* second. Finding it in the secondary store covers two cases that used to be
|
||||
* one-way migrations and are now read-through:
|
||||
*
|
||||
* - a pre-#1265 identity the previous build left in sessionStorage. Without
|
||||
* this everyone with a gallery open at upgrade time is treated as a new
|
||||
* guest on reload — the exact duplicate-row bug, fired once per guest;
|
||||
* - the quota fallback in storeGuestIdentity(). That used to repoint reads
|
||||
* at sessionStorage through module state, which a reload discards: the
|
||||
* next page load probed localStorage, passed, and read nothing while the
|
||||
* identity sat unreadable one store over. Reads now find it wherever it
|
||||
* demonstrably fits.
|
||||
*
|
||||
* A pair found in the secondary store is promoted into the primary one when
|
||||
* that store will take it (MOVED, not copied — a stale copy would otherwise
|
||||
* be migrated straight back after "forget me"), and left where it is when it
|
||||
* will not. A pair already in the primary store always wins over the
|
||||
* secondary copy: a fresh registration in this tab beats a stale leftover.
|
||||
*/
|
||||
function readPair(slug: string): { token: string | null; identityRaw: string | null } {
|
||||
const primary = getStorage();
|
||||
if (!primary) return { token: null, identityRaw: null };
|
||||
const tokenKey = `${TOKEN_KEY_PREFIX}${slug}`;
|
||||
const identityKey = `${IDENTITY_KEY_PREFIX}${slug}`;
|
||||
|
||||
let token: string | null = null;
|
||||
let identityRaw: string | null = null;
|
||||
try {
|
||||
token = primary.getItem(tokenKey);
|
||||
identityRaw = primary.getItem(identityKey);
|
||||
} catch {
|
||||
return { token: null, identityRaw: null };
|
||||
}
|
||||
|
||||
const secondary = secondaryStore(primary);
|
||||
if (!secondary) return { token, identityRaw };
|
||||
|
||||
let secondaryToken: string | null = null;
|
||||
let secondaryIdentity: string | null = null;
|
||||
try {
|
||||
secondaryToken = secondary.getItem(tokenKey);
|
||||
secondaryIdentity = secondary.getItem(identityKey);
|
||||
} catch {
|
||||
return { token, identityRaw };
|
||||
}
|
||||
|
||||
if (token) {
|
||||
// Primary wins. Drop the leftover so it can never be promoted later.
|
||||
if (secondaryToken || secondaryIdentity) {
|
||||
try {
|
||||
secondary.removeItem(tokenKey);
|
||||
secondary.removeItem(identityKey);
|
||||
} catch {
|
||||
// Best-effort.
|
||||
}
|
||||
}
|
||||
return { token, identityRaw };
|
||||
}
|
||||
|
||||
if (!secondaryToken) return { token: null, identityRaw };
|
||||
|
||||
if (!promotionFailed.has(slug) && secondaryIdentity && writePair(primary, slug, secondaryIdentity, secondaryToken)) {
|
||||
try {
|
||||
secondary.removeItem(tokenKey);
|
||||
secondary.removeItem(identityKey);
|
||||
} catch {
|
||||
// The copy stays behind; the primary now wins on every later read.
|
||||
}
|
||||
} else if (secondaryIdentity) {
|
||||
promotionFailed.add(slug);
|
||||
}
|
||||
return { token: secondaryToken, identityRaw: secondaryIdentity };
|
||||
}
|
||||
|
||||
export function storeGuestIdentity(slug: string, identity: GuestIdentity, token: string): void {
|
||||
const storage = getStorage();
|
||||
if (!storage || !slug) return;
|
||||
storage.setItem(`${TOKEN_KEY_PREFIX}${slug}`, token);
|
||||
storage.setItem(`${IDENTITY_KEY_PREFIX}${slug}`, JSON.stringify(identity));
|
||||
const identityRaw = JSON.stringify(identity);
|
||||
|
||||
if (writePair(storage, slug, identityRaw, token)) {
|
||||
// A fresh registration supersedes anything the secondary store holds.
|
||||
const secondary = secondaryStore(storage);
|
||||
if (secondary) {
|
||||
try {
|
||||
secondary.removeItem(`${TOKEN_KEY_PREFIX}${slug}`);
|
||||
secondary.removeItem(`${IDENTITY_KEY_PREFIX}${slug}`);
|
||||
} catch {
|
||||
// Best-effort.
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// The probe in getStorage() only proves a one-byte write fits; a JWT plus a
|
||||
// profile is far larger, so a nearly-full store can pass the probe and still
|
||||
// reject the real write. Retry in sessionStorage rather than leaving the
|
||||
// context believing it is signed in with nothing persisted — that state
|
||||
// 401s immediately and re-registers (another duplicate row) on reload.
|
||||
// readPair() looks there on every read, including after a reload, so no
|
||||
// module state has to remember which store took the write.
|
||||
const secondary = secondaryStore(storage);
|
||||
if (secondary) {
|
||||
promotionFailed.add(slug);
|
||||
writePair(secondary, slug, identityRaw, token);
|
||||
}
|
||||
// Otherwise no store will take it. The identity lasts as long as this page
|
||||
// does, which is strictly better than rejecting a registration the server
|
||||
// has already completed.
|
||||
}
|
||||
|
||||
export function getGuestToken(slug?: string | null): string | null {
|
||||
const storage = getStorage();
|
||||
if (!storage) return null;
|
||||
const resolvedSlug = slug || extractSlugFromLocation();
|
||||
if (!resolvedSlug) return null;
|
||||
return storage.getItem(`${TOKEN_KEY_PREFIX}${resolvedSlug}`);
|
||||
const { token } = readPair(resolvedSlug);
|
||||
if (token && isExpired(token)) {
|
||||
clearGuestIdentity(resolvedSlug);
|
||||
return null;
|
||||
}
|
||||
return token;
|
||||
}
|
||||
|
||||
export function getGuestIdentity(slug?: string | null): GuestIdentity | null {
|
||||
const storage = getStorage();
|
||||
if (!storage) return null;
|
||||
const resolvedSlug = slug || extractSlugFromLocation();
|
||||
if (!resolvedSlug) return null;
|
||||
const raw = storage.getItem(`${IDENTITY_KEY_PREFIX}${resolvedSlug}`);
|
||||
if (!raw) return null;
|
||||
const { token, identityRaw } = readPair(resolvedSlug);
|
||||
// An identity whose token has expired must not be presented as signed in.
|
||||
if (!token) return null;
|
||||
if (isExpired(token)) {
|
||||
clearGuestIdentity(resolvedSlug);
|
||||
return null;
|
||||
}
|
||||
if (!identityRaw) return null;
|
||||
try {
|
||||
return JSON.parse(raw) as GuestIdentity;
|
||||
return JSON.parse(identityRaw) as GuestIdentity;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function clearGuestIdentity(slug: string): void {
|
||||
const storage = getStorage();
|
||||
if (!storage || !slug) return;
|
||||
storage.removeItem(`${TOKEN_KEY_PREFIX}${slug}`);
|
||||
storage.removeItem(`${IDENTITY_KEY_PREFIX}${slug}`);
|
||||
if (!slug) return;
|
||||
promotionFailed.delete(slug);
|
||||
// Clear BOTH stores: a copy left behind in sessionStorage would be migrated
|
||||
// straight back on the next read, silently undoing "forget me".
|
||||
const stores: Storage[] = [];
|
||||
const primary = getStorage();
|
||||
if (primary) stores.push(primary);
|
||||
if (isBrowser) {
|
||||
try {
|
||||
if (window.sessionStorage && !stores.includes(window.sessionStorage)) {
|
||||
stores.push(window.sessionStorage);
|
||||
}
|
||||
} catch {
|
||||
// sessionStorage unavailable — nothing to clear there.
|
||||
}
|
||||
}
|
||||
for (const storage of stores) {
|
||||
try {
|
||||
storage.removeItem(`${TOKEN_KEY_PREFIX}${slug}`);
|
||||
storage.removeItem(`${IDENTITY_KEY_PREFIX}${slug}`);
|
||||
} catch {
|
||||
// Best-effort.
|
||||
}
|
||||
}
|
||||
|
||||
// `storage` events fire only in OTHER documents, so a clear triggered inside
|
||||
// this tab (the axios interceptor dropping a server-rejected identity) would
|
||||
// leave the provider still showing the guest and ensureIdentity() still
|
||||
// handing it out. Announce it locally too.
|
||||
if (isBrowser) {
|
||||
try {
|
||||
window.dispatchEvent(new CustomEvent(GUEST_IDENTITY_CLEARED_EVENT, { detail: { slug } }));
|
||||
} catch {
|
||||
// CustomEvent unavailable — the provider simply refreshes on next mount.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user