diff --git a/backend/__tests__/routes/authSession.symmetry.test.js b/backend/__tests__/routes/authSession.symmetry.test.js index 50ba848f..e6fff216 100644 --- a/backend/__tests__/routes/authSession.symmetry.test.js +++ b/backend/__tests__/routes/authSession.symmetry.test.js @@ -135,9 +135,9 @@ function signAdminToken({ id = 1, username = 'admin', iat, exp }) { ); } -function signGalleryToken({ eventId = 100, eventSlug = 'wedding' } = {}) { +function signGalleryToken({ eventId = 100, eventSlug = 'wedding', ...extra } = {}) { return jwt.sign( - { eventId, eventSlug, type: 'gallery' }, + { eventId, eventSlug, type: 'gallery', ...extra }, process.env.JWT_SECRET, { expiresIn: '1h', issuer: 'picpeak-auth' } ); @@ -288,6 +288,56 @@ describe('GET /auth/session — symmetry with protected middleware', () => { expect(res.body.valid).toBe(true); }); + /** + * What KIND of gallery session this is (#1149). + * + * The frontend used to keep this in sessionStorage, which is per-TAB while + * the cookie is per-browser: a gallery reopened in a second tab lost + * 'client' even though the backend still served it as one, and the UI hid + * the only control that clears the privileged cookie. Reported from the + * token so a restored session knows what it actually is. + */ + describe('gallery session kind', () => { + beforeEach(() => { + fakeDb.events.push({ + id: 100, + slug: 'wedding', + is_active: true, + is_archived: false, + expires_at: new Date(Date.now() + 86400_000), + }); + }); + + it('reports a PIN-client session as client', async () => { + const res = await request(makeApp()) + .get('/auth/session?slug=wedding') + .set('Authorization', `Bearer ${signGalleryToken({ accessLevel: 'client' })}`); + expect(res.body.valid).toBe(true); + expect(res.body.accessLevel).toBe('client'); + expect(res.body.viaCustomer).toBe(false); + }); + + it('reports a customer-portal session, which looks like a guest', async () => { + const res = await request(makeApp()) + .get('/auth/session?slug=wedding') + .set('Authorization', `Bearer ${signGalleryToken({ via: 'customer', customerId: 7 })}`); + expect(res.body.valid).toBe(true); + expect(res.body.accessLevel).toBe('guest'); + expect(res.body.viaCustomer).toBe(true); + }); + + it('reports a plain guest as neither', async () => { + // The flags have to discriminate, or they would just hand every visitor + // a Logout button back. + const res = await request(makeApp()) + .get('/auth/session?slug=wedding') + .set('Authorization', `Bearer ${signGalleryToken()}`); + expect(res.body.valid).toBe(true); + expect(res.body.accessLevel).toBe('guest'); + expect(res.body.viaCustomer).toBe(false); + }); + }); + it('returns valid:false when the token is revoked', async () => { fakeDb.adminUsers.push({ id: 1, diff --git a/backend/src/routes/auth.js b/backend/src/routes/auth.js index 13a6acf0..6f770525 100644 --- a/backend/src/routes/auth.js +++ b/backend/src/routes/auth.js @@ -725,7 +725,18 @@ router.get('/session', async (req, res) => { expiresIn: Math.floor(remainingTime), user: decoded.username || decoded.eventSlug, eventSlug: decoded.eventSlug, - adminUsername: decoded.username + adminUsername: decoded.username, + // What KIND of gallery session this cookie is (#1149). The frontend + // kept this in sessionStorage, which is per-tab: reopening a gallery + // in a second tab lost 'client' while the cookie — and therefore the + // backend — still treated it as one. Reported from the token so a + // restored session knows what it actually is. + // + // viaCustomer marks a portal-minted token, which opens the gallery + // without the password. Also a credential, and it does not look like + // one: it runs at accessLevel 'guest'. + accessLevel: decoded.type === 'gallery' ? (decoded.accessLevel || 'guest') : undefined, + viaCustomer: decoded.type === 'gallery' ? decoded.via === 'customer' : undefined }); } catch (err) { res.json({ diff --git a/frontend/src/components/gallery/GalleryView.tsx b/frontend/src/components/gallery/GalleryView.tsx index b63eca31..fc548f51 100644 --- a/frontend/src/components/gallery/GalleryView.tsx +++ b/frontend/src/components/gallery/GalleryView.tsx @@ -33,6 +33,20 @@ import { useQueryClient } from '@tanstack/react-query'; interface GalleryViewProps { slug: string; + /** + * Whether this gallery is password-protected (#1149). + * + * Drives the Logout button. Logging out of a gallery that asks for nothing + * is meaningless — there is no credential to drop and nothing to return to + * — and it used to strand the visitor: GalleryPage's auto-login is a + * one-shot latch, so clearing the session left the page rendering its + * skeleton until a manual reload. + * + * A client (PIN) session still gets the button on a public gallery: that + * one IS a credential, and it is the only way back to the guest view. So is + * a customer-portal session. + */ + requiresPassword?: boolean; event: { id: number; event_name: string; @@ -67,9 +81,9 @@ const parseDefaultPhotoSort = (defaultSort?: string): { sortBy: 'date' | 'name' } }; -export const GalleryView: React.FC = ({ slug, event }) => { +export const GalleryView: React.FC = ({ slug, event, requiresPassword = true }) => { const { t } = useTranslation(); - const { logout, isClient } = useGalleryAuth(); + const { logout, isClient, viaCustomer } = useGalleryAuth(); const { setTheme, theme } = useTheme(); const queryClient = useQueryClient(); const [selectedCategoryId, setSelectedCategoryId] = useState(null); @@ -705,6 +719,18 @@ export const GalleryView: React.FC = ({ slug, event }) => { // Skip all wrapper elements (header, footer, sidebar, filters) for these layouts const isFullPageLayout = theme.galleryLayout === 'gallery-premium' || theme.galleryLayout === 'gallery-story'; + // Does this session hold something worth dropping? A password gallery and a + // PIN client obviously do, and so does a customer-portal session — its token + // opens the gallery without the password and lives for 24h in a cookie the + // customer logout does not clear. + // + // Read from the auth context, which resolves it from /auth/session on mount. + // accessLevel used to come from sessionStorage alone, which is per-TAB while + // the cookie is per-browser: a gallery reopened in a second tab lost + // 'client' while the backend went on serving it as one, and the gate would + // then hide the only control that clears the privileged cookie (#1149). + const showLogoutControl = requiresPassword || isClient || viaCustomer; + // For full-page layouts, render just the PhotoGridWithLayouts without any wrappers if (isFullPageLayout) { return ( @@ -745,7 +771,10 @@ export const GalleryView: React.FC = ({ slug, event }) => { heroDividerStyle={data?.event?.hero_divider_style || theme.heroDividerStyle || 'wave'} heroImageAnchor={data?.event?.hero_image_anchor || 'center'} welcomeMessage={event.welcome_message} - onLogout={logout} + // Same gate as the standard layout below (#1149). These layouts + // render the button on the callback being present rather than on a + // showLogout flag, so withholding it is how the gate reaches them. + onLogout={showLogoutControl ? logout : undefined} showOriginalFilename={showOriginalFilename} /> @@ -823,7 +852,7 @@ export const GalleryView: React.FC = ({ slug, event }) => { heroLogoVisible={data?.event?.hero_logo_visible !== false} heroLogoSize={data?.event?.hero_logo_size || undefined} headerStyle={data?.event?.header_style || theme.headerStyle} - showLogout={true} + showLogout={showLogoutControl} onLogout={logout} // Old Download All header button is replaced by the new // showHeaderDownload below — accent-coloured, always visible when diff --git a/frontend/src/contexts/GalleryAuthContext.tsx b/frontend/src/contexts/GalleryAuthContext.tsx index 2fce4061..6f15cb18 100644 --- a/frontend/src/contexts/GalleryAuthContext.tsx +++ b/frontend/src/contexts/GalleryAuthContext.tsx @@ -39,6 +39,8 @@ interface GalleryAuthContextType { isAuthenticated: boolean; event: GalleryEvent | null; accessLevel: GalleryAccessLevel; + /** Session was minted by the customer portal — credentialed, not a plain guest. */ + viaCustomer: boolean; isClient: boolean; login: (slug: string, password?: string, recaptchaToken?: string | null) => Promise; clientLogin: (slug: string, password: string) => Promise; @@ -65,6 +67,7 @@ export const GalleryAuthProvider: React.FC = ({ childr const [isAuthenticated, setIsAuthenticated] = useState(false); const [event, setEvent] = useState(null); const [accessLevel, setAccessLevel] = useState('guest'); + const [viaCustomer, setViaCustomer] = useState(false); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); const [routeError, setRouteError] = useState(null); @@ -204,13 +207,25 @@ export const GalleryAuthProvider: React.FC = ({ childr const initialise = async () => { try { setIsLoading(true); - const sessionResponse = await api.get<{ valid: boolean; type: string; eventSlug?: string }>( + const sessionResponse = await api.get<{ + valid: boolean; type: string; eventSlug?: string; + accessLevel?: GalleryAccessLevel; viaCustomer?: boolean; + }>( '/auth/session', { params: { slug: currentSlug } } ); if (sessionResponse.data?.valid && sessionResponse.data.type === 'gallery' && sessionResponse.data.eventSlug === currentSlug) { setIsAuthenticated(true); + // The SERVER's view of this session, not the per-tab sessionStorage + // guess above (#1149). A second tab has no sessionStorage but the + // same cookie, so the stored value silently downgraded a client + // session to 'guest' while the backend kept serving it as a client. + if (sessionResponse.data.accessLevel === 'client') { + setAccessLevel('client'); + sessionStorage.setItem(`gallery_access_level_${currentSlug}`, 'client'); + } + setViaCustomer(Boolean(sessionResponse.data.viaCustomer)); // Always refresh from the server — the stored event from sessionStorage // is shown above as an instant placeholder for perceived perf, but it @@ -340,6 +355,7 @@ export const GalleryAuthProvider: React.FC = ({ childr setIsAuthenticated(false); setEvent(null); setAccessLevel('guest'); + setViaCustomer(false); clearActiveGallerySlug(); }; @@ -350,6 +366,7 @@ export const GalleryAuthProvider: React.FC = ({ childr event, accessLevel, isClient: accessLevel === 'client', + viaCustomer, login, clientLogin: clientLoginFn, logout, diff --git a/frontend/src/pages/GalleryPage.tsx b/frontend/src/pages/GalleryPage.tsx index 811f25b7..7bb96084 100644 --- a/frontend/src/pages/GalleryPage.tsx +++ b/frontend/src/pages/GalleryPage.tsx @@ -345,14 +345,51 @@ export const GalleryPage: React.FC = () => { // Show gallery view if authenticated if (isAuthenticated && event) { - return ; + return ; } // Public gallery: auto-login is in flight (or about to fire). Show the // skeleton instead of the "publicly accessible — loading photos" card so // visitors see one continuous skeleton until real photos appear (#321). if (!requiresPassword) { - return ; + if (!autoLoginAttempted || isLoggingIn) { + return ; + } + + // Auto-login has run and we are still not authenticated (#1149). + // + // Returning the skeleton here meant it never stopped: the effect above is + // latched on autoLoginAttempted and will not fire again, so the visitor + // sat on a loading gallery until they reloaded by hand. It also swallowed + // loginError completely — a public gallery that failed to open showed no + // reason, because this branch returns before the form that renders it. + // + // Reachable two ways: a failed or expired auto-login, and clearing the + // session from inside the gallery (the Logout button that should not have + // been there, or GalleryView's 401 handler). Retry re-arms the latch; it + // is a button rather than an automatic re-fire so a genuinely failing + // gallery cannot spin. + return ( +
+ + + +

+ {loginError || t('gallery.failedToLoad', 'Failed to load gallery')} +

+ +
+
+
+ ); } // Show login form