diff --git a/backend/__tests__/routes/authSession.symmetry.test.js b/backend/__tests__/routes/authSession.symmetry.test.js index 7b78e61e..5c48adf8 100644 --- a/backend/__tests__/routes/authSession.symmetry.test.js +++ b/backend/__tests__/routes/authSession.symmetry.test.js @@ -148,9 +148,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' } ); @@ -301,6 +301,58 @@ 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 () => { + // via:'customer' runs at accessLevel 'guest' but bypasses reveal mode, + // so it is a credential that does not look like one. + 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 ded3c862..bdeb36f1 100644 --- a/backend/src/routes/auth.js +++ b/backend/src/routes/auth.js @@ -819,6 +819,17 @@ router.get('/session', async (req, res) => { user: decoded.username || decoded.eventSlug, eventSlug: decoded.eventSlug, 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. It runs at accessLevel + // 'guest' but bypasses reveal mode, so it is a credential even though + // it does not look like one. + accessLevel: decoded.type === 'gallery' ? (decoded.accessLevel || 'guest') : undefined, + viaCustomer: decoded.type === 'gallery' ? decoded.via === 'customer' : undefined, // Full admin payload (or null) — lets the SPA hydrate its user // state after a redirect-established session (SSO, #798) where no // login JSON response ever reached it. diff --git a/frontend/src/components/gallery/GalleryView.tsx b/frontend/src/components/gallery/GalleryView.tsx index 585a5a6e..0ba3ea6a 100644 --- a/frontend/src/components/gallery/GalleryView.tsx +++ b/frontend/src/components/gallery/GalleryView.tsx @@ -55,6 +55,20 @@ interface GalleryViewProps { info_mode?: 'inherit' | 'custom' | 'off'; info_markdown?: string | null; }; + /** + * 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 — see `via_customer` on the /photos response. + */ + requiresPassword?: boolean; } // Convert default_photo_sort DB value to internal sortBy state @@ -76,9 +90,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); @@ -994,6 +1008,19 @@ 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. So does a customer-portal session: its token + // bypasses reveal mode, so it opens galleries a plain visitor cannot, and it + // lives for 24h in a cookie the customer logout does not clear. Hiding the + // control would remove the only way to drop it (#1149). + // + // Read from the auth context, which resolves this from /auth/session on + // mount — NOT from the photos payload. That response is cached by React + // Query for five minutes on a key that knows nothing about the session, so + // opening a gallery as a guest and then from the portal would have reused + // the guest answer, and vice versa. + const showLogoutControl = requiresPassword || isClient || viaCustomer; + // For full-page layouts, render just the PhotoGridWithLayouts without any wrappers if (isFullPageLayout) { return ( @@ -1045,7 +1072,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} /> @@ -1149,7 +1179,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..e6d35931 100644 --- a/frontend/src/contexts/GalleryAuthContext.tsx +++ b/frontend/src/contexts/GalleryAuthContext.tsx @@ -40,6 +40,8 @@ interface GalleryAuthContextType { event: GalleryEvent | null; accessLevel: GalleryAccessLevel; isClient: boolean; + /** Session was minted by the customer portal — credentialed, bypasses reveal. */ + viaCustomer: boolean; login: (slug: string, password?: string, recaptchaToken?: string | null) => Promise; clientLogin: (slug: string, password: string) => Promise; logout: () => void; @@ -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 17a796e9..0aa28ce3 100644 --- a/frontend/src/pages/GalleryPage.tsx +++ b/frontend/src/pages/GalleryPage.tsx @@ -419,14 +419,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