diff --git a/backend/__tests__/routes/adminEvents.smoke.test.js b/backend/__tests__/routes/adminEvents.smoke.test.js index 728ceb9e..2a1fa7b6 100644 --- a/backend/__tests__/routes/adminEvents.smoke.test.js +++ b/backend/__tests__/routes/adminEvents.smoke.test.js @@ -201,6 +201,34 @@ describe('admin events CRUD endpoints (smoke)', () => { }); expect(res.status).toBe(400); }); + + // #894 — per-event password-page logo toggle: false hides, null + // restores the default (show). + it('stores login_logo_visible: false and clears it back to NULL', async () => { + const id = await insertEvent(db, adminId); + const hide = await auth(request(app).put(`/api/admin/events/${id}`)).send({ + login_logo_visible: false, + }); + expect(hide.status).toBe(200); + let row = await db('events').where({ id }).first(); + expect([false, 0]).toContain(row.login_logo_visible); + + const clear = await auth(request(app).put(`/api/admin/events/${id}`)).send({ + login_logo_visible: null, + }); + expect(clear.status).toBe(200); + row = await db('events').where({ id }).first(); + expect(row.login_logo_visible).toBeNull(); + + // The string "false" passes isBoolean() validation — it must be + // parsed, not treated as a truthy string (would store 1 = show). + const hideStr = await auth(request(app).put(`/api/admin/events/${id}`)).send({ + login_logo_visible: 'false', + }); + expect(hideStr.status).toBe(200); + row = await db('events').where({ id }).first(); + expect([false, 0]).toContain(row.login_logo_visible); + }); }); describe('DELETE /:id', () => { diff --git a/backend/migrations/core/166_add_login_logo_visible.js b/backend/migrations/core/166_add_login_logo_visible.js new file mode 100644 index 00000000..4b7b3c42 --- /dev/null +++ b/backend/migrations/core/166_add_login_logo_visible.js @@ -0,0 +1,21 @@ +/** + * Migration 166: per-event toggle to hide the branding logo on the + * gallery password page (#894). + * + * NULL (the default) keeps today's behaviour — the global branding logo is + * shown above the password form. Only an explicit `false` hides it for + * that gallery; the admin login page and other surfaces are unaffected. + */ +exports.up = async function (knex) { + if (await knex.schema.hasColumn('events', 'login_logo_visible')) return; + await knex.schema.alterTable('events', (t) => { + t.boolean('login_logo_visible').nullable(); + }); +}; + +exports.down = async function (knex) { + if (!(await knex.schema.hasColumn('events', 'login_logo_visible'))) return; + await knex.schema.alterTable('events', (t) => { + t.dropColumn('login_logo_visible'); + }); +}; diff --git a/backend/src/routes/adminEvents/crud.js b/backend/src/routes/adminEvents/crud.js index 5af5daeb..c1cfcfa2 100644 --- a/backend/src/routes/adminEvents/crud.js +++ b/backend/src/routes/adminEvents/crud.js @@ -1090,6 +1090,7 @@ module.exports = (router) => { hero_logo_visible: source.hero_logo_visible, hero_logo_size: source.hero_logo_size, hero_logo_position: source.hero_logo_position, + login_logo_visible: source.login_logo_visible, header_style: source.header_style || 'standard', hero_divider_style: source.hero_divider_style || 'wave', hero_image_anchor: source.hero_image_anchor || 'center', @@ -1237,6 +1238,8 @@ module.exports = (router) => { body('hero_logo_visible').optional({ nullable: true }).isBoolean(), body('hero_logo_size').optional({ nullable: true }).isIn(['small', 'medium', 'large', 'xlarge']), body('hero_logo_position').optional().isIn(['top', 'center', 'bottom']), + // Password-page logo toggle (#894). null = default (show). + body('login_logo_visible').optional({ nullable: true }).isBoolean(), // Header style settings (decoupled from layout) body('header_style').optional().isIn(['hero', 'standard', 'banner', 'minimal', 'none']), body('hero_divider_style').optional().isIn(['wave', 'straight', 'angle', 'curve', 'none']), @@ -1452,6 +1455,16 @@ module.exports = (router) => { : formatBoolean(updates.hero_logo_visible); } + // Password-page logo toggle (#894): null passes through; other + // accepted representations ("false", 0, …) are parsed before the + // DB formatting — formatBoolean alone would store the truthy + // string "false" as 1. + if (Object.prototype.hasOwnProperty.call(updates, 'login_logo_visible')) { + updates.login_logo_visible = updates.login_logo_visible === null + ? null + : formatBoolean(parseBooleanInput(updates.login_logo_visible, true)); + } + // Per-event opt-in for hero-photo OG share image (#474). Coerce so // SQLite stores 0/1 and Postgres stores boolean true/false. if (Object.prototype.hasOwnProperty.call(updates, 'og_image_share_enabled')) { diff --git a/backend/src/routes/gallery.js b/backend/src/routes/gallery.js index b9ab961a..2b08a304 100644 --- a/backend/src/routes/gallery.js +++ b/backend/src/routes/gallery.js @@ -221,6 +221,7 @@ router.get('/:slug/info', async (req, res) => { 'hero_logo_size', 'hero_logo_position', 'hero_logo_url', + 'login_logo_visible', 'header_style', 'hero_divider_style', 'hero_image_anchor', @@ -290,6 +291,9 @@ router.get('/:slug/info', async (req, res) => { enable_devtools_protection: event.enable_devtools_protection === true || event.enable_devtools_protection === 1 || event.enable_devtools_protection === '1', use_canvas_rendering: event.use_canvas_rendering === true || event.use_canvas_rendering === 1 || event.use_canvas_rendering === '1', hero_logo_visible: resolveHeroLogoVisible(event.hero_logo_visible, globalHeroLogoVisible), + // #894: only an explicit false hides the logo on the password page; + // NULL keeps the default (show). + login_logo_visible: !(event.login_logo_visible === false || event.login_logo_visible === 0 || event.login_logo_visible === '0'), // #756: NULL per-event size inherits the global branding_logo_size. hero_logo_size: event.hero_logo_size || globalLogoSize || 'medium', hero_logo_position: event.hero_logo_position || 'top', diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index 97367792..2426a450 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -1149,6 +1149,9 @@ "heroLogoInherit": "Branding-Standard verwenden", "heroLogoShow": "Immer anzeigen", "heroLogoHide": "Immer ausblenden", + "loginLogoVisible": "Logo auf der Passwort-Seite anzeigen", + "loginLogoShow": "Anzeigen (Standard)", + "loginLogoHide": "Ausblenden", "heroLogoSize": "Logo-Größe", "heroLogoSizeSmall": "Klein", "heroLogoSizeMedium": "Mittel", diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 22661f2d..874c604f 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -692,6 +692,9 @@ "heroLogoInherit": "Use branding default", "heroLogoShow": "Always show", "heroLogoHide": "Always hide", + "loginLogoVisible": "Display logo on password page", + "loginLogoShow": "Show (default)", + "loginLogoHide": "Hide", "heroLogoSize": "Logo Size", "heroLogoSizeSmall": "Small", "heroLogoSizeMedium": "Medium", diff --git a/frontend/src/pages/ClientAccessPage.tsx b/frontend/src/pages/ClientAccessPage.tsx index f27e0530..281dd64d 100644 --- a/frontend/src/pages/ClientAccessPage.tsx +++ b/frontend/src/pages/ClientAccessPage.tsx @@ -110,8 +110,8 @@ export const ClientAccessPage: React.FC = () => { return (
{t('events.heroLogoInfo', 'These settings apply when the gallery uses the Hero layout. You can hide the logo or customize its size and position.')}
diff --git a/frontend/src/pages/admin/event-details/types.ts b/frontend/src/pages/admin/event-details/types.ts index 71dec4f7..d97743ce 100644 --- a/frontend/src/pages/admin/event-details/types.ts +++ b/frontend/src/pages/admin/event-details/types.ts @@ -31,6 +31,8 @@ export type EditFormState = { hero_logo_visible: boolean | null; hero_logo_size: 'small' | 'medium' | 'large' | 'xlarge' | null; hero_logo_position: 'top' | 'center' | 'bottom'; + // #894: null = default (show); false hides the logo on the password page. + login_logo_visible: boolean | null; // Hero image anchor position (#162) – keyword or "X% Y%" focal point hero_image_anchor: string; // Photo cap @@ -81,6 +83,7 @@ export const INITIAL_EDIT_FORM: EditFormState = { hero_logo_visible: null, hero_logo_size: null, hero_logo_position: 'top', + login_logo_visible: null, // Hero image anchor position (#162) hero_image_anchor: 'center', // Photo cap diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index f0715abf..dc16da4d 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -51,6 +51,9 @@ export interface Event { hero_logo_size?: 'small' | 'medium' | 'large' | 'xlarge' | null; hero_logo_position?: 'top' | 'center' | 'bottom'; hero_logo_url?: string | null; + // Hide the branding logo on the gallery password page (#894). + // null = default (show); only an explicit false hides it. + login_logo_visible?: boolean | null; // Per-event opt-in for using the hero photo as the social-share // preview image (#474). When false, og:image falls back to the // brand logo. Defaults false on existing rows so no admin's hero @@ -105,6 +108,9 @@ export interface GalleryInfo { requires_password?: boolean; color_theme?: string; default_photo_sort?: string; + // Resolved server-side (#894): false only when the admin hid the logo + // on this gallery's password page. + login_logo_visible?: boolean; } export interface Photo { @@ -198,6 +204,9 @@ export interface GalleryData { hero_logo_size?: 'small' | 'medium' | 'large' | 'xlarge' | null; hero_logo_position?: 'top' | 'center' | 'bottom'; hero_logo_url?: string | null; + // Resolved server-side (#894): false only when the admin hid the + // logo on this gallery's password page. + login_logo_visible?: boolean; // Header style settings (decoupled from layout) header_style?: 'hero' | 'standard' | 'minimal' | 'none'; hero_divider_style?: 'wave' | 'straight' | 'angle' | 'curve' | 'none';