fix(branding): hide "Powered by PicPeak" on every page, not only the gallery (#999)

branding_hide_powered_by only hid the attribution on the main gallery footer. It
stayed visible on the gallery password screen, client access page, Premium
layout, admin and customer login, accept-invite and CMS pages — AdminLoginPage
rendered it unconditionally with no guard at all, so the setting genuinely did
not apply there.

Routes those surfaces through one <PoweredBy /> component in components/common
that reads the public setting itself (the DynamicFavicon pattern) and renders
nothing when white-labeling is on, including while the settings are still
loading so a white-labelled instance never flashes the attribution.

Also collapses three duplicate translation keys (gallery.poweredBy,
adminLogin.poweredBy, customer.login.poweredBy) into a single common.poweredBy,
and translates pages that had 'Powered by' hardcoded in English across all 8
locales.

Fork-PR workflows were never approved so CI did not run. Verified locally
against cf243b44: tsc --noEmit clean, ESLint clean, vitest 124 passed across 24
files, and npm run build succeeds.

GalleryLayout.tsx keeps its own inline guard and is not routed through the new
component; tracked separately.

Co-authored-by: lbossuyt <[email protected]>
This commit is contained in:
Lutthy
2026-08-09 11:16:18 +02:00
committed by GitHub
co-authored by lbossuyt
parent 1f224f4ead
commit 3bb4f1a1a8
18 changed files with 131 additions and 71 deletions
@@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next';
import DOMPurify from 'dompurify';
import { Card } from './Card';
import { Loading } from './Loading';
import { PoweredBy } from './PoweredBy';
import { cmsService } from '../../services/cms.service';
import { usePublicSettings } from '../../hooks/usePublicSettings';
import { buildResourceUrl } from '../../utils/url';
@@ -132,11 +133,7 @@ export const CMSContentBlock: React.FC<CMSContentBlockProps> = ({ slug, fallback
{lang === 'de' ? 'Datenschutz' : 'Privacy Policy'}
</Link>
</div>
{!settings?.branding_hide_powered_by && (
<p className="mt-2">
Powered by <span className="font-semibold">PicPeak</span>
</p>
)}
<PoweredBy className="mt-2" />
</footer>
</div>
);
@@ -0,0 +1,25 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { usePublicSettings } from '../../hooks/usePublicSettings';
interface PoweredByProps {
className?: string;
style?: React.CSSProperties;
}
// "Powered by PicPeak" footer/login attribution. Reads the setting itself (like
// DynamicFavicon) so callers don't repeat the guard; hidden for white-label.
export const PoweredBy: React.FC<PoweredByProps> = ({ className, style }) => {
const { t } = useTranslation();
const { data: settings } = usePublicSettings();
// Hide while settings are still loading too, otherwise a white-labelled
// instance flashes the attribution before branding_hide_powered_by resolves.
if (!settings || settings.branding_hide_powered_by) return null;
return (
<p className={className} style={style}>
{t('common.poweredBy')} <span className="font-semibold">PicPeak</span>
</p>
);
};
@@ -0,0 +1,63 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import { vi, describe, it, expect, beforeEach } from 'vitest';
import { PoweredBy } from '../PoweredBy';
import { usePublicSettings } from '../../../hooks/usePublicSettings';
// stub i18n so the prefix comes back as plain English for the assertions
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => (key === 'common.poweredBy' ? 'Powered by' : key),
}),
}));
vi.mock('../../../hooks/usePublicSettings', () => ({
usePublicSettings: vi.fn(),
}));
const mockUsePublicSettings = vi.mocked(usePublicSettings);
const setSettings = (data: Record<string, unknown> | undefined) => {
mockUsePublicSettings.mockReturnValue({ data } as never);
};
describe('PoweredBy', () => {
beforeEach(() => {
mockUsePublicSettings.mockReset();
});
it('renders the "Powered by PicPeak" attribution by default', () => {
setSettings({});
render(<PoweredBy />);
expect(screen.getByText(/Powered by/)).toBeInTheDocument();
expect(screen.getByText('PicPeak')).toBeInTheDocument();
});
it('renders nothing while settings are still loading (no attribution flash)', () => {
setSettings(undefined);
const { container } = render(<PoweredBy />);
expect(container).toBeEmptyDOMElement();
expect(screen.queryByText('PicPeak')).not.toBeInTheDocument();
});
it('renders nothing when branding_hide_powered_by is enabled (white-label)', () => {
setSettings({ branding_hide_powered_by: true });
const { container } = render(<PoweredBy />);
expect(container).toBeEmptyDOMElement();
expect(screen.queryByText('PicPeak')).not.toBeInTheDocument();
});
it('renders when branding_hide_powered_by is explicitly false', () => {
setSettings({ branding_hide_powered_by: false });
render(<PoweredBy />);
expect(screen.getByText('PicPeak')).toBeInTheDocument();
});
it('forwards className and style to the wrapping paragraph', () => {
setSettings({});
render(<PoweredBy className="text-xs mt-2" style={{ opacity: 0.5 }} />);
const paragraph = screen.getByText('PicPeak').closest('p');
expect(paragraph).toHaveClass('text-xs', 'mt-2');
expect(paragraph).toHaveStyle({ opacity: '0.5' });
});
});
+1
View File
@@ -29,4 +29,5 @@ export { ProtectionWarning } from './ProtectionWarning';
export { ReCaptcha } from './ReCaptcha';
export { PasswordGenerator } from './PasswordGenerator';
export { MarkdownContent } from './MarkdownContent';
export { PoweredBy } from './PoweredBy';
export { ConfirmDialogProvider, useConfirm, type ConfirmOptions, type ConfirmVariant } from './ConfirmDialog';
@@ -18,7 +18,7 @@ import { useInView } from 'react-intersection-observer';
import type { BaseGalleryLayoutProps } from './BaseGalleryLayout';
import type { Photo } from '../../../types';
import { AuthenticatedImage } from '../../common';
import { AuthenticatedImage, PoweredBy } from '../../common';
import { feedbackService } from '../../../services/feedback.service';
import { PhotoReactions } from '../PhotoReactions';
import { useGuestIdentityOptional } from '../../../contexts/GuestIdentityContext';
@@ -598,7 +598,7 @@ export const GalleryPremiumLayout: React.FC<GalleryPremiumLayoutProps> = ({
{/* Footer */}
<footer className="gallery-premium-footer">
<p>{t('gallery.poweredBy', 'Powered by PicPeak')}</p>
<PoweredBy />
<p>© {new Date().getFullYear()} {t('gallery.allRightsReserved', 'All rights reserved')}</p>
</footer>
+4 -6
View File
@@ -152,7 +152,8 @@
"duplicate": "Duplizieren",
"showAll": "Alle anzeigen",
"confirm": "Bestätigen",
"show": "Einblenden"
"show": "Einblenden",
"poweredBy": "Bereitgestellt von"
},
"upload": {
"photoCategory": "Fotokategorie",
@@ -936,8 +937,7 @@
"socials": "Soziale Netzwerke"
},
"photosCount_one": "{{count}} Foto",
"photosCount_other": "{{count}} Fotos",
"poweredBy": "Bereitgestellt von PicPeak"
"photosCount_other": "{{count}} Fotos"
},
"categories": {
"title": "Fotokategorien",
@@ -3792,7 +3792,6 @@
"invalidCredentials": "Ungültige E-Mail oder Passwort",
"generalError": "Ein Fehler ist aufgetreten. Bitte versuchen Sie es erneut.",
"needHelp": "Hilfe benötigt? Kontakt",
"poweredBy": "Bereitgestellt von PicPeak",
"devModeHint": "Entwicklungsmodus: E-Mail: [email protected], Passwort: admin123",
"mfa": {
"title": "Zwei-Faktor-Authentifizierung",
@@ -3889,8 +3888,7 @@
"adminHint": "Sie suchen das Admin-Panel? Besuchen Sie /admin/login.",
"emailPlaceholder": "[email protected]",
"passwordPlaceholder": "Dein Passwort",
"needHelp": "Brauchst du Hilfe?",
"poweredBy": "Bereitgestellt von PicPeak"
"needHelp": "Brauchst du Hilfe?"
},
"acceptInvite": {
"title": "Konto einrichten",
+3 -5
View File
@@ -152,7 +152,8 @@
"duplicate": "Duplicate",
"showAll": "Show all",
"confirm": "Confirm",
"show": "Show"
"show": "Show",
"poweredBy": "Powered by"
},
"upload": {
"photoCategory": "Photo Category",
@@ -478,7 +479,6 @@
},
"photosCount_one": "{{count}} photo",
"photosCount_other": "{{count}} photos",
"poweredBy": "Powered by PicPeak",
"photosSelected_one": "{{count}} photo selected",
"photosSelected_other": "{{count}} photos selected",
"downloadSelected_one": "Download {{count}} photo",
@@ -3678,7 +3678,6 @@
"invalidCredentials": "Invalid email or password",
"generalError": "An error occurred. Please try again.",
"needHelp": "Need help? Contact",
"poweredBy": "Powered by PicPeak",
"devModeHint": "Development Mode: Use email: [email protected], password: admin123",
"mfa": {
"title": "Two-factor authentication",
@@ -3889,8 +3888,7 @@
"adminHint": "Looking for the admin panel? Visit /admin/login.",
"emailPlaceholder": "[email protected]",
"passwordPlaceholder": "Your password",
"needHelp": "Need help?",
"poweredBy": "Powered by PicPeak"
"needHelp": "Need help?"
},
"acceptInvite": {
"title": "Set up your account",
+2 -2
View File
@@ -119,7 +119,8 @@
"select": "Seleccionar",
"selected": "Seleccionado",
"chunk": "Fragmento",
"optional": "opcional"
"optional": "opcional",
"poweredBy": "Desarrollado por"
},
"upload": {
"photoCategory": "Categoría de foto",
@@ -2458,7 +2459,6 @@
"invalidCredentials": "Correo o contraseña inválidos",
"generalError": "Ocurrió un error. Por favor, inténtalo de nuevo.",
"needHelp": "¿Necesitas ayuda? Contacta",
"poweredBy": "Desarrollado por PicPeak",
"devModeHint": "Modo desarrollo: usa [email protected], contraseña: admin123"
},
"clientAccess": {
+3 -5
View File
@@ -124,7 +124,8 @@
"saveChanges": "Enregistrer les modifications",
"resetChanges": "Réinitialiser les modifications",
"dismiss": "Ignorer",
"discard": "Abandonner"
"discard": "Abandonner",
"poweredBy": "Propulsé par"
},
"upload": {
"photoCategory": "Catégorie de la photo",
@@ -334,7 +335,6 @@
},
"photosCount_one": "{{count}} photo",
"photosCount_other": "{{count}} photos",
"poweredBy": "Propulsé par PicPeak",
"photosSelected_one": "{{count}} photo sélectionnée",
"photosSelected_other": "{{count}} photos sélectionnées",
"downloadSelected_one": "Télécharger {{count}} photo",
@@ -2682,7 +2682,6 @@
"invalidCredentials": "E-mail ou mot de passe invalide",
"generalError": "Une erreur est survenue. Veuillez réessayer.",
"needHelp": "Besoin d'aide ? Contactez",
"poweredBy": "Propulsé par PicPeak",
"devModeHint": "Mode développement : Utilisez l'e-mail : [email protected], mot de passe : admin123"
},
"clientAccess": {
@@ -2762,8 +2761,7 @@
"adminHint": "Vous cherchez le panneau d'administration ? Visitez /admin/login.",
"emailPlaceholder": "[email protected]",
"passwordPlaceholder": "Votre mot de passe",
"needHelp": "Besoin d'aide ?",
"poweredBy": "Propulsé par PicPeak"
"needHelp": "Besoin d'aide ?"
},
"acceptInvite": {
"title": "Configurer votre compte",
+4 -6
View File
@@ -124,7 +124,8 @@
"saveChanges": "Wijzigingen opslaan",
"resetChanges": "Wijzigingen ongedaan maken",
"dismiss": "Sluiten",
"discard": "Verwerpen"
"discard": "Verwerpen",
"poweredBy": "Mogelijk gemaakt door"
},
"upload": {
"photoCategory": "Fotocategorie",
@@ -337,8 +338,7 @@
"socials": "Sociale media"
},
"photosCount_one": "{{count}} foto",
"photosCount_other": "{{count}} foto's",
"poweredBy": "Mogelijk gemaakt door PicPeak"
"photosCount_other": "{{count}} foto's"
},
"categories": {
"title": "Fotocategorieen",
@@ -2680,7 +2680,6 @@
"invalidCredentials": "Ongeldige e-mail of wachtwoord",
"generalError": "Er is een fout opgetreden. Probeer het opnieuw.",
"needHelp": "Hulp nodig? Neem contact op met",
"poweredBy": "Powered by PicPeak",
"devModeHint": "Ontwikkelmodus: Gebruik e-mail: [email protected], wachtwoord: admin123"
},
"clientAccess": {
@@ -2751,8 +2750,7 @@
"adminHint": "Op zoek naar het beheerderspaneel? Ga naar /admin/login.",
"emailPlaceholder": "[email protected]",
"passwordPlaceholder": "Je wachtwoord",
"needHelp": "Hulp nodig?",
"poweredBy": "Mogelijk gemaakt door PicPeak"
"needHelp": "Hulp nodig?"
},
"acceptInvite": {
"title": "Account aanmaken",
+4 -6
View File
@@ -124,7 +124,8 @@
"saveChanges": "Salvar alterações",
"resetChanges": "Repor alterações",
"dismiss": "Dispensar",
"discard": "Descartar"
"discard": "Descartar",
"poweredBy": "Desenvolvido por"
},
"upload": {
"photoCategory": "Categoria da Foto",
@@ -345,8 +346,7 @@
},
"photosCount_many": "{{count}} fotos",
"photosCount_one": "{{count}} foto",
"photosCount_other": "{{count}} fotos",
"poweredBy": "Desenvolvido por PicPeak"
"photosCount_other": "{{count}} fotos"
},
"categories": {
"title": "Categorias de Fotos",
@@ -2701,7 +2701,6 @@
"invalidCredentials": "E-mail ou senha inválidos",
"generalError": "Ocorreu um erro. Tente novamente.",
"needHelp": "Precisa de ajuda? Contate",
"poweredBy": "Distribuído por PicPeak",
"devModeHint": "Modo Desenvolvimento: E-mail: [email protected], Senha: admin123"
},
"clientAccess": {
@@ -2784,8 +2783,7 @@
"adminHint": "Procurando o painel de administração? Acesse /admin/login.",
"emailPlaceholder": "[email protected]",
"passwordPlaceholder": "Sua senha",
"needHelp": "Precisa de ajuda?",
"poweredBy": "Desenvolvido por PicPeak"
"needHelp": "Precisa de ajuda?"
},
"acceptInvite": {
"title": "Configure sua conta",
+4 -6
View File
@@ -124,7 +124,8 @@
"saveChanges": "Сохранить изменения",
"resetChanges": "Сбросить изменения",
"dismiss": "Закрыть",
"discard": "Отменить"
"discard": "Отменить",
"poweredBy": "Работает на"
},
"upload": {
"photoCategory": "Категория фото",
@@ -353,8 +354,7 @@
"photosCount_few": "{{count}} фото",
"photosCount_many": "{{count}} фото",
"photosCount_one": "{{count}} фото",
"photosCount_other": "{{count}} фото",
"poweredBy": "Работает на PicPeak"
"photosCount_other": "{{count}} фото"
},
"categories": {
"title": "Категории фото",
@@ -2731,7 +2731,6 @@
"invalidCredentials": "Неверный email или пароль",
"generalError": "Произошла ошибка. Попробуйте снова.",
"needHelp": "Нужна помощь? Свяжитесь",
"poweredBy": "Работает на PicPeak",
"devModeHint": "Режим разработки: используйте email: [email protected], пароль: admin123"
},
"clientAccess": {
@@ -2817,8 +2816,7 @@
"adminHint": "Ищете админ-панель? Перейдите на /admin/login.",
"emailPlaceholder": "[email protected]",
"passwordPlaceholder": "Ваш пароль",
"needHelp": "Нужна помощь?",
"poweredBy": "Работает на PicPeak"
"needHelp": "Нужна помощь?"
},
"acceptInvite": {
"title": "Создайте учётную запись",
+3 -5
View File
@@ -124,7 +124,8 @@
"saveChanges": "Shrani spremembe",
"resetChanges": "Ponastavi spremembe",
"dismiss": "Zapri",
"discard": "Zavrzi"
"discard": "Zavrzi",
"poweredBy": "Poganja"
},
"upload": {
"photoCategory": "Kategorija fotografije",
@@ -334,7 +335,6 @@
},
"photosCount_one": "{{count}} fotografija",
"photosCount_other": "{{count}} fotografij",
"poweredBy": "Poganja PicPeak",
"photosSelected_one": "Izbrana {{count}} fotografija",
"photosSelected_other": "Izbranih {{count}} fotografij",
"downloadSelected_one": "Prenesi {{count}} fotografijo",
@@ -2671,7 +2671,6 @@
"invalidCredentials": "Neveljavna e-pošta ali geslo",
"generalError": "Prišlo je do napake. Poskusite znova.",
"needHelp": "Potrebujete pomoč? Kontakt",
"poweredBy": "Poganja PicPeak",
"devModeHint": "Razvojni način: uporabite e-pošto: [email protected], geslo: admin123"
},
"clientAccess": {
@@ -2751,8 +2750,7 @@
"adminHint": "Iščete administratorsko ploščo? Obiščite /admin/login.",
"emailPlaceholder": "[email protected]",
"passwordPlaceholder": "Vaše geslo",
"needHelp": "Potrebujete pomoč?",
"poweredBy": "Poganja PicPeak"
"needHelp": "Potrebujete pomoč?"
},
"acceptInvite": {
"title": "Nastavite svoj račun",
+2 -4
View File
@@ -3,7 +3,7 @@ import { useParams, useSearchParams, useNavigate, Link } from 'react-router-dom'
import { AlertCircle, Lock } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Card, CardContent, Input, Button, Loading } from '../components/common';
import { Card, CardContent, Input, Button, Loading, PoweredBy } from '../components/common';
import { useGalleryAuth } from '../contexts';
import { useGalleryInfo } from '../hooks/useGallery';
import { usePublicSettings } from '../hooks/usePublicSettings';
@@ -197,9 +197,7 @@ export const ClientAccessPage: React.FC = () => {
{t('legal.datenschutz')}
</Link>
</div>
<p className="text-xs mt-2 text-neutral-500">
Powered by <span className="font-semibold">PicPeak</span>
</p>
<PoweredBy className="text-xs mt-2 text-neutral-500" />
</div>
</div>
</div>
+3 -7
View File
@@ -6,7 +6,7 @@ import { useTranslation } from 'react-i18next';
import { useLocalizedDate } from '../hooks/useLocalizedDate';
import { usePublicSettings } from '../hooks/usePublicSettings';
import { Card, CardContent, Input, Button, ReCaptcha, CMSContentBlock } from '../components/common';
import { Card, CardContent, Input, Button, ReCaptcha, CMSContentBlock, PoweredBy } from '../components/common';
import { useGalleryAuth, useTheme } from '../contexts';
import { useGalleryInfo } from '../hooks/useGallery';
import { GalleryView } from '../components/gallery';
@@ -386,9 +386,7 @@ export const GalleryPage: React.FC = () => {
{t('legal.datenschutz')}
</Link>
</div>
<p className="text-xs mt-2 text-neutral-500">
Powered by <span className="font-semibold">PicPeak</span>
</p>
<PoweredBy className="text-xs mt-2 text-neutral-500" />
</div>
</div>
</div>
@@ -594,9 +592,7 @@ export const GalleryPage: React.FC = () => {
{t('legal.datenschutz')}
</Link>
</div>
<p className="text-xs mt-2 text-neutral-500">
Powered by <span className="font-semibold">PicPeak</span>
</p>
<PoweredBy className="text-xs mt-2 text-neutral-500" />
</div>
</div>
</div>
+2 -4
View File
@@ -5,7 +5,7 @@ import { Lock, Mail, Eye, EyeOff, AlertCircle, ShieldCheck, KeyRound, ArrowLeft
import { toast } from 'react-toastify';
import { useTranslation } from 'react-i18next';
import { Button, Input, Card, ReCaptcha } from '../../components/common';
import { Button, Input, Card, ReCaptcha, PoweredBy } from '../../components/common';
import { useAdminAuth } from '../../contexts';
import { authService } from '../../services/auth.service';
import { isMfaChallenge } from '../../types';
@@ -486,9 +486,7 @@ export const AdminLoginPage: React.FC = () => {
{settingsData?.branding_support_email || '[email protected]'}
</a>
</p>
<p className="text-xs mt-2" style={{ color: 'var(--color-text, #171717)', opacity: 0.5 }}>
{t('adminLogin.poweredBy')}
</p>
<PoweredBy className="text-xs mt-2" style={{ color: 'var(--color-text, #171717)', opacity: 0.5 }} />
</div>
{/* Development Hint */}
@@ -10,7 +10,7 @@ import { Lock, Mail, Eye, EyeOff, AlertCircle } from 'lucide-react';
import { toast } from 'react-toastify';
import { useTranslation } from 'react-i18next';
import { Button, Input, Card, ReCaptcha } from '../../components/common';
import { Button, Input, Card, ReCaptcha, PoweredBy } from '../../components/common';
import { useCustomerAuth } from '../../contexts/CustomerAuthContext';
import { customerService } from '../../services/customer.service';
import { usePublicSettings } from '../../hooks/usePublicSettings';
@@ -261,9 +261,7 @@ export const CustomerLoginPage: React.FC = () => {
{settingsData?.branding_support_email || '[email protected]'}
</a>
</p>
<p className="text-xs mt-2" style={{ color: 'var(--color-text, #171717)', opacity: 0.5 }}>
{t('customer.login.poweredBy', 'Powered by PicPeak')}
</p>
<PoweredBy className="text-xs mt-2" style={{ color: 'var(--color-text, #171717)', opacity: 0.5 }} />
</div>
</div>
</div>
@@ -14,7 +14,7 @@ import {
} from 'lucide-react';
import { toast } from 'react-toastify';
import { Button, Input, Card, Loading } from '../../components/common';
import { Button, Input, Card, Loading, PoweredBy } from '../../components/common';
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
import { api } from '../../config/api';
@@ -537,9 +537,7 @@ export const AcceptInvitePage: React.FC = () => {
{t('acceptInvitation.signIn')}
</a>
</p>
<p className="text-xs mt-2" style={{ color: 'var(--color-text, #171717)', opacity: 0.5 }}>
{t('adminLogin.poweredBy')}
</p>
<PoweredBy className="text-xs mt-2" style={{ color: 'var(--color-text, #171717)', opacity: 0.5 }} />
</div>
</div>
</div>