fix(gallery): no Logout button on galleries that don't require a password (#1149) (#1152)

showLogout was hard-coded true, so a gallery with no password showed a Logout button. Logging out of it is meaningless — no credential to drop, nothing to return to — and it stranded the visitor: GalleryPage's auto-login is a one-shot latch, so clearing the session left the page on its skeleton until a manual reload. That is the 'turns blank' in the report.

The button is gated on requiresPassword || isClient || viaCustomer at both call sites. The full-page layouts render it on the callback being present rather than on a flag, so withholding the callback is how the gate reaches them.

Session kind now comes from /auth/session rather than sessionStorage, which is per-tab while the cookie is per-browser: a gallery reopened in a second tab lost 'client' while the backend kept serving it as one. viaCustomer marks a portal token, which bypasses reveal mode and so is a credential that does not look like one.

The public-gallery branch no longer returns the skeleton unconditionally — once auto-login has run and left us unauthenticated it shows the reason and a Retry. That state was otherwise unrecoverable, and it also swallowed loginError entirely.

Merged with admin privileges: the author cannot self-approve.
This commit is contained in:
Paul Nothaft
2026-08-23 22:06:47 +02:00
committed by GitHub
parent b581267031
commit e4a8be8e7e
5 changed files with 156 additions and 9 deletions
@@ -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,
+11
View File
@@ -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.
@@ -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<GalleryViewProps> = ({ slug, event }) => {
export const GalleryView: React.FC<GalleryViewProps> = ({ 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<number | string | null>(null);
@@ -994,6 +1008,19 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ 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<GalleryViewProps> = ({ 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<GalleryViewProps> = ({ 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
+18 -1
View File
@@ -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<void>;
clientLogin: (slug: string, password: string) => Promise<void>;
logout: () => void;
@@ -65,6 +67,7 @@ export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ childr
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [event, setEvent] = useState<GalleryEvent | null>(null);
const [accessLevel, setAccessLevel] = useState<GalleryAccessLevel>('guest');
const [viaCustomer, setViaCustomer] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [routeError, setRouteError] = useState<string | null>(null);
@@ -204,13 +207,25 @@ export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ 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<GalleryAuthProviderProps> = ({ childr
setIsAuthenticated(false);
setEvent(null);
setAccessLevel('guest');
setViaCustomer(false);
clearActiveGallerySlug();
};
@@ -350,6 +366,7 @@ export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ childr
event,
accessLevel,
isClient: accessLevel === 'client',
viaCustomer,
login,
clientLogin: clientLoginFn,
logout,
+39 -2
View File
@@ -419,14 +419,51 @@ export const GalleryPage: React.FC = () => {
// Show gallery view if authenticated
if (isAuthenticated && event) {
return <GalleryView slug={gallerySlugForView} event={event} />;
return <GalleryView slug={gallerySlugForView} event={event} requiresPassword={requiresPassword} />;
}
// 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 <GallerySkeleton />;
if (!autoLoginAttempted || isLoggingIn) {
return <GallerySkeleton />;
}
// 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 (
<div className="min-h-screen flex items-center justify-center p-4"
style={{ backgroundColor: 'var(--color-background, #fafafa)' }}>
<Card className="w-full max-w-md">
<CardContent className="p-6 text-center">
<AlertCircle className="w-10 h-10 mx-auto mb-3 text-muted-theme" />
<p className="text-base mb-4">
{loginError || t('gallery.failedToLoad', 'Failed to load gallery')}
</p>
<Button
onClick={() => {
setLoginError(null);
setAutoLoginAttempted(false);
}}
>
{t('gallery.tryAgain', 'Try again')}
</Button>
</CardContent>
</Card>
</div>
);
}
// Show login form