fix(admin): graceful logo-img fallback + show sidebar widgets during perm hydration (#523 follow-up 2)

Two issues Rekoo-PS hit immediately after upgrading to v3.60.3-beta.0:

1. **Broken logo URL rendered the browser's broken-image icon + alt
   text.** Their `<img src={resolvedLogoUrl}>` had no `onError` handler,
   so a 404 / slow logo URL produced the default broken-image rendering
   — which uses the `alt` attribute (`companyName`) as text. Visually it
   looked like the wordmark span had unexpectedly re-appeared on phone,
   even though the actual `<span>` was correctly hidden by the existing
   `wordmarkVisibilityClass` logic.

   Fix:
   - `useState` tracks `logoLoadError` (first failure) and
     `fallbackLoadError` (second failure). On a configured-URL miss the
     `<img>` swaps to the bundled `/picpeak-kamera-transparent.png`; on
     a second miss the `<img>` is removed from the DOM entirely.
   - `useEffect([resolvedLogoUrl])` resets both flags when the URL
     changes, so a dark-mode toggle that flips `lightLogo ↔ darkLogo`
     gets a fresh attempt instead of being permanently sad.
   - `wordmarkVisibilityClass` now derives from `logoEffectivelyVisible`
     (showLogo && !fallbackLoadError) — when both the configured URL
     AND the bundled fallback have failed, the wordmark un-hides on <sm
     so the phone header isn't completely empty.

2. **Sidebar VersionInfo + StorageInfo vanished during the
   permission-hydration window.** The bottom block was gated on
   `hasPermission('settings.view')` directly, which returns `false`
   while `PermissionsContext.isLoading` is still resolving (a few
   hundred ms right after a deploy when the auth context bootstraps).
   Net effect: the whole "Version / Storage" block was absent on first
   paint, then re-appeared once permissions hydrated — Rekoo-PS read
   that flash as "backend version + storage missing".

   Fix: gate on `permissionsLoading || hasPermission('settings.view')`.
   Optimistic render during hydration; permitted users see the widgets
   immediately (with each widget's own internal loading state), denied
   users still see nothing once the permission state lands as `false`.

Side benefit: the `<img>` fallback chain also covers the broader "logo
hosted on a flaky CDN" case for self-hosters, not just the one-time
post-upgrade asset-cache hiccup. Pure resilience polish — no behaviour
change when everything works.
This commit is contained in:
Paul Nothaft
2026-06-08 17:38:01 +02:00
parent b7fc86deef
commit f51b9cf8df
2 changed files with 53 additions and 7 deletions
+40 -4
View File
@@ -1,4 +1,4 @@
import React, { useState, useRef } from 'react';
import React, { useState, useRef, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { Menu, User, LogOut, Settings, Bell, Lock, CheckCircle, Trash2, Sun, Moon, Globe, ChevronDown } from 'lucide-react';
import { useTranslation } from 'react-i18next';
@@ -51,11 +51,31 @@ export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
? (logoUrl.startsWith('http') ? logoUrl : buildResourceUrl(logoUrl))
: '/picpeak-kamera-transparent.png';
// #523 follow-up 2: graceful fallback when the configured logo URL
// 404s or stalls. Without an error handler, the <img> failure draws
// the browser's default broken-image-icon + alt text rendering — see
// Rekoo-PS's 3.60.3-beta.0 screenshot where "Arkan Studio" appeared
// as the alt text of a broken icon, not the real wordmark span. The
// chain:
// 1. configured URL fails → try the bundled picpeak fallback
// 2. bundled fallback fails → hide the image entirely, let the
// wordmark carry the brand
// Reset on URL change so a dark-mode toggle (which can flip lightLogo
// ↔ darkLogo) retries the new URL instead of being permanently sad.
const [logoLoadError, setLogoLoadError] = useState(false);
const [fallbackLoadError, setFallbackLoadError] = useState(false);
useEffect(() => {
setLogoLoadError(false);
setFallbackLoadError(false);
}, [resolvedLogoUrl]);
const logoImgSrc = logoLoadError ? '/picpeak-kamera-transparent.png' : resolvedLogoUrl;
// Renders the logo + wordmark block per the current logo_display_mode.
// Re-used in left / center / right slots below so all three positions
// produce visually identical brand chrome.
const showLogo = !logoInSidebar && (logoDisplayMode === 'logo_only' || logoDisplayMode === 'logo_and_text');
const showText = logoDisplayMode === 'text_only' || logoDisplayMode === 'logo_and_text';
const logoEffectivelyVisible = showLogo && !fallbackLoadError;
// On <sm the wordmark hides when the logo carries the brand identity
// (logo_and_text). Same pattern LanguageSelector uses for its language
// name (#527). Without this, even with truncate, a phone-width admin
@@ -64,7 +84,12 @@ export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
// cluster it overlaps the LanguageSelector button (#523 follow-up,
// Rekoo-PS's "Arkan Studio" screenshot in v3.59.0-beta.0). text_only
// mode keeps the wordmark on every width — nothing else would render.
const wordmarkVisibilityClass = showLogo ? 'hidden sm:inline' : 'inline';
//
// #523 follow-up 2: when both the configured URL AND the bundled
// fallback have failed (fallbackLoadError → logoEffectivelyVisible
// false), unhide the wordmark on <sm too — otherwise the phone header
// shows nothing at all for the brand block.
const wordmarkVisibilityClass = logoEffectivelyVisible ? 'hidden sm:inline' : 'inline';
const renderBrandBlock = () => {
// Skeleton placeholder while `usePublicSettings()` is in flight (#523
// follow-up — Rekoo-PS's "logo took some time to load" screenshot in
@@ -88,8 +113,19 @@ export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
// action buttons on narrow mobile widths (#523 regression).
return (
<div className="flex items-center gap-2 min-w-0">
{showLogo && (
<img src={resolvedLogoUrl} alt={companyName} className="h-8 w-auto object-contain flex-shrink-0" />
{logoEffectivelyVisible && (
<img
src={logoImgSrc}
alt={companyName}
className="h-8 w-auto object-contain flex-shrink-0"
onError={() => {
// First failure: configured URL → try the bundled fallback.
// Second failure: bundled fallback → hide entirely, let
// the wordmark carry the brand (#523 follow-up 2).
if (!logoLoadError) setLogoLoadError(true);
else setFallbackLoadError(true);
}}
/>
)}
{showText && (
<span className={`${wordmarkVisibilityClass} text-xl sm:text-2xl truncate`} style={{ fontFamily: 'Poppins, sans-serif', fontWeight: 600, color: '#145346' }}>{companyName}</span>
+13 -3
View File
@@ -101,7 +101,7 @@ const navigation: NavItem[] = [
export const AdminSidebar: React.FC<AdminSidebarProps> = ({ isOpen, onClose, collapsed = false, onToggleCollapse }) => {
const location = useLocation();
const { t } = useTranslation();
const { hasPermission } = usePermissions();
const { hasPermission, isLoading: permissionsLoading } = usePermissions();
const { flags } = useFeatureFlags();
// Branding lookup for the "logo_position = sidepanel" mode — when
// chosen, the logo replaces the "PicPeak Admin" text in the brand
@@ -283,8 +283,18 @@ export const AdminSidebar: React.FC<AdminSidebarProps> = ({ isOpen, onClose, col
{/* Bottom section - sticky to bottom (only for users with settings.view permission).
Hidden on desktop when collapsed since these widgets don't fit in the icon rail;
mobile keeps them visible because mobile width is always w-64. */}
{hasPermission('settings.view') && (
mobile keeps them visible because mobile width is always w-64.
#523 follow-up 2: render OPTIMISTICALLY while permissions are
still hydrating from the auth context (Rekoo-PS's 3.60.3-beta.0
screenshot showed the whole bottom block missing on first paint
right after a deploy — `hasPermission` returns false during the
~hundreds-of-ms hydration window, the widgets vanish entirely,
then re-appear). Only HIDE the block when we definitively know
the user lacks the permission. VersionInfo + StorageInfo each
have their own loading states so admins see "—" / a spinner
instead of nothing during the actual data fetch. */}
{(permissionsLoading || hasPermission('settings.view')) && (
<div className={`flex-shrink-0 ${collapsed ? 'lg:hidden' : ''}`}>
{/* Version Info */}
<VersionInfo />