feat(admin): in-app migration banner for the org rename (#669)

One-time banner shown at the top of the admin layout to surface the org
rename + GHCR registry change for operators who haven't read the release
notes. Sits right below the existing maintenance banner — same pattern.

## What it looks like

Blue, dismissible banner with a short body:

> PicPeak's image registry has moved
> Update your docker-compose.yml to pull from
> ghcr.io/picpeak/picpeak/{backend,frontend} — the old path is no longer
> being updated. [See migration notes]

The link goes to `docs/migration-to-org.md` on the new org repo.

## Design choices

- **No backend feature flag.** A hard-coded `MIGRATION_BANNER_ENABLED`
  constant in the component file (1 line) gates global display. After
  ~1 quarter, flip it to false (or drop the mount in `AdminLayout.tsx`)
  in a small follow-up PR. A backend `app_settings` row + Settings UI
  toggle would be overkill for a one-time migration event.

- **Per-admin dismissal via localStorage.** Key is `picpeak:migration-banner:v1`
  (versioned so a future "we've moved AGAIN" banner can show without
  inheriting earlier dismissal). Wrapped in try/catch so private-mode
  browsers + storage-quota-exceeded errors don't crash the layout.

- **EN + DE strings** under a new top-level `migrationBanner` namespace.
  Other locales (fr, nl, pt, ru) fall through to EN — `migrationBanner.*`
  keys aren't translated there yet, deliberate (per #669 the ops
  banner is operator-facing and admins reading EN/DE is the majority).

- **Reuses `common.dismiss`** for the close-button aria-label.

## Test plan

- [ ] Frontend `npm run build:check` passes (TS + build)
- [ ] Open admin dashboard in EN → banner shows at top, below header,
      above main content
- [ ] Switch to DE → banner shows German strings
- [ ] Click dismiss → banner hides, doesn't re-appear on hard refresh
- [ ] Clear localStorage `picpeak:migration-banner:v1` → banner returns
- [ ] Flip `MIGRATION_BANNER_ENABLED` to false → banner doesn't render
      for anyone, regardless of dismissal state

Refs #669.
This commit is contained in:
Paul Nothaft
2026-06-29 22:19:22 +02:00
parent 448da95020
commit 2a4bf3b868
4 changed files with 85 additions and 0 deletions
@@ -7,6 +7,7 @@ import { useSessionTimeout } from '../../hooks/useSessionTimeout';
import { AdminSidebar } from './AdminSidebar'; import { AdminSidebar } from './AdminSidebar';
import { AdminHeader } from './AdminHeader'; import { AdminHeader } from './AdminHeader';
import { MaintenanceBanner } from './MaintenanceBanner'; import { MaintenanceBanner } from './MaintenanceBanner';
import { MigrationBanner } from './MigrationBanner';
import { MandatoryPasswordChangeModal } from './MandatoryPasswordChangeModal'; import { MandatoryPasswordChangeModal } from './MandatoryPasswordChangeModal';
const SIDEBAR_COLLAPSED_KEY = 'admin-sidebar-collapsed'; const SIDEBAR_COLLAPSED_KEY = 'admin-sidebar-collapsed';
@@ -114,6 +115,11 @@ const AdminLayoutInner: React.FC<AdminLayoutInnerProps> = ({ sidebarOpen, setSid
{/* Maintenance mode banner */} {/* Maintenance mode banner */}
<MaintenanceBanner /> <MaintenanceBanner />
{/* One-time migration banner — flip the constant in MigrationBanner.tsx
(or remove this mount) after operators have had time to update their
docker-compose.yml. See #669. */}
<MigrationBanner />
{/* Page content - disabled when password change required. {/* Page content - disabled when password change required.
overflow moved up to the column so the scrollbar gutter is overflow moved up to the column so the scrollbar gutter is
reserved once at the column level (see above). main now reserved once at the column level (see above). main now
@@ -0,0 +1,69 @@
import React from 'react';
import { Info, X } from 'lucide-react';
import { useTranslation } from 'react-i18next';
// Hard-coded "feature flag" — flip to false (or remove this component from
// AdminLayout) once the migration window settles, after operators have had
// ~1 quarter to update their docker-compose.yml. See #669.
const MIGRATION_BANNER_ENABLED = true;
// localStorage key — versioned (`:v1`) so a future "we've moved again" banner
// can show without inheriting the user's earlier dismissal.
const DISMISS_KEY = 'picpeak:migration-banner:v1';
/**
* One-time migration banner shown at the top of the admin layout (#669).
*
* Surfaces the org rename + new GHCR registry path so an operator who hasn't
* read the release notes sees the change when they next log in. Dismissible
* per-admin via localStorage; the toggle above can flip it off globally.
*/
export const MigrationBanner: React.FC = () => {
const { t } = useTranslation();
const [dismissed, setDismissed] = React.useState<boolean>(() => {
try { return localStorage.getItem(DISMISS_KEY) === '1'; } catch { return false; }
});
if (!MIGRATION_BANNER_ENABLED || dismissed) return null;
const handleDismiss = () => {
try { localStorage.setItem(DISMISS_KEY, '1'); } catch { /* localStorage blocked */ }
setDismissed(true);
};
return (
<div className="bg-blue-50 dark:bg-blue-900/20 border-b border-blue-200 dark:border-blue-800">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex items-start justify-between gap-3 py-3">
<div className="flex items-start gap-3 min-w-0">
<Info className="w-5 h-5 text-blue-600 dark:text-blue-300 flex-shrink-0 mt-0.5" />
<div className="text-sm text-blue-900 dark:text-blue-100 min-w-0">
<p className="font-medium">{t('migrationBanner.title', "PicPeak's image registry has moved")}</p>
<p className="text-blue-800 dark:text-blue-200 mt-0.5">
{t('migrationBanner.body', {
defaultValue: 'Update your docker-compose.yml to pull from {{newPath}} — the old path is no longer being updated.',
newPath: 'ghcr.io/picpeak/picpeak/{backend,frontend}',
})}{' '}
<a
href="https://github.com/PicPeak/picpeak/blob/main/docs/migration-to-org.md"
target="_blank"
rel="noreferrer"
className="underline hover:no-underline"
>
{t('migrationBanner.link', 'See migration notes')}
</a>
</p>
</div>
</div>
<button
onClick={handleDismiss}
className="text-blue-600 dark:text-blue-300 hover:text-blue-700 dark:hover:text-blue-200 flex-shrink-0"
aria-label={t('common.dismiss', 'Dismiss')}
>
<X className="w-5 h-5" />
</button>
</div>
</div>
</div>
);
};
+5
View File
@@ -3065,6 +3065,11 @@
"message": "Wir führen derzeit geplante Wartungsarbeiten durch, um unseren Service zu verbessern. Wir sind in Kürze wieder online.", "message": "Wir führen derzeit geplante Wartungsarbeiten durch, um unseren Service zu verbessern. Wir sind in Kürze wieder online.",
"urgentMatters": "Bei dringenden Anliegen kontaktieren Sie bitte" "urgentMatters": "Bei dringenden Anliegen kontaktieren Sie bitte"
}, },
"migrationBanner": {
"title": "Die Image-Registry von PicPeak wurde verschoben",
"body": "Aktualisiere deine docker-compose.yml, um Images von {{newPath}} zu beziehen — der alte Pfad wird nicht mehr aktualisiert.",
"link": "Migrationshinweise ansehen"
},
"passwordChange": { "passwordChange": {
"title": "Passwort ändern", "title": "Passwort ändern",
"currentPassword": "Aktuelles Passwort", "currentPassword": "Aktuelles Passwort",
+5
View File
@@ -3086,6 +3086,11 @@
"message": "We're currently performing scheduled maintenance to improve our service. We'll be back online shortly.", "message": "We're currently performing scheduled maintenance to improve our service. We'll be back online shortly.",
"urgentMatters": "For urgent matters, please contact" "urgentMatters": "For urgent matters, please contact"
}, },
"migrationBanner": {
"title": "PicPeak's image registry has moved",
"body": "Update your docker-compose.yml to pull from {{newPath}} — the old path is no longer being updated.",
"link": "See migration notes"
},
"passwordChange": { "passwordChange": {
"title": "Change Password", "title": "Change Password",
"currentPassword": "Current Password", "currentPassword": "Current Password",