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

Stable twin of #1152. The reporter is on v3.46.1, so this branch is where the bug was actually seen.

showLogout was hard-coded true, so a gallery with no password showed a Logout button, and clicking it stranded the visitor on the loading skeleton — GalleryPage's auto-login is a one-shot latch that never re-fires.

The button is gated at both call sites, including the full-page layouts which render it on the callback rather than a flag. accessLevel and viaCustomer now come from /auth/session instead of per-tab sessionStorage, which silently downgraded a PIN-client session in a second tab. The public-gallery branch shows a reason and a Retry once auto-login has run and failed, instead of a skeleton that never stops.

Carries the full main fix including viaCustomer, even though reveal mode does not exist here, so the branches do not drift.

Merged with admin privileges: the author cannot self-approve.
This commit is contained in:
Paul Nothaft
2026-08-23 22:07:05 +02:00
committed by GitHub
parent e46260ad07
commit d46397d92a
5 changed files with 154 additions and 10 deletions
@@ -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,
+12 -1
View File
@@ -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({
@@ -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<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);
@@ -705,6 +719,18 @@ 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, 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<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}
/>
@@ -823,7 +852,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
@@ -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<void>;
clientLogin: (slug: string, password: string) => Promise<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
@@ -345,14 +345,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