Sfoglia il codice sorgente

Let a super admin post a notice to everyone at once (#261)

* Let a super admin post a notice to everyone at once

Settings → the admin panel now carries a notice that appears at the top of
every page in the app, for the infrastructure incidents where everybody needs
to hear the same thing and nobody can work around it. Three severities, its
own Post and Clear buttons rather than the page's single Save, since the one
setting here that gets touched mid-incident should not need a scroll past the
mail configuration to switch off. The card previews the real banner.

No migration. It rides on the existing SystemSetting table, and the reader
returns nothing rather than throwing if the database cannot be reached, since
it sits in the root layout and a missing notice must not take every page with
it.

Never shown to a workshop's own customers. The root layout also wraps the
invoice, quote and portal pages they open, and those carry the workshop's
branding, not ours. A white-label licence exists precisely so Torqvoice stays
off that paperwork, and a message aimed at staff is not their business either.

Pushed over the existing socket, on its own fan-out that ignores organization
because an outage is not one workshop's business. Its own connection, because
the notification socket is mounted for owners and admins only and this has to
reach the technician in the bay.

One strip at a time, app-wide. These bars were rendered independently in three
layouts, so a notice plus a new-version note pushed the app down by two at
once, and nothing stopped a fourth. They queue by priority now, and dismissing
the winner lets the next through rather than losing it.

Dismissals are per browser, keyed on when the notice last changed, so editing
it brings it back for everyone including whoever waved the last one away. The
timestamp is stamped server-side, never accepted from the caller: a client
that could set it could hide a live incident.

* Keep the notice up after leaving the settings page

The admin card previews the notice by rendering the real banner, so two of
them were mounted while that page was open. The queue keyed claims by banner
id, so both wrote the same entry and the preview's unmount released the one
the page banner was relying on. The page banner's effect had no reason to run
again, so the notice stayed gone until the next full load.

Claims are keyed per instance now, which makes the queue correct for any
banner mounted twice rather than only for this one.

The preview also had no business competing for a page-level slot. It takes no
part in the queue, shows what it is handed rather than what the socket last
said, and ignores what this browser has dismissed, since an admin writing a
notice needs to see it even if they closed the previous one.
Bernt Christian Egeland 1 mese fa
parent
commit
380c5e697d

+ 15 - 0
messages/de/common.json

@@ -80,5 +80,20 @@
     "installDescription": "Zum Home-Bildschirm hinzufügen für schnellen Zugriff",
     "installDescription": "Zum Home-Bildschirm hinzufügen für schnellen Zugriff",
     "enterFullscreen": "Vollbild",
     "enterFullscreen": "Vollbild",
     "exitFullscreen": "Vollbild beenden"
     "exitFullscreen": "Vollbild beenden"
+  },
+  "broadcast": {
+    "dismiss": "Schließen",
+    "title": "Hinweis an alle",
+    "description": "Wird oben auf jeder Seite angezeigt, für alle, ob angemeldet oder nicht. Leer lassen für keinen Hinweis.",
+    "message": "Nachricht",
+    "level": "Dringlichkeit",
+    "post": "Hinweis veröffentlichen",
+    "clear": "Hinweis entfernen",
+    "levelInfo": "Information",
+    "levelWarning": "Warnung",
+    "levelCritical": "Kritisch",
+    "placeholder": "z. B. Wir haben Probleme mit unserer Serverinfrastruktur und arbeiten daran.",
+    "posted": "Hinweis veröffentlicht",
+    "cleared": "Hinweis entfernt"
   }
   }
 }
 }

+ 15 - 0
messages/en/common.json

@@ -80,5 +80,20 @@
     "installDescription": "Add to your home screen for quick access",
     "installDescription": "Add to your home screen for quick access",
     "enterFullscreen": "Fullscreen",
     "enterFullscreen": "Fullscreen",
     "exitFullscreen": "Exit fullscreen"
     "exitFullscreen": "Exit fullscreen"
+  },
+  "broadcast": {
+    "dismiss": "Dismiss",
+    "title": "Notice to everyone",
+    "description": "Shown at the top of every page, for everyone, signed in or not. Leave it empty for no notice.",
+    "message": "Message",
+    "level": "Severity",
+    "post": "Post notice",
+    "clear": "Clear notice",
+    "levelInfo": "Information",
+    "levelWarning": "Warning",
+    "levelCritical": "Critical",
+    "placeholder": "e.g. We are having trouble with our server infrastructure and are working on it.",
+    "posted": "Notice posted",
+    "cleared": "Notice cleared"
   }
   }
 }
 }

+ 15 - 0
messages/es/common.json

@@ -80,5 +80,20 @@
     "installDescription": "Añádela a tu pantalla de inicio para acceder rápido",
     "installDescription": "Añádela a tu pantalla de inicio para acceder rápido",
     "enterFullscreen": "Pantalla completa",
     "enterFullscreen": "Pantalla completa",
     "exitFullscreen": "Salir de pantalla completa"
     "exitFullscreen": "Salir de pantalla completa"
+  },
+  "broadcast": {
+    "dismiss": "Cerrar",
+    "title": "Aviso para todos",
+    "description": "Se muestra en la parte superior de cada página, para todos, con sesión iniciada o no. Déjalo vacío para no mostrar nada.",
+    "message": "Mensaje",
+    "level": "Gravedad",
+    "post": "Publicar aviso",
+    "clear": "Quitar aviso",
+    "levelInfo": "Información",
+    "levelWarning": "Advertencia",
+    "levelCritical": "Crítico",
+    "placeholder": "p. ej. Tenemos problemas con nuestra infraestructura de servidores y estamos trabajando en ello.",
+    "posted": "Aviso publicado",
+    "cleared": "Aviso retirado"
   }
   }
 }
 }

+ 15 - 0
messages/fr/common.json

@@ -80,5 +80,20 @@
     "installDescription": "Ajoutez-la à votre écran d'accueil pour un accès rapide",
     "installDescription": "Ajoutez-la à votre écran d'accueil pour un accès rapide",
     "enterFullscreen": "Plein écran",
     "enterFullscreen": "Plein écran",
     "exitFullscreen": "Quitter le plein écran"
     "exitFullscreen": "Quitter le plein écran"
+  },
+  "broadcast": {
+    "dismiss": "Fermer",
+    "title": "Message à tous",
+    "description": "Affiché en haut de chaque page, pour tout le monde, connecté ou non. Laissez vide pour ne rien afficher.",
+    "message": "Message",
+    "level": "Gravité",
+    "post": "Publier le message",
+    "clear": "Retirer le message",
+    "levelInfo": "Information",
+    "levelWarning": "Avertissement",
+    "levelCritical": "Critique",
+    "placeholder": "ex. Nous rencontrons des difficultés avec notre infrastructure serveur et y travaillons.",
+    "posted": "Message publié",
+    "cleared": "Message retiré"
   }
   }
 }
 }

+ 15 - 0
messages/it/common.json

@@ -80,5 +80,20 @@
     "installDescription": "Aggiungila alla schermata Home per un accesso rapido",
     "installDescription": "Aggiungila alla schermata Home per un accesso rapido",
     "enterFullscreen": "Schermo intero",
     "enterFullscreen": "Schermo intero",
     "exitFullscreen": "Esci da schermo intero"
     "exitFullscreen": "Esci da schermo intero"
+  },
+  "broadcast": {
+    "dismiss": "Chiudi",
+    "title": "Avviso a tutti",
+    "description": "Mostrato in cima a ogni pagina, per tutti, con o senza accesso. Lascia vuoto per non mostrare nulla.",
+    "message": "Messaggio",
+    "level": "Gravità",
+    "post": "Pubblica avviso",
+    "clear": "Rimuovi avviso",
+    "levelInfo": "Informazione",
+    "levelWarning": "Avvertenza",
+    "levelCritical": "Critico",
+    "placeholder": "es. Stiamo riscontrando problemi con la nostra infrastruttura server e ci stiamo lavorando.",
+    "posted": "Avviso pubblicato",
+    "cleared": "Avviso rimosso"
   }
   }
 }
 }

+ 15 - 0
messages/lt/common.json

@@ -80,5 +80,20 @@
     "installDescription": "Įtraukite į pradžios ekraną, kad pasiektumėte greičiau",
     "installDescription": "Įtraukite į pradžios ekraną, kad pasiektumėte greičiau",
     "enterFullscreen": "Visas ekranas",
     "enterFullscreen": "Visas ekranas",
     "exitFullscreen": "Išjungti visą ekraną"
     "exitFullscreen": "Išjungti visą ekraną"
+  },
+  "broadcast": {
+    "dismiss": "Uždaryti",
+    "title": "Pranešimas visiems",
+    "description": "Rodomas kiekvieno puslapio viršuje visiems, prisijungusiems ar ne. Palikite tuščią, jei pranešimo nereikia.",
+    "message": "Pranešimas",
+    "level": "Svarbumas",
+    "post": "Paskelbti pranešimą",
+    "clear": "Pašalinti pranešimą",
+    "levelInfo": "Informacija",
+    "levelWarning": "Įspėjimas",
+    "levelCritical": "Kritinis",
+    "placeholder": "pvz. Turime serverių infrastruktūros problemų ir jas sprendžiame.",
+    "posted": "Pranešimas paskelbtas",
+    "cleared": "Pranešimas pašalintas"
   }
   }
 }
 }

+ 15 - 0
messages/nb/common.json

@@ -80,5 +80,20 @@
     "installDescription": "Legg den til på hjem-skjermen for rask tilgang",
     "installDescription": "Legg den til på hjem-skjermen for rask tilgang",
     "enterFullscreen": "Fullskjerm",
     "enterFullscreen": "Fullskjerm",
     "exitFullscreen": "Avslutt fullskjerm"
     "exitFullscreen": "Avslutt fullskjerm"
+  },
+  "broadcast": {
+    "dismiss": "Lukk",
+    "title": "Melding til alle",
+    "description": "Vises øverst på hver side, for alle, innlogget eller ikke. La den stå tom for ingen melding.",
+    "message": "Melding",
+    "level": "Alvorlighet",
+    "post": "Publiser melding",
+    "clear": "Fjern melding",
+    "levelInfo": "Informasjon",
+    "levelWarning": "Advarsel",
+    "levelCritical": "Kritisk",
+    "placeholder": "f.eks. Vi har problemer med serverinfrastrukturen vår og jobber med saken.",
+    "posted": "Meldingen er publisert",
+    "cleared": "Meldingen er fjernet"
   }
   }
 }
 }

+ 15 - 0
messages/nl/common.json

@@ -80,5 +80,20 @@
     "installDescription": "Zet de app op je beginscherm voor snelle toegang",
     "installDescription": "Zet de app op je beginscherm voor snelle toegang",
     "enterFullscreen": "Volledig scherm",
     "enterFullscreen": "Volledig scherm",
     "exitFullscreen": "Volledig scherm afsluiten"
     "exitFullscreen": "Volledig scherm afsluiten"
+  },
+  "broadcast": {
+    "dismiss": "Sluiten",
+    "title": "Bericht aan iedereen",
+    "description": "Wordt bovenaan elke pagina getoond, aan iedereen, ingelogd of niet. Laat leeg voor geen bericht.",
+    "message": "Bericht",
+    "level": "Ernst",
+    "post": "Bericht plaatsen",
+    "clear": "Bericht verwijderen",
+    "levelInfo": "Informatie",
+    "levelWarning": "Waarschuwing",
+    "levelCritical": "Kritiek",
+    "placeholder": "bijv. We hebben problemen met onze serverinfrastructuur en werken eraan.",
+    "posted": "Bericht geplaatst",
+    "cleared": "Bericht verwijderd"
   }
   }
 }
 }

+ 15 - 0
messages/pl/common.json

@@ -80,5 +80,20 @@
     "installDescription": "Dodaj do ekranu głównego, aby mieć szybki dostęp",
     "installDescription": "Dodaj do ekranu głównego, aby mieć szybki dostęp",
     "enterFullscreen": "Pełny ekran",
     "enterFullscreen": "Pełny ekran",
     "exitFullscreen": "Zamknij pełny ekran"
     "exitFullscreen": "Zamknij pełny ekran"
+  },
+  "broadcast": {
+    "dismiss": "Zamknij",
+    "title": "Komunikat dla wszystkich",
+    "description": "Wyświetlany na górze każdej strony, wszystkim, zalogowanym i nie. Zostaw puste, aby nic nie pokazywać.",
+    "message": "Komunikat",
+    "level": "Waga",
+    "post": "Opublikuj komunikat",
+    "clear": "Usuń komunikat",
+    "levelInfo": "Informacja",
+    "levelWarning": "Ostrzeżenie",
+    "levelCritical": "Krytyczny",
+    "placeholder": "np. Mamy problemy z infrastrukturą serwerową i pracujemy nad nimi.",
+    "posted": "Komunikat opublikowany",
+    "cleared": "Komunikat usunięty"
   }
   }
 }
 }

+ 15 - 0
messages/pt-BR/common.json

@@ -80,5 +80,20 @@
     "installDescription": "Adicione à tela de início para acesso rápido",
     "installDescription": "Adicione à tela de início para acesso rápido",
     "enterFullscreen": "Tela cheia",
     "enterFullscreen": "Tela cheia",
     "exitFullscreen": "Sair da tela cheia"
     "exitFullscreen": "Sair da tela cheia"
+  },
+  "broadcast": {
+    "dismiss": "Fechar",
+    "title": "Aviso para todos",
+    "description": "Exibido no topo de cada página, para todos, conectados ou não. Deixe vazio para não exibir nada.",
+    "message": "Mensagem",
+    "level": "Gravidade",
+    "post": "Publicar aviso",
+    "clear": "Remover aviso",
+    "levelInfo": "Informação",
+    "levelWarning": "Alerta",
+    "levelCritical": "Crítico",
+    "placeholder": "ex. Estamos com problemas na nossa infraestrutura de servidores e já estamos trabalhando nisso.",
+    "posted": "Aviso publicado",
+    "cleared": "Aviso removido"
   }
   }
 }
 }

+ 15 - 0
messages/ru/common.json

@@ -80,5 +80,20 @@
     "installDescription": "Добавьте на главный экран для быстрого доступа",
     "installDescription": "Добавьте на главный экран для быстрого доступа",
     "enterFullscreen": "Полный экран",
     "enterFullscreen": "Полный экран",
     "exitFullscreen": "Выйти из полного экрана"
     "exitFullscreen": "Выйти из полного экрана"
+  },
+  "broadcast": {
+    "dismiss": "Закрыть",
+    "title": "Сообщение для всех",
+    "description": "Показывается вверху каждой страницы всем, вошедшим и нет. Оставьте пустым, чтобы ничего не показывать.",
+    "message": "Сообщение",
+    "level": "Важность",
+    "post": "Опубликовать сообщение",
+    "clear": "Убрать сообщение",
+    "levelInfo": "Информация",
+    "levelWarning": "Предупреждение",
+    "levelCritical": "Критично",
+    "placeholder": "напр. У нас проблемы с серверной инфраструктурой, мы уже работаем над этим.",
+    "posted": "Сообщение опубликовано",
+    "cleared": "Сообщение убрано"
   }
   }
 }
 }

+ 15 - 0
messages/tr/common.json

@@ -80,5 +80,20 @@
     "installDescription": "Hızlı erişim için ana ekranınıza ekleyin",
     "installDescription": "Hızlı erişim için ana ekranınıza ekleyin",
     "enterFullscreen": "Tam ekran",
     "enterFullscreen": "Tam ekran",
     "exitFullscreen": "Tam ekrandan çık"
     "exitFullscreen": "Tam ekrandan çık"
+  },
+  "broadcast": {
+    "dismiss": "Kapat",
+    "title": "Herkese duyuru",
+    "description": "Her sayfanın üstünde, oturum açmış olsun olmasın herkese gösterilir. Duyuru istemiyorsanız boş bırakın.",
+    "message": "Mesaj",
+    "level": "Önem",
+    "post": "Duyuruyu yayınla",
+    "clear": "Duyuruyu kaldır",
+    "levelInfo": "Bilgi",
+    "levelWarning": "Uyarı",
+    "levelCritical": "Kritik",
+    "placeholder": "ör. Sunucu altyapımızda sorun yaşıyoruz ve üzerinde çalışıyoruz.",
+    "posted": "Duyuru yayınlandı",
+    "cleared": "Duyuru kaldırıldı"
   }
   }
 }
 }

+ 129 - 0
src/__tests__/components/banner-slot.test.tsx

@@ -0,0 +1,129 @@
+/**
+ * One full-width strip at a time.
+ *
+ * These bars are pinned above everything and were rendered independently in
+ * three different layouts, so two at once pushed the whole app down by two
+ * bars. Nothing stopped a fourth being added, and three notices on screen at
+ * once are three nobody reads.
+ */
+
+import { describe, it, expect } from 'vitest'
+import { render, screen, act } from '@testing-library/react'
+import { BannerSlotProvider, useBannerSlot, BANNER_PRIORITY } from '@/components/banner-slot'
+
+function Strip({
+  id,
+  priority,
+  wants = true,
+}: {
+  id: string
+  priority: number
+  wants?: boolean
+}) {
+  const mine = useBannerSlot(id, priority, wants)
+  return mine ? <div data-testid={`strip-${id}`}>{id}</div> : null
+}
+
+function visible() {
+  return screen
+    .queryAllByTestId(/^strip-/)
+    .map((el) => el.getAttribute('data-testid')?.replace('strip-', ''))
+}
+
+describe('the banner queue', () => {
+  it('shows the loudest of several', () => {
+    render(
+      <BannerSlotProvider>
+        <Strip id="update" priority={BANNER_PRIORITY.update} />
+        <Strip id="broadcast" priority={BANNER_PRIORITY.broadcast} />
+        <Strip id="licence" priority={BANNER_PRIORITY.licence} />
+      </BannerSlotProvider>
+    )
+    expect(visible()).toEqual(['broadcast'])
+  })
+
+  it('shows nothing extra, whatever the order they mount in', () => {
+    render(
+      <BannerSlotProvider>
+        <Strip id="broadcast" priority={BANNER_PRIORITY.broadcast} />
+        <Strip id="demo" priority={BANNER_PRIORITY.demo} />
+      </BannerSlotProvider>
+    )
+    expect(visible()).toHaveLength(1)
+  })
+
+  it('lets the next one through when the winner stands down', () => {
+    // Dismissing the top notice should reveal the one behind it, not lose it.
+    const { rerender } = render(
+      <BannerSlotProvider>
+        <Strip id="broadcast" priority={BANNER_PRIORITY.broadcast} />
+        <Strip id="update" priority={BANNER_PRIORITY.update} />
+      </BannerSlotProvider>
+    )
+    expect(visible()).toEqual(['broadcast'])
+
+    act(() => {
+      rerender(
+        <BannerSlotProvider>
+          <Strip id="broadcast" priority={BANNER_PRIORITY.broadcast} wants={false} />
+          <Strip id="update" priority={BANNER_PRIORITY.update} />
+        </BannerSlotProvider>
+      )
+    })
+    expect(visible()).toEqual(['update'])
+  })
+
+  it('frees the slot when the winner unmounts entirely', () => {
+    const { rerender } = render(
+      <BannerSlotProvider>
+        <Strip id="broadcast" priority={BANNER_PRIORITY.broadcast} />
+        <Strip id="update" priority={BANNER_PRIORITY.update} />
+      </BannerSlotProvider>
+    )
+    act(() => {
+      rerender(
+        <BannerSlotProvider>
+          <Strip id="update" priority={BANNER_PRIORITY.update} />
+        </BannerSlotProvider>
+      )
+    })
+    expect(visible()).toEqual(['update'])
+  })
+
+  it('survives a second instance of the same banner going away', () => {
+    // The admin card previews the real banner, so two of them are mounted
+    // while that page is open. The preview unmounting must not take the page
+    // banner's claim with it, which is what "the notice only shows on the
+    // settings page" turned out to be.
+    const { rerender } = render(
+      <BannerSlotProvider>
+        <Strip id="broadcast" priority={BANNER_PRIORITY.broadcast} />
+        <Strip id="broadcast" priority={BANNER_PRIORITY.broadcast} />
+      </BannerSlotProvider>
+    )
+    act(() => {
+      rerender(
+        <BannerSlotProvider>
+          <Strip id="broadcast" priority={BANNER_PRIORITY.broadcast} />
+        </BannerSlotProvider>
+      )
+    })
+    expect(visible()).toEqual(['broadcast'])
+  })
+
+  it('shows nothing when nobody has anything to say', () => {
+    render(
+      <BannerSlotProvider>
+        <Strip id="update" priority={BANNER_PRIORITY.update} wants={false} />
+      </BannerSlotProvider>
+    )
+    expect(visible()).toEqual([])
+  })
+
+  it('keeps the notice above every other strip', () => {
+    // The ordering that matters: an incident outranks housekeeping.
+    expect(BANNER_PRIORITY.broadcast).toBeGreaterThan(BANNER_PRIORITY.demo)
+    expect(BANNER_PRIORITY.broadcast).toBeGreaterThan(BANNER_PRIORITY.licence)
+    expect(BANNER_PRIORITY.broadcast).toBeGreaterThan(BANNER_PRIORITY.update)
+  })
+})

+ 135 - 0
src/__tests__/components/broadcast-banner.test.tsx

@@ -0,0 +1,135 @@
+/**
+ * The banner half of the platform-wide notice.
+ *
+ * The behaviour worth pinning is the dismissal. It is remembered per browser
+ * and keyed on when the notice last changed, which is what makes a second
+ * incident reach the people who dismissed the first one. Key it on anything
+ * more stable and the next outage is invisible to everybody who was here for
+ * the last.
+ */
+
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { render, screen, act } from '@testing-library/react'
+import { BannerSlotProvider } from '@/components/banner-slot'
+import { clearLiveBroadcast, setLiveBroadcast } from '@/components/broadcast-store'
+
+vi.mock('next-intl', () => ({ useTranslations: () => (key: string) => key }))
+
+const { BroadcastBanner } = await import('@/components/broadcast-banner')
+
+/** The banner only renders inside the slot it competes for. */
+function show(broadcast: Parameters<typeof BroadcastBanner>[0]['broadcast']) {
+  return render(
+    <BannerSlotProvider>
+      <BroadcastBanner broadcast={broadcast} />
+    </BannerSlotProvider>
+  )
+}
+
+const NOTICE = {
+  message: 'We are having trouble with our server infrastructure',
+  level: 'critical' as const,
+  updatedAt: '2026-08-21T10:00:00.000Z',
+}
+
+function shown() {
+  return screen.queryByRole('status')
+}
+
+beforeEach(() => {
+  localStorage.clear()
+  clearLiveBroadcast()
+})
+
+describe('the notice banner', () => {
+  it('shows the message', () => {
+    show(NOTICE)
+    expect(shown()?.textContent).toContain('server infrastructure')
+  })
+
+  it('renders nothing when there is no notice', () => {
+    show(null)
+    expect(shown()).toBeNull()
+  })
+
+  it('stays gone once dismissed', () => {
+    show(NOTICE)
+    act(() => {
+      screen.getByRole('button').click()
+    })
+    expect(shown()).toBeNull()
+    expect(localStorage.getItem('broadcast-dismissed')).toBe(NOTICE.updatedAt)
+  })
+
+  it('stays gone on the next load', () => {
+    localStorage.setItem('broadcast-dismissed', NOTICE.updatedAt)
+    show(NOTICE)
+    expect(shown()).toBeNull()
+  })
+
+  it('comes back when the notice changes', () => {
+    // The one that matters. A second incident has to reach the people who
+    // waved the first one away.
+    localStorage.setItem('broadcast-dismissed', NOTICE.updatedAt)
+    show({ ...NOTICE, message: 'A different outage', updatedAt: '2026-08-22T09:00:00.000Z' })
+    expect(shown()?.textContent).toContain('A different outage')
+  })
+
+  it('shows when the browser refuses storage', () => {
+    // Private mode. Showing a notice twice is a smaller problem than never
+    // showing one during an outage.
+    const getItem = vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
+      throw new Error('denied')
+    })
+    show(NOTICE)
+    expect(shown()).not.toBeNull()
+    getItem.mockRestore()
+  })
+
+  it('takes a notice pushed over the socket without a reload', () => {
+    // The point of the live channel. Someone already looking at a screen when
+    // an incident is posted should see it there, not on their next click.
+    show(null)
+    expect(shown()).toBeNull()
+
+    act(() => {
+      setLiveBroadcast({ message: 'Just posted', level: 'warning', updatedAt: 'live-1' })
+    })
+    expect(shown()?.textContent).toContain('Just posted')
+  })
+
+  it('takes it away again when it is cleared', () => {
+    show(NOTICE)
+    expect(shown()).not.toBeNull()
+
+    act(() => {
+      setLiveBroadcast(null)
+    })
+    expect(shown()).toBeNull()
+  })
+
+  it('previews without joining the queue or reading dismissals', () => {
+    // The admin card renders the real banner to preview it. It must show the
+    // text being written even if this browser dismissed the last notice, and
+    // it must not hold the page-level slot, which is what made the notice
+    // vanish the moment you navigated away from settings.
+    localStorage.setItem('broadcast-dismissed', NOTICE.updatedAt)
+    render(<BroadcastBanner preview broadcast={NOTICE} />)
+    expect(shown()?.textContent).toContain('server infrastructure')
+  })
+
+  it('previews what it is handed, not what the socket last said', () => {
+    act(() => {
+      setLiveBroadcast({ message: 'Something else', level: 'info', updatedAt: 'live' })
+    })
+    render(<BroadcastBanner preview broadcast={NOTICE} />)
+    expect(shown()?.textContent).toContain('server infrastructure')
+  })
+
+  it('announces politely rather than interrupting', () => {
+    // It is on screen and will not move, so there is no reason to cut across
+    // whatever a screen reader is in the middle of.
+    show(NOTICE)
+    expect(shown()?.getAttribute('aria-live')).toBe('polite')
+  })
+})

+ 98 - 0
src/__tests__/features/admin/broadcast-settings.test.ts

@@ -0,0 +1,98 @@
+/**
+ * Posting the platform-wide notice.
+ *
+ * The timestamp is the load-bearing part. Every reader's dismissal is keyed on
+ * it, so it decides whether a new notice reaches somebody who dismissed the
+ * last one. It is written here, never accepted from the caller, because a
+ * client that could set it could silence a live incident for everyone who had
+ * already waved the previous one away.
+ */
+
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+
+const upsert = vi.fn((args: unknown) => args)
+vi.mock('@/lib/db', () => ({
+  db: {
+    systemSetting: { upsert: (a: unknown) => upsert(a) },
+    $transaction: (ops: unknown[]) => Promise.resolve(ops),
+  },
+}))
+vi.mock('@/lib/with-super-admin', () => ({
+  withSuperAdmin: async (fn: () => Promise<unknown>) => {
+    try {
+      return { success: true, data: await fn() }
+    } catch (error) {
+      return { success: false, error: (error as Error).message }
+    }
+  },
+}))
+vi.mock('@/lib/demo', () => ({
+  demoGuard: () => undefined,
+}))
+
+const { setSystemSettings } = await import('@/features/admin/Actions/setSystemSettings')
+
+/** The key/value pairs the action actually wrote. */
+function written() {
+  const out: Record<string, string> = {}
+  for (const call of upsert.mock.calls) {
+    const args = call[0] as { where: { key: string }; update: { value: string } }
+    out[args.where.key] = args.update.value
+  }
+  return out
+}
+
+beforeEach(() => {
+  upsert.mockClear()
+})
+
+describe('posting a notice', () => {
+  it('stamps when it changed, so dismissals reset', async () => {
+    await setSystemSettings({ 'broadcast.message': 'Server trouble' })
+    const at = written()['broadcast.updatedAt']
+    expect(at).toBeTruthy()
+    expect(Number.isNaN(Date.parse(at))).toBe(false)
+  })
+
+  it('ignores a timestamp the caller supplies', async () => {
+    // Forging an old one would hide a live incident from everybody who had
+    // dismissed the previous notice.
+    await setSystemSettings({
+      'broadcast.message': 'Server trouble',
+      'broadcast.updatedAt': '1999-01-01T00:00:00.000Z',
+    })
+    expect(written()['broadcast.updatedAt']).not.toBe('1999-01-01T00:00:00.000Z')
+  })
+
+  it('trims and caps the message', async () => {
+    await setSystemSettings({ 'broadcast.message': `  ${'x'.repeat(900)}  ` })
+    expect(written()['broadcast.message'].length).toBe(400)
+  })
+
+  it('falls back to info for a level it cannot style', async () => {
+    await setSystemSettings({ 'broadcast.message': 'Hi', 'broadcast.level': 'apocalyptic' })
+    expect(written()['broadcast.level']).toBe('info')
+  })
+
+  it('keeps a level it can style', async () => {
+    await setSystemSettings({ 'broadcast.message': 'Hi', 'broadcast.level': 'warning' })
+    expect(written()['broadcast.level']).toBe('warning')
+  })
+
+  it('stamps on clearing too, so the banner goes away for everyone', async () => {
+    await setSystemSettings({ 'broadcast.message': '' })
+    expect(written()['broadcast.message']).toBe('')
+    expect(written()['broadcast.updatedAt']).toBeTruthy()
+  })
+
+  it('leaves other settings alone', async () => {
+    // Saving mail config must not stamp a notice nobody touched.
+    await setSystemSettings({ 'smtp.host': 'mail.example.com' })
+    expect(written()).toEqual({ 'smtp.host': 'mail.example.com' })
+  })
+
+  it('refuses a key it does not know', async () => {
+    await setSystemSettings({ 'something.invented': 'x' })
+    expect(written()).toEqual({})
+  })
+})

+ 135 - 0
src/__tests__/lib/broadcast.test.ts

@@ -0,0 +1,135 @@
+/**
+ * The platform-wide notice.
+ *
+ * This one reads in the root layout, so it is on the path of every page in the
+ * app including sign-in and the public customer links. Two things follow from
+ * that and neither is obvious from the call site: it must survive a database
+ * it cannot reach, and it must never hand the banner a level it cannot style.
+ */
+
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+
+const findMany = vi.fn()
+vi.mock('@/lib/db', () => ({ db: { systemSetting: { findMany } } }))
+
+/** A fresh module each time, so React's per-request cache cannot leak. */
+async function read() {
+  vi.resetModules()
+  const { getBroadcast } = await import('@/lib/broadcast')
+  return getBroadcast()
+}
+
+function stored(entries: Record<string, string>) {
+  findMany.mockResolvedValue(Object.entries(entries).map(([key, value]) => ({ key, value })))
+}
+
+beforeEach(() => {
+  findMany.mockReset()
+})
+
+describe('who the notice reaches', () => {
+  it('never reaches a page a workshop customer opens', async () => {
+    // These carry the workshop's branding, not ours, and a white-label licence
+    // exists so Torqvoice does not appear on that paperwork at all. A notice
+    // aimed at staff is also none of a customer's business.
+    const { isCustomerFacingPath } = await import('@/lib/broadcast')
+    for (const path of [
+      '/portal',
+      '/portal/org123',
+      '/portal/org123/invoices',
+      '/portal/org123/auth/login',
+      '/share/invoice/org123/tok',
+      '/share/quote/org123/tok',
+      '/share/inspection/org123/tok',
+      '/share/status-report/org123/tok',
+      '/share/terms/org123',
+      '/terms',
+    ]) {
+      expect(isCustomerFacingPath(path), `${path} must not show a notice`).toBe(true)
+    }
+  })
+
+  it('reaches the staff app, including the sign-in page', async () => {
+    // Somebody who cannot sign in during an outage is the person who most
+    // needs to know why.
+    const { isCustomerFacingPath } = await import('@/lib/broadcast')
+    for (const path of [
+      '/',
+      '/auth/sign-in',
+      '/work-orders',
+      '/settings/license',
+      '/admin/settings',
+      '/tire-hotel',
+    ]) {
+      expect(isCustomerFacingPath(path), `${path} should show a notice`).toBe(false)
+    }
+  })
+
+  it('is not fooled by a path that merely starts with the same letters', async () => {
+    const { isCustomerFacingPath } = await import('@/lib/broadcast')
+    expect(isCustomerFacingPath('/portal-settings')).toBe(false)
+    expect(isCustomerFacingPath('/shared-notes')).toBe(false)
+  })
+})
+
+describe('reading the notice', () => {
+  it('is nothing when none is set', async () => {
+    stored({})
+    expect(await read()).toBeNull()
+  })
+
+  it('is nothing when the message is only whitespace', async () => {
+    // Clearing the field writes an empty string rather than deleting the row.
+    stored({ 'broadcast.message': '   ' })
+    expect(await read()).toBeNull()
+  })
+
+  it('carries the message, level and timestamp', async () => {
+    stored({
+      'broadcast.message': 'Server trouble, we are on it',
+      'broadcast.level': 'critical',
+      'broadcast.updatedAt': '2026-08-21T10:00:00.000Z',
+    })
+    expect(await read()).toEqual({
+      message: 'Server trouble, we are on it',
+      level: 'critical',
+      updatedAt: '2026-08-21T10:00:00.000Z',
+    })
+  })
+
+  it('falls back to info for a level it cannot style', async () => {
+    // The banner indexes a style map by this. An unknown value would render
+    // undefined classes on every page at once.
+    stored({ 'broadcast.message': 'Hello', 'broadcast.level': 'catastrophic' })
+    expect((await read())?.level).toBe('info')
+  })
+
+  it('defaults to info when no level was stored', async () => {
+    stored({ 'broadcast.message': 'Hello' })
+    expect((await read())?.level).toBe('info')
+  })
+
+  it('trims the message', async () => {
+    stored({ 'broadcast.message': '  spaced  ' })
+    expect((await read())?.message).toBe('spaced')
+  })
+
+  it('caps a message long enough to push the page around', async () => {
+    stored({ 'broadcast.message': 'x'.repeat(5000) })
+    expect((await read())?.message.length).toBe(400)
+  })
+
+  it('keys dismissals on the text when there is no timestamp', async () => {
+    // A notice written before the timestamp existed still needs something
+    // stable, or it would reappear on every page load.
+    stored({ 'broadcast.message': 'Hello' })
+    expect((await read())?.updatedAt).toBe('Hello')
+  })
+
+  it('returns nothing rather than taking every page down with it', async () => {
+    // It runs in the root layout. A database that is unreachable, or not yet
+    // migrated, must not turn a missing notice into a blank app.
+    findMany.mockRejectedValue(new Error('connection refused'))
+    expect(await read()).toBeNull()
+  })
+})

+ 5 - 0
src/app/(authenticated)/layout.tsx

@@ -13,6 +13,7 @@ import { getFeatures, isCloudMode } from '@/lib/features'
 import { WhiteLabelCtaProvider } from '@/components/white-label-cta-context'
 import { WhiteLabelCtaProvider } from '@/components/white-label-cta-context'
 import { DateSettingsProvider } from '@/components/date-settings-context'
 import { DateSettingsProvider } from '@/components/date-settings-context'
 import { UpdateBanner } from '@/components/update-banner'
 import { UpdateBanner } from '@/components/update-banner'
+import { BroadcastLive } from '@/components/broadcast-live'
 import { CurrencySettingsProvider } from '@/components/currency-settings-context'
 import { CurrencySettingsProvider } from '@/components/currency-settings-context'
 import { getCachedMembership } from '@/lib/cached-session'
 import { getCachedMembership } from '@/lib/cached-session'
 import { hasPermission, PermissionAction, PermissionSubject } from '@/lib/permissions'
 import { hasPermission, PermissionAction, PermissionSubject } from '@/lib/permissions'
@@ -136,6 +137,10 @@ export default async function DashboardLayout({ children }: { children: React.Re
             aria-hidden
             aria-hidden
             className="pointer-events-none fixed inset-x-0 top-0 z-50 h-px bg-linear-to-r from-primary via-primary/35 to-transparent"
             className="pointer-events-none fixed inset-x-0 top-0 z-50 h-px bg-linear-to-r from-primary via-primary/35 to-transparent"
           />
           />
+          {/* Watches for a notice posted while this page is already open. Here
+              rather than beside the banner because the socket authenticates on
+              the session cookie, which the sign-in page does not have. */}
+          <BroadcastLive />
           {/* The demo banner already occupies the header, and demo image tags
           {/* The demo banner already occupies the header, and demo image tags
         (demo-abc1234) are not versions a visitor should be notified about. */}
         (demo-abc1234) are not versions a visitor should be notified about. */}
           {!isDemoMode && (
           {!isDemoMode && (

+ 9 - 0
src/app/api/protected/ws/route.ts

@@ -45,6 +45,15 @@ notificationBus.on("workboard", (event: { organizationId: string }) => {
   }
   }
 });
 });
 
 
+// Platform-wide notice. The only event here that ignores organizationId:
+// an infrastructure incident is not one workshop's business, it is everyone's.
+notificationBus.on("broadcast", (broadcast: unknown) => {
+  const payload = JSON.stringify({ type: "broadcast", data: broadcast });
+  for (const client of clients) {
+    if (client.readyState === 1) client.send(payload);
+  }
+});
+
 /**
 /**
  * Resolve the session token from the Next.js cookie store.
  * Resolve the session token from the Next.js cookie store.
  * better-auth uses chunked cookies for large tokens (.0, .1, …)
  * better-auth uses chunked cookies for large tokens (.0, .1, …)

+ 19 - 2
src/app/layout.tsx

@@ -12,6 +12,10 @@ import { PostHogProvider } from '@/components/posthog-provider'
 import { isCloudMode } from '@/lib/features'
 import { isCloudMode } from '@/lib/features'
 import { isDemoMode } from '@/lib/demo'
 import { isDemoMode } from '@/lib/demo'
 import { DemoBanner } from '@/components/demo-banner'
 import { DemoBanner } from '@/components/demo-banner'
+import { BroadcastBanner } from '@/components/broadcast-banner'
+import { BannerSlotProvider } from '@/components/banner-slot'
+import { getBroadcast, isCustomerFacingPath } from '@/lib/broadcast'
+import { headers } from 'next/headers'
 import 'react-grid-layout/css/styles.css'
 import 'react-grid-layout/css/styles.css'
 import 'react-resizable/css/styles.css'
 import 'react-resizable/css/styles.css'
 import './globals.css'
 import './globals.css'
@@ -86,6 +90,13 @@ export default async function RootLayout({
 }>) {
 }>) {
   const locale = await getLocale()
   const locale = await getLocale()
   const messages = await getMessages()
   const messages = await getMessages()
+  // Staff only, deliberately. This layout also wraps the invoice, quote and
+  // portal pages a workshop's own customers open, and those carry the
+  // workshop's branding, not ours. A white-label licence exists precisely so
+  // Torqvoice does not appear on that paperwork, and a platform notice there
+  // would be both off-brand and none of the customer's business.
+  const pathname = (await headers()).get('x-pathname')
+  const broadcast = isCustomerFacingPath(pathname) ? null : await getBroadcast()
 
 
   return (
   return (
     <html lang={locale} translate="no" suppressHydrationWarning>
     <html lang={locale} translate="no" suppressHydrationWarning>
@@ -109,8 +120,14 @@ export default async function RootLayout({
             <ThemeProvider defaultTheme="dark">
             <ThemeProvider defaultTheme="dark">
               <QueryProvider>
               <QueryProvider>
                 <TooltipProvider>
                 <TooltipProvider>
-                  <DemoBanner />
-                  {children}
+                  {/* One strip at a time. These used to render independently
+                      in three layouts, so a notice and a new-version note
+                      pushed the app down by two bars at once. */}
+                  <BannerSlotProvider>
+                    <BroadcastBanner broadcast={broadcast} />
+                    <DemoBanner isDemo={isDemoMode} />
+                    {children}
+                  </BannerSlotProvider>
                   <GlassModal />
                   <GlassModal />
                   <Toaster richColors position="bottom-right" />
                   <Toaster richColors position="bottom-right" />
                   <PWAServiceWorker />
                   <PWAServiceWorker />

+ 109 - 0
src/components/banner-slot.tsx

@@ -0,0 +1,109 @@
+'use client'
+
+import { createContext, useCallback, useContext, useEffect, useId, useMemo, useState } from 'react'
+
+/**
+ * How loud each strip is allowed to be, when more than one has something to
+ * say. Higher wins.
+ */
+export const BANNER_PRIORITY = {
+  /** An incident somebody is dealing with right now. */
+  broadcast: 100,
+  /** Permanent context for the public demo. */
+  demo: 80,
+  /** A licence about to lapse. Weeks of warning, so it can wait its turn. */
+  licence: 60,
+  /** The app updated. Interesting, never urgent. */
+  update: 40,
+} as const
+
+/**
+ * Keyed by instance, not by banner id.
+ *
+ * Two of the same banner can be mounted at once: the admin card previews the
+ * real one. Keying by id let the preview's unmount release the page banner's
+ * claim, so the notice vanished the moment you navigated away from settings.
+ */
+type Registry = Record<string, { id: string; priority: number }>
+
+const Context = createContext<{
+  /** The instance currently allowed to render, if any. */
+  winner: string | null
+  claim: (instance: string, id: string, priority: number) => void
+  release: (instance: string) => void
+}>({
+  winner: null,
+  claim: () => undefined,
+  release: () => undefined,
+})
+
+/**
+ * One strip at a time, across the whole app.
+ *
+ * These are full-width bars pinned above everything, and they were rendered
+ * independently in three different layouts. Two at once pushed the app down by
+ * two bars; a third would have been worse, and nothing stopped a fourth being
+ * added. Stacking is also the wrong answer on its own terms: somebody reading
+ * three notices at once reads none of them.
+ *
+ * So they queue instead. The loudest shows, the rest wait, and dismissing the
+ * winner lets the next one through rather than losing it.
+ */
+export function BannerSlotProvider({ children }: { children: React.ReactNode }) {
+  const [waiting, setWaiting] = useState<Registry>({})
+
+  const claim = useCallback((instance: string, id: string, priority: number) => {
+    setWaiting((current) => {
+      const held = current[instance]
+      if (held && held.id === id && held.priority === priority) return current
+      return { ...current, [instance]: { id, priority } }
+    })
+  }, [])
+
+  const release = useCallback((instance: string) => {
+    setWaiting((current) => {
+      if (!(instance in current)) return current
+      const next = { ...current }
+      delete next[instance]
+      return next
+    })
+  }, [])
+
+  const winner = useMemo(() => {
+    const entries = Object.entries(waiting)
+    if (entries.length === 0) return null
+    return entries.reduce((best, entry) => (entry[1].priority > best[1].priority ? entry : best))[0]
+  }, [waiting])
+
+  const value = useMemo(() => ({ winner, claim, release }), [winner, claim, release])
+
+  return <Context.Provider value={value}>{children}</Context.Provider>
+}
+
+/**
+ * Says whether this banner is the one to render.
+ *
+ * `wants` is the banner's own condition. Claiming happens in an effect, not
+ * during render, because claiming sets state on the provider and touching a
+ * parent's state while a child renders is what React warns about.
+ *
+ * The consequence is that nothing shows on the very first frame, which is the
+ * right way round: a banner appearing a frame late is invisible, whereas three
+ * appearing at once and then collapsing to one is exactly the flicker this
+ * exists to remove.
+ */
+export function useBannerSlot(id: string, priority: number, wants: boolean): boolean {
+  const { winner, claim, release } = useContext(Context)
+  const instance = useId()
+
+  useEffect(() => {
+    if (!wants) {
+      release(instance)
+      return
+    }
+    claim(instance, id, priority)
+    return () => release(instance)
+  }, [instance, id, priority, wants, claim, release])
+
+  return wants && winner === instance
+}

+ 128 - 0
src/components/broadcast-banner.tsx

@@ -0,0 +1,128 @@
+'use client'
+
+import { useEffect, useState, useSyncExternalStore } from 'react'
+import { useTranslations } from 'next-intl'
+import { AlertTriangle, Info, OctagonAlert, X } from 'lucide-react'
+import type { Broadcast } from '@/lib/broadcast'
+import { BANNER_PRIORITY, useBannerSlot } from './banner-slot'
+import { getLiveBroadcast, subscribe } from './broadcast-store'
+
+const STYLES = {
+  info: 'bg-sky-500 text-sky-950',
+  warning: 'bg-amber-500 text-amber-950',
+  critical: 'bg-red-600 text-white',
+} as const
+
+const ICONS = {
+  info: Info,
+  warning: AlertTriangle,
+  critical: OctagonAlert,
+} as const
+
+/**
+ * Remembered per browser rather than per workshop or per account.
+ *
+ * Dismissing a notice is one person saying "I have read this", which is not
+ * something they can decide on a colleague's behalf, and not something worth a
+ * round trip or a column. Keyed on when the notice last changed, so editing it
+ * brings it back for everybody, including the people who already waved the
+ * previous one away.
+ */
+const DISMISSED_KEY = 'broadcast-dismissed'
+
+/**
+ * The platform-wide notice, set by a super admin.
+ *
+ * Rendered from the root layout, so it reaches the sign-in page and the public
+ * links a customer opens, not only the app. During an outage the person who
+ * cannot sign in is the one who most needs to know why.
+ */
+export function BroadcastBanner({
+  broadcast: fromServer,
+  preview = false,
+}: {
+  broadcast: Broadcast | null
+  /**
+   * Rendered inside the admin card rather than at the top of the page.
+   *
+   * A preview is not a page-level strip: it takes no part in the queue, reads
+   * only what it is handed, and ignores what this browser has dismissed. The
+   * admin needs to see the notice they are writing even if they closed the
+   * last one.
+   */
+  preview?: boolean
+}) {
+  const t = useTranslations('common.broadcast')
+
+  // A notice posted while this page was already open arrives over the socket.
+  // Undefined means none has, so what the page loaded with still stands; null
+  // means one was explicitly cleared.
+  const live = useSyncExternalStore(
+    subscribe,
+    getLiveBroadcast,
+    () => undefined as Broadcast | null | undefined
+  )
+  const broadcast = preview || live === undefined ? fromServer : live
+  // Starts hidden and appears once the browser has been asked. Rendering it
+  // first and hiding it a moment later would flash a stale outage notice at
+  // somebody who dismissed it days ago.
+  const [visible, setVisible] = useState(false)
+
+  useEffect(() => {
+    if (preview) {
+      setVisible(true)
+      return
+    }
+    if (!broadcast) return
+    let seen: string | null = null
+    try {
+      seen = localStorage.getItem(DISMISSED_KEY)
+    } catch {
+      // Private mode, or storage disabled. Showing it is the safe way to be
+      // wrong about a notice that matters.
+    }
+    setVisible(seen !== broadcast.updatedAt)
+  }, [broadcast, preview])
+
+  const mine = useBannerSlot(
+    'broadcast',
+    BANNER_PRIORITY.broadcast,
+    !preview && Boolean(broadcast) && visible
+  )
+
+  if (!broadcast || !visible || (!preview && !mine)) return null
+
+  const Icon = ICONS[broadcast.level]
+
+  const dismiss = () => {
+    setVisible(false)
+    try {
+      localStorage.setItem(DISMISSED_KEY, broadcast.updatedAt)
+    } catch {
+      // As above. It reappears on the next load, which is a smaller problem
+      // than never showing it.
+    }
+  }
+
+  return (
+    <div
+      // polite, not assertive: it is on screen and will not move, so there is
+      // no reason to cut across whatever is being read.
+      role="status"
+      aria-live="polite"
+      className={`flex items-center gap-2 px-4 py-1.5 text-xs font-medium ${STYLES[broadcast.level]}`}
+    >
+      <Icon className="size-4 shrink-0" aria-hidden="true" />
+      <p className="min-w-0 flex-1 text-center">{broadcast.message}</p>
+      <button
+        type="button"
+        onClick={dismiss}
+        aria-label={t('dismiss')}
+        title={t('dismiss')}
+        className="shrink-0 rounded p-0.5 opacity-70 transition-opacity hover:opacity-100"
+      >
+        <X className="size-4" />
+      </button>
+    </div>
+  )
+}

+ 67 - 0
src/components/broadcast-live.tsx

@@ -0,0 +1,67 @@
+'use client'
+
+import { useEffect } from 'react'
+import { setLiveBroadcast } from './broadcast-store'
+import type { Broadcast } from '@/lib/broadcast'
+
+/**
+ * Listens for a notice posted while somebody is already looking at a screen.
+ *
+ * Mounted from the authenticated layout rather than beside the banner, because
+ * the socket authenticates on the session cookie: from the sign-in page it
+ * would only ever fail and retry. Someone signed out still sees the notice,
+ * just on the page they load rather than the moment it is posted.
+ *
+ * A socket of its own, not the notification one. That is mounted for owners
+ * and admins only, and an outage notice has to reach the technician in the bay
+ * as much as the person who owns the shop.
+ */
+export function BroadcastLive() {
+  useEffect(() => {
+    // Liveness as a closure, not a ref: StrictMode remounts in dev, and a ref
+    // lets the old socket's onclose schedule a reconnect against the new run,
+    // leaving two sockets open.
+    let alive = true
+    let socket: WebSocket | null = null
+    let retry: ReturnType<typeof setTimeout> | undefined
+    // Backs off so a server that is down, which is exactly when a notice gets
+    // posted, does not get hammered by every open tab in every workshop.
+    let attempt = 0
+
+    const connect = () => {
+      if (!alive) return
+      const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
+      socket = new WebSocket(`${protocol}//${window.location.host}/api/protected/ws`)
+
+      socket.onopen = () => {
+        attempt = 0
+      }
+
+      socket.onmessage = (event) => {
+        try {
+          const message = JSON.parse(event.data)
+          if (message.type !== 'broadcast') return
+          setLiveBroadcast((message.data as Broadcast | null) ?? null)
+        } catch {
+          // Someone else's frame, or a truncated one. Not ours to report.
+        }
+      }
+
+      socket.onclose = () => {
+        if (!alive) return
+        attempt += 1
+        retry = setTimeout(connect, Math.min(30_000, 1000 * 2 ** attempt))
+      }
+    }
+
+    connect()
+
+    return () => {
+      alive = false
+      clearTimeout(retry)
+      socket?.close()
+    }
+  }, [])
+
+  return null
+}

+ 42 - 0
src/components/broadcast-store.ts

@@ -0,0 +1,42 @@
+'use client'
+
+import type { Broadcast } from '@/lib/broadcast'
+
+/**
+ * The current notice, once a live update has replaced what the page loaded
+ * with.
+ *
+ * Module scope rather than context, for the same reason the install prompt
+ * uses it: the listener and the banner sit in different layouts, and the value
+ * has to outlive any single subtree. `undefined` means nothing has arrived yet
+ * and the server's value still stands, which is a different thing from `null`,
+ * meaning a notice was explicitly cleared.
+ */
+let live: Broadcast | null | undefined
+const subscribers = new Set<() => void>()
+
+export function subscribe(onChange: () => void) {
+  subscribers.add(onChange)
+  return () => subscribers.delete(onChange)
+}
+
+export function getLiveBroadcast() {
+  return live
+}
+
+export function setLiveBroadcast(next: Broadcast | null) {
+  live = next
+  for (const notify of subscribers) notify()
+}
+
+/**
+ * Forgets the live value so the server's stands again.
+ *
+ * Distinct from setting it to null, which asserts that the notice was cleared.
+ * "I no longer know" and "there is none" are different claims, and only the
+ * second should blank a banner the page was rendered with.
+ */
+export function clearLiveBroadcast() {
+  live = undefined
+  for (const notify of subscribers) notify()
+}

+ 14 - 3
src/components/demo-banner.tsx

@@ -1,9 +1,20 @@
-import { isDemoMode } from "@/lib/demo";
+"use client";
+
 import { useTranslations } from "next-intl";
 import { useTranslations } from "next-intl";
+import { BANNER_PRIORITY, useBannerSlot } from "./banner-slot";
 
 
-export function DemoBanner() {
+/**
+ * Permanent context for the public demo.
+ *
+ * Takes `isDemo` as a prop rather than reading it: DEMO_MODE has no
+ * NEXT_PUBLIC prefix, so it is server-only, and this became a client component
+ * in order to queue behind a platform notice rather than stack under one.
+ */
+export function DemoBanner({ isDemo }: { isDemo: boolean }) {
   const t = useTranslations("common.shared");
   const t = useTranslations("common.shared");
-  if (!isDemoMode) return null;
+  const mine = useBannerSlot("demo", BANNER_PRIORITY.demo, isDemo);
+
+  if (!isDemo || !mine) return null;
 
 
   return (
   return (
     <div className="bg-amber-500 text-center text-xs font-medium text-amber-950 py-1.5 px-4">
     <div className="bg-amber-500 text-center text-xs font-medium text-amber-950 py-1.5 px-4">

+ 9 - 1
src/components/page-header.tsx

@@ -18,6 +18,7 @@ import { SidebarTrigger } from '@/components/ui/sidebar'
 import { AlertTriangle, Search, X, Zap } from 'lucide-react'
 import { AlertTriangle, Search, X, Zap } from 'lucide-react'
 import { useShowWhiteLabelCta } from '@/components/white-label-cta-context'
 import { useShowWhiteLabelCta } from '@/components/white-label-cta-context'
 import { useLicenseExpiry } from '@/components/license-expiry-context'
 import { useLicenseExpiry } from '@/components/license-expiry-context'
+import { BANNER_PRIORITY, useBannerSlot } from '@/components/banner-slot'
 import { QuickCreateMenu } from '@/components/quick-create-menu'
 import { QuickCreateMenu } from '@/components/quick-create-menu'
 import { DocsLink } from '@/components/docs-link'
 import { DocsLink } from '@/components/docs-link'
 
 
@@ -146,6 +147,13 @@ export function PageHeader() {
   const pathname = usePathname()
   const pathname = usePathname()
   const showWhiteLabelCta = useShowWhiteLabelCta()
   const showWhiteLabelCta = useShowWhiteLabelCta()
   const { daysUntilExpiry, dismissed, dismiss } = useLicenseExpiry()
   const { daysUntilExpiry, dismissed, dismiss } = useLicenseExpiry()
+  // Weeks of warning before a licence lapses, so this waits behind anything
+  // happening right now rather than adding a second bar beneath it.
+  const showLicenceNotice = useBannerSlot(
+    'licence',
+    BANNER_PRIORITY.licence,
+    daysUntilExpiry !== null && daysUntilExpiry <= 14 && !dismissed
+  )
   const t = useTranslations('navigation.breadcrumbs')
   const t = useTranslations('navigation.breadcrumbs')
   const tn = useTranslations('navigation')
   const tn = useTranslations('navigation')
 
 
@@ -246,7 +254,7 @@ export function PageHeader() {
           )}
           )}
         </div>
         </div>
       </header>
       </header>
-      {daysUntilExpiry !== null && daysUntilExpiry <= 14 && !dismissed && (
+      {showLicenceNotice && daysUntilExpiry !== null && (
         <div
         <div
           className={`flex items-center gap-2 px-4 py-2 text-sm ${
           className={`flex items-center gap-2 px-4 py-2 text-sm ${
             daysUntilExpiry <= 0
             daysUntilExpiry <= 0

+ 6 - 1
src/components/update-banner.tsx

@@ -4,6 +4,7 @@ import { useEffect, useState } from "react";
 import { useTranslations } from "next-intl";
 import { useTranslations } from "next-intl";
 import { X } from "lucide-react";
 import { X } from "lucide-react";
 import { markVersionSeen } from "@/features/users/Actions/versionActions";
 import { markVersionSeen } from "@/features/users/Actions/versionActions";
+import { BANNER_PRIORITY, useBannerSlot } from "./banner-slot";
 
 
 /**
 /**
  * One-time "the app was updated" notice, shown when the running APP_VERSION
  * One-time "the app was updated" notice, shown when the running APP_VERSION
@@ -38,7 +39,11 @@ export function UpdateBanner({
     }
     }
   }, [neverSeeded, currentVersion]);
   }, [neverSeeded, currentVersion]);
 
 
-  if (!show) return null;
+  // Last in the queue. Interesting, never urgent, and it waits behind an
+  // outage notice rather than sitting under one.
+  const mine = useBannerSlot("update", BANNER_PRIORITY.update, show);
+
+  if (!show || !mine) return null;
 
 
   const acknowledge = () => {
   const acknowledge = () => {
     setDismissed(true);
     setDismissed(true);

+ 44 - 0
src/features/admin/Actions/setSystemSettings.ts

@@ -5,8 +5,13 @@ import { db } from "@/lib/db";
 import {
 import {
   systemSettingsUpdateSchema,
   systemSettingsUpdateSchema,
   ALL_SYSTEM_KEYS,
   ALL_SYSTEM_KEYS,
+  SYSTEM_SETTING_KEYS,
+  BROADCAST_LEVELS,
+  BROADCAST_MAX_LENGTH,
+  type BroadcastLevel,
 } from "../Schema/systemSettingsSchema";
 } from "../Schema/systemSettingsSchema";
 import { demoGuard } from "@/lib/demo";
 import { demoGuard } from "@/lib/demo";
+import { notificationBus } from "@/lib/notification-bus";
 
 
 export async function setSystemSettings(input: Record<string, string>) {
 export async function setSystemSettings(input: Record<string, string>) {
   return withSuperAdmin(async () => {
   return withSuperAdmin(async () => {
@@ -21,6 +26,29 @@ export async function setSystemSettings(input: Record<string, string>) {
       return true;
       return true;
     });
     });
 
 
+    const broadcast = entries.find(
+      ([key]) => key === SYSTEM_SETTING_KEYS.BROADCAST_MESSAGE,
+    );
+    if (broadcast) {
+      // Trimmed and capped here rather than in the form, because this is the
+      // only door: an over-long notice would push the page around on every
+      // screen in every workshop at once.
+      broadcast[1] = broadcast[1].trim().slice(0, BROADCAST_MAX_LENGTH);
+
+      const level = entries.find(
+        ([key]) => key === SYSTEM_SETTING_KEYS.BROADCAST_LEVEL,
+      );
+      if (level && !BROADCAST_LEVELS.includes(level[1] as BroadcastLevel)) {
+        level[1] = "info";
+      }
+
+      // Stamped here, never by the caller. Each person's dismissal is keyed on
+      // this value, so it decides whether a notice reappears, and a client
+      // that could set it could hide a live incident from everyone who had
+      // already dismissed the previous one.
+      entries.push([SYSTEM_SETTING_KEYS.BROADCAST_UPDATED_AT, new Date().toISOString()]);
+    }
+
     await db.$transaction(
     await db.$transaction(
       entries.map(([key, value]) =>
       entries.map(([key, value]) =>
         db.systemSetting.upsert({
         db.systemSetting.upsert({
@@ -31,6 +59,22 @@ export async function setSystemSettings(input: Record<string, string>) {
       ),
       ),
     );
     );
 
 
+    // Pushed to everyone already looking at a screen. A notice about an
+    // outage that only appears on the next navigation reaches people slowest
+    // at exactly the moment it matters most.
+    if (broadcast) {
+      const level = entries.find(([key]) => key === SYSTEM_SETTING_KEYS.BROADCAST_LEVEL)?.[1];
+      const updatedAt = entries.find(
+        ([key]) => key === SYSTEM_SETTING_KEYS.BROADCAST_UPDATED_AT,
+      )?.[1];
+      notificationBus.emit(
+        "broadcast",
+        broadcast[1]
+          ? { message: broadcast[1], level: level ?? "info", updatedAt }
+          : null,
+      );
+    }
+
     return { updated: entries.length };
     return { updated: entries.length };
   });
   });
 }
 }

+ 9 - 1
src/features/admin/Components/admin-settings.tsx

@@ -10,9 +10,10 @@ import { Button } from '@/components/ui/button'
 import { Label } from '@/components/ui/label'
 import { Label } from '@/components/ui/label'
 import { Switch } from '@/components/ui/switch'
 import { Switch } from '@/components/ui/switch'
 import { Loader2, Send } from 'lucide-react'
 import { Loader2, Send } from 'lucide-react'
-import { SYSTEM_SETTING_KEYS } from '../Schema/systemSettingsSchema'
+import { SYSTEM_SETTING_KEYS, type BroadcastLevel } from '../Schema/systemSettingsSchema'
 import type { SystemSettingsMap } from '../Schema/systemSettingsSchema'
 import type { SystemSettingsMap } from '../Schema/systemSettingsSchema'
 import { setSystemSettings } from '../Actions/setSystemSettings'
 import { setSystemSettings } from '../Actions/setSystemSettings'
+import { BroadcastCard } from './broadcast-card'
 import { testEmailConnection } from '../Actions/testEmailConnection'
 import { testEmailConnection } from '../Actions/testEmailConnection'
 
 
 type EmailProviderType = 'smtp' | 'resend' | 'postmark' | 'mailgun' | 'sendgrid' | 'ses'
 type EmailProviderType = 'smtp' | 'resend' | 'postmark' | 'mailgun' | 'sendgrid' | 'ses'
@@ -204,6 +205,13 @@ export function AdminSettings({
 
 
   return (
   return (
     <div className="space-y-6">
     <div className="space-y-6">
+      <BroadcastCard
+        initialMessage={initial[SYSTEM_SETTING_KEYS.BROADCAST_MESSAGE] ?? ''}
+        initialLevel={
+          (initial[SYSTEM_SETTING_KEYS.BROADCAST_LEVEL] as BroadcastLevel | undefined) ?? 'info'
+        }
+      />
+
       {/* Platform Settings */}
       {/* Platform Settings */}
       <AppCard
       <AppCard
         title={t('adminSettings.platformTitle')}
         title={t('adminSettings.platformTitle')}

+ 127 - 0
src/features/admin/Components/broadcast-card.tsx

@@ -0,0 +1,127 @@
+'use client'
+
+import { useState, useTransition } from 'react'
+import { useTranslations } from 'next-intl'
+import { toast } from 'sonner'
+import { Megaphone } from 'lucide-react'
+import { AppCard } from '@/components/app-card'
+import { Button } from '@/components/ui/button'
+import { Label } from '@/components/ui/label'
+import { Textarea } from '@/components/ui/textarea'
+import { BroadcastBanner } from '@/components/broadcast-banner'
+import { setSystemSettings } from '../Actions/setSystemSettings'
+import {
+  BROADCAST_LEVELS,
+  BROADCAST_MAX_LENGTH,
+  SYSTEM_SETTING_KEYS,
+  type BroadcastLevel,
+} from '../Schema/systemSettingsSchema'
+
+/**
+ * The platform-wide notice, posted and cleared from here.
+ *
+ * Saves on its own rather than joining the page's single Save button. This is
+ * the one setting on the page that gets touched mid-incident, and clearing it
+ * afterwards should not mean scrolling past the whole mail configuration to
+ * find a button that also rewrites everything else.
+ */
+export function BroadcastCard({
+  initialMessage,
+  initialLevel,
+}: {
+  initialMessage: string
+  initialLevel: BroadcastLevel
+}) {
+  const t = useTranslations('common.broadcast')
+  const [message, setMessage] = useState(initialMessage)
+  const [level, setLevel] = useState<BroadcastLevel>(initialLevel)
+  const [isPending, startTransition] = useTransition()
+
+  const save = (text: string, cleared: boolean) => {
+    startTransition(async () => {
+      const result = await setSystemSettings({
+        [SYSTEM_SETTING_KEYS.BROADCAST_MESSAGE]: text,
+        [SYSTEM_SETTING_KEYS.BROADCAST_LEVEL]: level,
+      })
+      if (!result.success) {
+        toast.error(result.error ?? 'Failed to save')
+        return
+      }
+      setMessage(text)
+      toast.success(cleared ? t('cleared') : t('posted'))
+    })
+  }
+
+  const trimmed = message.trim()
+
+  return (
+    <AppCard
+      icon={Megaphone}
+      title={t('title')}
+      description={t('description')}
+      contentClassName="space-y-4"
+    >
+      <div className="space-y-2">
+        <Label htmlFor="broadcast-message">{t('message')}</Label>
+        <Textarea
+          id="broadcast-message"
+          value={message}
+          onChange={(event) => setMessage(event.target.value)}
+          maxLength={BROADCAST_MAX_LENGTH}
+          rows={3}
+          placeholder={t('placeholder')}
+        />
+        <p className="text-right text-xs text-muted-foreground tabular-nums">
+          {trimmed.length} / {BROADCAST_MAX_LENGTH}
+        </p>
+      </div>
+
+      <div className="space-y-2">
+        <Label>{t('level')}</Label>
+        <div className="flex flex-wrap gap-2">
+          {BROADCAST_LEVELS.map((option) => (
+            <Button
+              key={option}
+              type="button"
+              size="sm"
+              variant={level === option ? 'default' : 'outline'}
+              onClick={() => setLevel(option)}
+            >
+              {t(
+                option === 'info'
+                  ? 'levelInfo'
+                  : option === 'warning'
+                    ? 'levelWarning'
+                    : 'levelCritical'
+              )}
+            </Button>
+          ))}
+        </div>
+      </div>
+
+      {/* The real banner, not a mock-up of one. Anything that goes to every
+          screen in every workshop is worth seeing before it does. */}
+      {trimmed.length > 0 && (
+        <div className="overflow-hidden rounded-md border">
+          <BroadcastBanner
+            preview
+            broadcast={{ message: trimmed, level, updatedAt: 'preview' }}
+          />
+        </div>
+      )}
+
+      <div className="flex flex-wrap gap-2">
+        <Button onClick={() => save(trimmed, false)} disabled={isPending || !trimmed}>
+          {t('post')}
+        </Button>
+        <Button
+          variant="outline"
+          onClick={() => save('', true)}
+          disabled={isPending || initialMessage.length === 0}
+        >
+          {t('clear')}
+        </Button>
+      </div>
+    </AppCard>
+  )
+}

+ 19 - 0
src/features/admin/Schema/systemSettingsSchema.ts

@@ -50,6 +50,18 @@ export const SYSTEM_SETTING_KEYS = {
   SES_FROM_EMAIL: "ses.fromEmail",
   SES_FROM_EMAIL: "ses.fromEmail",
   SES_FROM_NAME: "ses.fromName",
   SES_FROM_NAME: "ses.fromName",
 
 
+  // A notice shown to everyone, set from the admin panel. Meant for the
+  // things nobody can work around and everybody needs to hear at once, an
+  // infrastructure incident being the usual one.
+  //
+  // The timestamp is what makes it repeatable: it is the key each person's
+  // dismissal is remembered against, so editing the text brings the banner
+  // back for everybody rather than leaving it hidden from whoever had already
+  // waved the last one away.
+  BROADCAST_MESSAGE: "broadcast.message",
+  BROADCAST_LEVEL: "broadcast.level",
+  BROADCAST_UPDATED_AT: "broadcast.updatedAt",
+
   // In-app support requests. Written from the torqvoice.com admin dashboard
   // In-app support requests. Written from the torqvoice.com admin dashboard
   // rather than from this app: whether a workshop may reach support is a
   // rather than from this app: whether a workshop may reach support is a
   // platform decision, not one the workshop makes for itself.
   // platform decision, not one the workshop makes for itself.
@@ -64,4 +76,11 @@ export const ALL_SYSTEM_KEYS = Object.values(SYSTEM_SETTING_KEYS);
 
 
 export const systemSettingsUpdateSchema = z.record(z.string(), z.string());
 export const systemSettingsUpdateSchema = z.record(z.string(), z.string());
 
 
+/** How loudly the global notice is shown. */
+export const BROADCAST_LEVELS = ["info", "warning", "critical"] as const;
+export type BroadcastLevel = (typeof BROADCAST_LEVELS)[number];
+
+/** Long enough for what happened and what is being done, short enough to read. */
+export const BROADCAST_MAX_LENGTH = 400;
+
 export type SystemSettingsMap = Partial<Record<SystemSettingKey, string>>;
 export type SystemSettingsMap = Partial<Record<SystemSettingKey, string>>;

+ 86 - 0
src/lib/broadcast.ts

@@ -0,0 +1,86 @@
+import { cache } from 'react'
+import { db } from '@/lib/db'
+import {
+  BROADCAST_LEVELS,
+  BROADCAST_MAX_LENGTH,
+  SYSTEM_SETTING_KEYS,
+  type BroadcastLevel,
+} from '@/features/admin/Schema/systemSettingsSchema'
+
+/**
+ * Pages a workshop's own customers see, where a platform notice must not go.
+ *
+ * Deliberately its own list rather than reuse of the locale helper, which
+ * matches on an org id and so lets bare `/portal` and `/terms` through. This
+ * one is a promise to every workshop, and it is a prefix check on purpose:
+ * anything added under these roots is covered without anyone remembering to
+ * come back here.
+ *
+ * Two reasons it matters. These pages carry the workshop's branding, not ours,
+ * and a white-label licence exists precisely so Torqvoice does not appear on
+ * that paperwork. And a notice aimed at staff, about maintenance or an
+ * incident, is not something a customer opening an invoice should read.
+ */
+const CUSTOMER_FACING = ['/portal', '/share', '/terms']
+
+export function isCustomerFacingPath(pathname: string | null | undefined): boolean {
+  if (!pathname) return false
+  return CUSTOMER_FACING.some(
+    (root) => pathname === root || pathname.startsWith(`${root}/`)
+  )
+}
+
+export type Broadcast = {
+  message: string
+  level: BroadcastLevel
+  /** Changes whenever the notice does, which is what resets dismissals. */
+  updatedAt: string
+}
+
+/**
+ * The notice every visitor sees, if there is one.
+ *
+ * Read without authentication on purpose. An infrastructure incident is worth
+ * saying on the sign-in page too, and someone who cannot get in is exactly the
+ * person who most needs to know why.
+ *
+ * Cached per request: this runs in the root layout, so it is on the path of
+ * every page in the app, and it must stay a single indexed lookup that returns
+ * nothing almost every time.
+ */
+export const getBroadcast = cache(async (): Promise<Broadcast | null> => {
+  let rows: { key: string; value: string }[]
+  try {
+    rows = await db.systemSetting.findMany({
+      where: {
+        key: {
+          in: [
+            SYSTEM_SETTING_KEYS.BROADCAST_MESSAGE,
+            SYSTEM_SETTING_KEYS.BROADCAST_LEVEL,
+            SYSTEM_SETTING_KEYS.BROADCAST_UPDATED_AT,
+          ],
+        },
+      },
+      select: { key: true, value: true },
+    })
+  } catch {
+    // The banner sits in the root layout, so a database that is down or not
+    // yet migrated must not take every page down with it. No notice is the
+    // right answer when we cannot read one.
+    return null
+  }
+
+  const stored = new Map(rows.map((row) => [row.key, row.value]))
+  const message = stored.get(SYSTEM_SETTING_KEYS.BROADCAST_MESSAGE)?.trim()
+  if (!message) return null
+
+  const level = stored.get(SYSTEM_SETTING_KEYS.BROADCAST_LEVEL) as BroadcastLevel | undefined
+
+  return {
+    message: message.slice(0, BROADCAST_MAX_LENGTH),
+    level: level && BROADCAST_LEVELS.includes(level) ? level : 'info',
+    // Falling back to the text itself still gives dismissals something stable
+    // to key on, for a notice written before the timestamp existed.
+    updatedAt: stored.get(SYSTEM_SETTING_KEYS.BROADCAST_UPDATED_AT) ?? message,
+  }
+})