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:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -345,16 +345,53 @@ 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) {
|
||||
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
|
||||
return (
|
||||
<div className="min-h-screen" style={{ backgroundColor: 'var(--color-background, #fafafa)' }}>
|
||||
|
||||
Reference in New Issue
Block a user