瀏覽代碼

Let licensed self-hosted installs brand the browser tab and app icon (#476)

* Let licensed self-hosted installs brand the browser tab and app icon

* Keep a failed icon cleanup from failing the saved branding change
Bernt Christian Egeland 3 天之前
父節點
當前提交
f812e825ec
共有 48 個文件被更改,包括 1933 次插入 和 43 次删除
  1. 23 1
      messages/de/admin.json
  2. 8 1
      messages/de/settings.json
  3. 23 1
      messages/en/admin.json
  4. 8 1
      messages/en/settings.json
  5. 23 1
      messages/es/admin.json
  6. 8 1
      messages/es/settings.json
  7. 23 1
      messages/fr/admin.json
  8. 8 1
      messages/fr/settings.json
  9. 23 1
      messages/it/admin.json
  10. 8 1
      messages/it/settings.json
  11. 23 1
      messages/lt/admin.json
  12. 8 1
      messages/lt/settings.json
  13. 23 1
      messages/nb/admin.json
  14. 8 1
      messages/nb/settings.json
  15. 23 1
      messages/nl/admin.json
  16. 8 1
      messages/nl/settings.json
  17. 23 1
      messages/pl/admin.json
  18. 8 1
      messages/pl/settings.json
  19. 23 1
      messages/pt-BR/admin.json
  20. 8 1
      messages/pt-BR/settings.json
  21. 23 1
      messages/pt-PT/admin.json
  22. 8 1
      messages/pt-PT/settings.json
  23. 23 1
      messages/ru/admin.json
  24. 8 1
      messages/ru/settings.json
  25. 23 1
      messages/tr/admin.json
  26. 8 1
      messages/tr/settings.json
  27. 0 0
      public/favicon.ico
  28. 217 0
      src/__tests__/api/app-icon.test.ts
  29. 161 0
      src/__tests__/features/admin/branding-card.test.tsx
  30. 50 0
      src/__tests__/features/admin/broadcast-settings.test.ts
  31. 90 0
      src/__tests__/features/settings/license-settings.test.tsx
  32. 147 0
      src/__tests__/lib/branding.test.ts
  33. 37 0
      src/__tests__/lib/files/manager.test.ts
  34. 16 2
      src/app/(authenticated)/admin/settings/page.tsx
  35. 6 1
      src/app/(authenticated)/layout.tsx
  36. 3 0
      src/app/(authenticated)/settings/license/page.tsx
  37. 150 0
      src/app/api/protected/admin/app-icon/route.ts
  38. 49 0
      src/app/api/public/app-icon/route.ts
  39. 31 2
      src/app/layout.tsx
  40. 33 7
      src/app/manifest.ts
  41. 21 4
      src/components/pwa-install-prompt.tsx
  42. 22 0
      src/features/admin/Actions/setSystemSettings.ts
  43. 18 0
      src/features/admin/Components/admin-settings.tsx
  44. 223 0
      src/features/admin/Components/branding-card.tsx
  45. 12 0
      src/features/admin/Schema/systemSettingsSchema.ts
  46. 102 1
      src/features/settings/Components/license-settings.tsx
  47. 113 0
      src/lib/branding.ts
  48. 29 0
      src/lib/files/manager.ts

+ 23 - 1
messages/de/admin.json

@@ -114,7 +114,29 @@
     "resendApiKeyHint": "Holen Sie Ihren API-Schlüssel von resend.com",
     "resendApiKeyHint": "Holen Sie Ihren API-Schlüssel von resend.com",
     "postmarkApiKeyHint": "Holen Sie Ihren Server-Token von postmarkapp.com",
     "postmarkApiKeyHint": "Holen Sie Ihren Server-Token von postmarkapp.com",
     "mailgunApiKeyHint": "Holen Sie Ihren API-Schlüssel von app.mailgun.com",
     "mailgunApiKeyHint": "Holen Sie Ihren API-Schlüssel von app.mailgun.com",
-    "sendgridApiKeyHint": "Holen Sie Ihren API-Schlüssel von app.sendgrid.com"
+    "sendgridApiKeyHint": "Holen Sie Ihren API-Schlüssel von app.sendgrid.com",
+    "branding": {
+      "title": "App-Branding",
+      "description": "Ändert den Titel im Browser-Tab, das Favicon sowie Namen und Symbol der installierten App auf dieser Installation. Dokumente und der Rest der App bleiben unverändert. Lassen Sie ein Feld leer, um den Standard zu verwenden.",
+      "nameLabel": "App-Name",
+      "nameHint": "Erscheint im Browser-Tab und als Name der installierten App.",
+      "save": "Namen speichern",
+      "saved": "App-Name gespeichert",
+      "saveFailed": "Der App-Name konnte nicht gespeichert werden",
+      "iconLabel": "App-Symbol",
+      "iconHint": "Ein quadratisches Bild mit mindestens 512 × 512 Pixeln. Am besten als PNG.",
+      "iconAlt": "Aktuelles App-Symbol",
+      "upload": "Hochladen",
+      "replace": "Ersetzen",
+      "remove": "Entfernen",
+      "uploaded": "App-Symbol aktualisiert",
+      "uploadFailed": "Das Symbol konnte nicht hochgeladen werden",
+      "removed": "App-Symbol entfernt",
+      "removeFailed": "Das Symbol konnte nicht entfernt werden",
+      "lockedBadge": "Lizenz erforderlich",
+      "lockedHint": "Eine White-Label-Lizenz schaltet App-Namen und App-Symbol frei.",
+      "lockedLink": "Zur Lizenzseite"
+    }
   },
   },
   "common": {
   "common": {
     "openMenu": "Menü öffnen"
     "openMenu": "Menü öffnen"

+ 8 - 1
messages/de/settings.json

@@ -1472,7 +1472,14 @@
     "unreachable": "torqvoice.com war nicht erreichbar. Die auf diesem Server gespeicherte Lizenz bleibt unverändert.",
     "unreachable": "torqvoice.com war nicht erreichbar. Die auf diesem Server gespeicherte Lizenz bleibt unverändert.",
     "unverifiedHint": "Dieser Server konnte die Lizenz seit {days} Tagen nicht bei torqvoice.com bestätigen. Nach {max} Tagen ohne erfolgreiche Prüfung kehrt das Torqvoice-Branding zurück.",
     "unverifiedHint": "Dieser Server konnte die Lizenz seit {days} Tagen nicht bei torqvoice.com bestätigen. Nach {max} Tagen ohne erfolgreiche Prüfung kehrt das Torqvoice-Branding zurück.",
     "rejected": "torqvoice.com hat diesen Schlüssel nicht akzeptiert: {reason}",
     "rejected": "torqvoice.com hat diesen Schlüssel nicht akzeptiert: {reason}",
-    "signedHint": "Die Validierung wird von torqvoice.com signiert und täglich erneuert, daher braucht der Server ausgehenden Zugriff auf torqvoice.com."
+    "signedHint": "Die Validierung wird von torqvoice.com signiert und täglich erneuert, daher braucht der Server ausgehenden Zugriff auf torqvoice.com.",
+    "makeItYours": "Eigene Marke zeigen",
+    "makeItYoursHint": "Das Torqvoice-Zeichen ist weg. Zwei Schritte noch, dann sieht die App nach dem eigenen Betrieb aus.",
+    "linkAppName": "App-Namen festlegen",
+    "linkAppIcon": "Eigenes Favicon und App-Symbol hinzufügen",
+    "adminOnlyHint": "Nur ein Super-Admin kann App-Namen und App-Symbol ändern. Wenden Sie sich an die Person, die diese Installation betreibt.",
+    "makeItYoursLocked": "Eine White-Label-Lizenz schaltet App-Namen und App-Symbol frei.",
+    "needsLicense": "Lizenz erforderlich"
   },
   },
   "about": {
   "about": {
     "title": "Uber Torqvoice",
     "title": "Uber Torqvoice",

+ 23 - 1
messages/en/admin.json

@@ -138,7 +138,29 @@
     "resendApiKeyHint": "Get your API key from resend.com",
     "resendApiKeyHint": "Get your API key from resend.com",
     "postmarkApiKeyHint": "Get your Server Token from postmarkapp.com",
     "postmarkApiKeyHint": "Get your Server Token from postmarkapp.com",
     "mailgunApiKeyHint": "Get your API key from app.mailgun.com",
     "mailgunApiKeyHint": "Get your API key from app.mailgun.com",
-    "sendgridApiKeyHint": "Get your API key from app.sendgrid.com"
+    "sendgridApiKeyHint": "Get your API key from app.sendgrid.com",
+    "branding": {
+      "title": "App branding",
+      "description": "Changes the browser tab title, the favicon and the installed app's name and icon on this install. Documents and the rest of the app are not affected. Leave a field empty to use the default.",
+      "nameLabel": "App name",
+      "nameHint": "Shown in the browser tab and as the name of the installed app.",
+      "save": "Save name",
+      "saved": "App name saved",
+      "saveFailed": "Could not save the app name",
+      "iconLabel": "App icon",
+      "iconHint": "A square image, at least 512 by 512 pixels. PNG works best.",
+      "iconAlt": "Current app icon",
+      "upload": "Upload",
+      "replace": "Replace",
+      "remove": "Remove",
+      "uploaded": "App icon updated",
+      "uploadFailed": "Could not upload the icon",
+      "removed": "App icon removed",
+      "removeFailed": "Could not remove the icon",
+      "lockedBadge": "Needs a licence",
+      "lockedHint": "A white-label licence unlocks the app name and icon.",
+      "lockedLink": "Open the License page"
+    }
   },
   },
   "common": {
   "common": {
     "openMenu": "Open menu"
     "openMenu": "Open menu"

+ 8 - 1
messages/en/settings.json

@@ -1472,7 +1472,14 @@
     "unreachable": "torqvoice.com could not be reached. The licence stored on this server is unchanged.",
     "unreachable": "torqvoice.com could not be reached. The licence stored on this server is unchanged.",
     "unverifiedHint": "This server has not been able to confirm the licence with torqvoice.com for {days} days. Torqvoice branding returns after {max} days without a successful check.",
     "unverifiedHint": "This server has not been able to confirm the licence with torqvoice.com for {days} days. Torqvoice branding returns after {max} days without a successful check.",
     "rejected": "torqvoice.com did not accept this key: {reason}",
     "rejected": "torqvoice.com did not accept this key: {reason}",
-    "signedHint": "Validation is signed by torqvoice.com and re-checked daily, so the server needs outbound access to torqvoice.com."
+    "signedHint": "Validation is signed by torqvoice.com and re-checked daily, so the server needs outbound access to torqvoice.com.",
+    "makeItYours": "Make it yours",
+    "makeItYoursHint": "The Torqvoice mark is gone. Two more steps make the app look like yours.",
+    "linkAppName": "Set your app name",
+    "linkAppIcon": "Add your own favicon and app icon",
+    "adminOnlyHint": "Only a super admin can change the app name and icon. Ask the person who runs this installation.",
+    "makeItYoursLocked": "A white-label licence unlocks the app name and icon.",
+    "needsLicense": "Needs a licence"
   },
   },
   "about": {
   "about": {
     "title": "About Torqvoice",
     "title": "About Torqvoice",

+ 23 - 1
messages/es/admin.json

@@ -114,7 +114,29 @@
     "resendApiKeyHint": "Obtenga su clave API de resend.com",
     "resendApiKeyHint": "Obtenga su clave API de resend.com",
     "postmarkApiKeyHint": "Obtenga su Server Token de postmarkapp.com",
     "postmarkApiKeyHint": "Obtenga su Server Token de postmarkapp.com",
     "mailgunApiKeyHint": "Obtenga su clave API de app.mailgun.com",
     "mailgunApiKeyHint": "Obtenga su clave API de app.mailgun.com",
-    "sendgridApiKeyHint": "Obtenga su clave API de app.sendgrid.com"
+    "sendgridApiKeyHint": "Obtenga su clave API de app.sendgrid.com",
+    "branding": {
+      "title": "Marca de la aplicación",
+      "description": "Cambia el título de la pestaña del navegador, el favicon y el nombre y el icono de la aplicación instalada en esta instalación. Los documentos y el resto de la aplicación no cambian. Deja un campo vacío para usar el valor predeterminado.",
+      "nameLabel": "Nombre de la aplicación",
+      "nameHint": "Aparece en la pestaña del navegador y como nombre de la aplicación instalada.",
+      "save": "Guardar nombre",
+      "saved": "Nombre de la aplicación guardado",
+      "saveFailed": "No se pudo guardar el nombre de la aplicación",
+      "iconLabel": "Icono de la aplicación",
+      "iconHint": "Una imagen cuadrada de al menos 512 × 512 píxeles. Mejor en PNG.",
+      "iconAlt": "Icono actual de la aplicación",
+      "upload": "Subir",
+      "replace": "Reemplazar",
+      "remove": "Quitar",
+      "uploaded": "Icono de la aplicación actualizado",
+      "uploadFailed": "No se pudo subir el icono",
+      "removed": "Icono de la aplicación eliminado",
+      "removeFailed": "No se pudo quitar el icono",
+      "lockedBadge": "Requiere licencia",
+      "lockedHint": "Una licencia white-label desbloquea el nombre y el icono de la aplicación.",
+      "lockedLink": "Abrir la página de licencia"
+    }
   },
   },
   "common": {
   "common": {
     "openMenu": "Abrir menú"
     "openMenu": "Abrir menú"

+ 8 - 1
messages/es/settings.json

@@ -1472,7 +1472,14 @@
     "unreachable": "No se pudo conectar con torqvoice.com. La licencia guardada en este servidor no ha cambiado.",
     "unreachable": "No se pudo conectar con torqvoice.com. La licencia guardada en este servidor no ha cambiado.",
     "unverifiedHint": "Este servidor no ha podido confirmar la licencia con torqvoice.com desde hace {days} días. La marca Torqvoice vuelve tras {max} días sin una comprobación correcta.",
     "unverifiedHint": "Este servidor no ha podido confirmar la licencia con torqvoice.com desde hace {days} días. La marca Torqvoice vuelve tras {max} días sin una comprobación correcta.",
     "rejected": "torqvoice.com no aceptó esta clave: {reason}",
     "rejected": "torqvoice.com no aceptó esta clave: {reason}",
-    "signedHint": "La validación está firmada por torqvoice.com y se repite a diario, por lo que el servidor necesita acceso saliente a torqvoice.com."
+    "signedHint": "La validación está firmada por torqvoice.com y se repite a diario, por lo que el servidor necesita acceso saliente a torqvoice.com.",
+    "makeItYours": "Hazlo tuyo",
+    "makeItYoursHint": "La marca Torqvoice ya no aparece. Dos pasos más y la aplicación lucirá como tuya.",
+    "linkAppName": "Definir el nombre de la aplicación",
+    "linkAppIcon": "Añadir tu propio favicon e icono de la aplicación",
+    "adminOnlyHint": "Solo un superadministrador puede cambiar el nombre y el icono de la aplicación. Pide ayuda a quien gestiona esta instalación.",
+    "makeItYoursLocked": "Una licencia white-label desbloquea el nombre y el icono de la aplicación.",
+    "needsLicense": "Requiere licencia"
   },
   },
   "about": {
   "about": {
     "title": "Acerca de Torqvoice",
     "title": "Acerca de Torqvoice",

+ 23 - 1
messages/fr/admin.json

@@ -114,7 +114,29 @@
     "resendApiKeyHint": "Obtenez votre clé API sur resend.com",
     "resendApiKeyHint": "Obtenez votre clé API sur resend.com",
     "postmarkApiKeyHint": "Obtenez votre Server Token sur postmarkapp.com",
     "postmarkApiKeyHint": "Obtenez votre Server Token sur postmarkapp.com",
     "mailgunApiKeyHint": "Obtenez votre clé API sur app.mailgun.com",
     "mailgunApiKeyHint": "Obtenez votre clé API sur app.mailgun.com",
-    "sendgridApiKeyHint": "Obtenez votre clé API sur app.sendgrid.com"
+    "sendgridApiKeyHint": "Obtenez votre clé API sur app.sendgrid.com",
+    "branding": {
+      "title": "Image de marque de l'application",
+      "description": "Modifie le titre de l'onglet du navigateur, le favicon ainsi que le nom et l'icône de l'application installée sur cette installation. Les documents et le reste de l'application ne changent pas. Laissez un champ vide pour garder la valeur par défaut.",
+      "nameLabel": "Nom de l'application",
+      "nameHint": "Affiché dans l'onglet du navigateur et comme nom de l'application installée.",
+      "save": "Enregistrer le nom",
+      "saved": "Nom de l'application enregistré",
+      "saveFailed": "Impossible d'enregistrer le nom de l'application",
+      "iconLabel": "Icône de l'application",
+      "iconHint": "Une image carrée d’au moins 512 × 512 pixels. Le PNG donne le meilleur résultat.",
+      "iconAlt": "Icône actuelle de l'application",
+      "upload": "Téléverser",
+      "replace": "Remplacer",
+      "remove": "Supprimer",
+      "uploaded": "Icône de l'application mise à jour",
+      "uploadFailed": "Impossible de téléverser l'icône",
+      "removed": "Icône de l'application supprimée",
+      "removeFailed": "Impossible de supprimer l'icône",
+      "lockedBadge": "Licence requise",
+      "lockedHint": "Une licence white-label débloque le nom et l'icône de l'application.",
+      "lockedLink": "Ouvrir la page Licence"
+    }
   },
   },
   "common": {
   "common": {
     "openMenu": "Ouvrir le menu"
     "openMenu": "Ouvrir le menu"

+ 8 - 1
messages/fr/settings.json

@@ -1472,7 +1472,14 @@
     "unreachable": "Impossible de joindre torqvoice.com. La licence enregistrée sur ce serveur reste inchangée.",
     "unreachable": "Impossible de joindre torqvoice.com. La licence enregistrée sur ce serveur reste inchangée.",
     "unverifiedHint": "Ce serveur n'a pas pu confirmer la licence auprès de torqvoice.com depuis {days} jours. La marque Torqvoice réapparaît après {max} jours sans vérification réussie.",
     "unverifiedHint": "Ce serveur n'a pas pu confirmer la licence auprès de torqvoice.com depuis {days} jours. La marque Torqvoice réapparaît après {max} jours sans vérification réussie.",
     "rejected": "torqvoice.com n'a pas accepté cette clé : {reason}",
     "rejected": "torqvoice.com n'a pas accepté cette clé : {reason}",
-    "signedHint": "La validation est signée par torqvoice.com et renouvelée chaque jour ; le serveur doit donc pouvoir joindre torqvoice.com."
+    "signedHint": "La validation est signée par torqvoice.com et renouvelée chaque jour ; le serveur doit donc pouvoir joindre torqvoice.com.",
+    "makeItYours": "Faites-le vôtre",
+    "makeItYoursHint": "La marque Torqvoice a disparu. Deux étapes de plus et l'application sera à votre image.",
+    "linkAppName": "Définir le nom de l'application",
+    "linkAppIcon": "Ajouter votre propre favicon et icône d'application",
+    "adminOnlyHint": "Seul un super-administrateur peut modifier le nom et l'icône de l'application. Adressez-vous à la personne qui gère cette installation.",
+    "makeItYoursLocked": "Une licence white-label débloque le nom et l'icône de l'application.",
+    "needsLicense": "Licence requise"
   },
   },
   "about": {
   "about": {
     "title": "A propos de Torqvoice",
     "title": "A propos de Torqvoice",

+ 23 - 1
messages/it/admin.json

@@ -114,7 +114,29 @@
     "resendApiKeyHint": "Ottieni la tua chiave API da resend.com",
     "resendApiKeyHint": "Ottieni la tua chiave API da resend.com",
     "postmarkApiKeyHint": "Ottieni il tuo Server Token da postmarkapp.com",
     "postmarkApiKeyHint": "Ottieni il tuo Server Token da postmarkapp.com",
     "mailgunApiKeyHint": "Ottieni la tua chiave API da app.mailgun.com",
     "mailgunApiKeyHint": "Ottieni la tua chiave API da app.mailgun.com",
-    "sendgridApiKeyHint": "Ottieni la tua chiave API da app.sendgrid.com"
+    "sendgridApiKeyHint": "Ottieni la tua chiave API da app.sendgrid.com",
+    "branding": {
+      "title": "Personalizzazione dell'app",
+      "description": "Cambia il titolo della scheda del browser, la favicon e il nome e l'icona dell'app installata su questa installazione. I documenti e il resto dell'app restano invariati. Lascia un campo vuoto per usare il valore predefinito.",
+      "nameLabel": "Nome dell'app",
+      "nameHint": "Compare nella scheda del browser e come nome dell'app installata.",
+      "save": "Salva nome",
+      "saved": "Nome dell'app salvato",
+      "saveFailed": "Impossibile salvare il nome dell'app",
+      "iconLabel": "Icona dell'app",
+      "iconHint": "Un’immagine quadrata di almeno 512 × 512 pixel. Meglio in PNG.",
+      "iconAlt": "Icona attuale dell'app",
+      "upload": "Carica",
+      "replace": "Sostituisci",
+      "remove": "Rimuovi",
+      "uploaded": "Icona dell'app aggiornata",
+      "uploadFailed": "Impossibile caricare l'icona",
+      "removed": "Icona dell'app rimossa",
+      "removeFailed": "Impossibile rimuovere l'icona",
+      "lockedBadge": "Richiede una licenza",
+      "lockedHint": "Una licenza white-label sblocca nome e icona dell'app.",
+      "lockedLink": "Apri la pagina Licenza"
+    }
   },
   },
   "common": {
   "common": {
     "openMenu": "Apri menu"
     "openMenu": "Apri menu"

+ 8 - 1
messages/it/settings.json

@@ -1472,7 +1472,14 @@
     "unreachable": "Impossibile raggiungere torqvoice.com. La licenza salvata su questo server resta invariata.",
     "unreachable": "Impossibile raggiungere torqvoice.com. La licenza salvata su questo server resta invariata.",
     "unverifiedHint": "Questo server non riesce a confermare la licenza con torqvoice.com da {days} giorni. Il marchio Torqvoice torna dopo {max} giorni senza una verifica riuscita.",
     "unverifiedHint": "Questo server non riesce a confermare la licenza con torqvoice.com da {days} giorni. Il marchio Torqvoice torna dopo {max} giorni senza una verifica riuscita.",
     "rejected": "torqvoice.com non ha accettato questa chiave: {reason}",
     "rejected": "torqvoice.com non ha accettato questa chiave: {reason}",
-    "signedHint": "La convalida è firmata da torqvoice.com e ripetuta ogni giorno, quindi il server deve poter raggiungere torqvoice.com."
+    "signedHint": "La convalida è firmata da torqvoice.com e ripetuta ogni giorno, quindi il server deve poter raggiungere torqvoice.com.",
+    "makeItYours": "Rendilo tuo",
+    "makeItYoursHint": "Il marchio Torqvoice non c'è più. Altri due passaggi e l'app avrà il tuo aspetto.",
+    "linkAppName": "Imposta il nome dell'app",
+    "linkAppIcon": "Aggiungi la tua favicon e l'icona dell'app",
+    "adminOnlyHint": "Solo un super amministratore può cambiare nome e icona dell'app. Rivolgiti a chi gestisce questa installazione.",
+    "makeItYoursLocked": "Una licenza white-label sblocca nome e icona dell'app.",
+    "needsLicense": "Richiede una licenza"
   },
   },
   "about": {
   "about": {
     "title": "Informazioni su Torqvoice",
     "title": "Informazioni su Torqvoice",

+ 23 - 1
messages/lt/admin.json

@@ -114,7 +114,29 @@
     "resendApiKeyHint": "Gaukite API raktą iš resend.com",
     "resendApiKeyHint": "Gaukite API raktą iš resend.com",
     "postmarkApiKeyHint": "Gaukite serverio žetoną iš postmarkapp.com",
     "postmarkApiKeyHint": "Gaukite serverio žetoną iš postmarkapp.com",
     "mailgunApiKeyHint": "Gaukite API raktą iš app.mailgun.com",
     "mailgunApiKeyHint": "Gaukite API raktą iš app.mailgun.com",
-    "sendgridApiKeyHint": "Gaukite API raktą iš app.sendgrid.com"
+    "sendgridApiKeyHint": "Gaukite API raktą iš app.sendgrid.com",
+    "branding": {
+      "title": "Programėlės prekės ženklas",
+      "description": "Pakeičia naršyklės skirtuko pavadinimą, svetainės piktogramą ir įdiegtos programėlės pavadinimą bei piktogramą šiame diegime. Dokumentai ir likusi programėlės dalis nesikeičia. Palikite lauką tuščią, jei norite numatytosios reikšmės.",
+      "nameLabel": "Programėlės pavadinimas",
+      "nameHint": "Rodomas naršyklės skirtuke ir kaip įdiegtos programėlės pavadinimas.",
+      "save": "Išsaugoti pavadinimą",
+      "saved": "Programėlės pavadinimas išsaugotas",
+      "saveFailed": "Nepavyko išsaugoti programėlės pavadinimo",
+      "iconLabel": "Programėlės piktograma",
+      "iconHint": "Kvadratinis vaizdas, ne mažesnis nei 512 × 512 pikselių. Geriausia PNG formatu.",
+      "iconAlt": "Dabartinė programėlės piktograma",
+      "upload": "Įkelti",
+      "replace": "Pakeisti",
+      "remove": "Pašalinti",
+      "uploaded": "Programėlės piktograma atnaujinta",
+      "uploadFailed": "Nepavyko įkelti piktogramos",
+      "removed": "Programėlės piktograma pašalinta",
+      "removeFailed": "Nepavyko pašalinti piktogramos",
+      "lockedBadge": "Reikia licencijos",
+      "lockedHint": "White-label licencija atrakina programos pavadinimą ir piktogramą.",
+      "lockedLink": "Atidaryti licencijos puslapį"
+    }
   },
   },
   "common": {
   "common": {
     "openMenu": "Atidaryti meniu"
     "openMenu": "Atidaryti meniu"

+ 8 - 1
messages/lt/settings.json

@@ -1472,7 +1472,14 @@
     "unreachable": "Nepavyko pasiekti torqvoice.com. Šiame serveryje saugoma licencija nepakito.",
     "unreachable": "Nepavyko pasiekti torqvoice.com. Šiame serveryje saugoma licencija nepakito.",
     "unverifiedHint": "Šis serveris jau {days} d. negali patvirtinti licencijos su torqvoice.com. Po {max} d. be sėkmingo patikrinimo Torqvoice prekės ženklas grįžta.",
     "unverifiedHint": "Šis serveris jau {days} d. negali patvirtinti licencijos su torqvoice.com. Po {max} d. be sėkmingo patikrinimo Torqvoice prekės ženklas grįžta.",
     "rejected": "torqvoice.com nepriėmė šio rakto: {reason}",
     "rejected": "torqvoice.com nepriėmė šio rakto: {reason}",
-    "signedHint": "Patikrinimą pasirašo torqvoice.com ir jis kartojamas kasdien, todėl serveriui reikia išeinančio ryšio su torqvoice.com."
+    "signedHint": "Patikrinimą pasirašo torqvoice.com ir jis kartojamas kasdien, todėl serveriui reikia išeinančio ryšio su torqvoice.com.",
+    "makeItYours": "Padarykite savo",
+    "makeItYoursHint": "Torqvoice ženklo nebėra. Dar du žingsniai, ir programa atrodys kaip jūsų.",
+    "linkAppName": "Nustatyti programos pavadinimą",
+    "linkAppIcon": "Pridėti savo favicon ir programos piktogramą",
+    "adminOnlyHint": "Programos pavadinimą ir piktogramą gali keisti tik super administratorius. Kreipkitės į šios instaliacijos valdytoją.",
+    "makeItYoursLocked": "White-label licencija atrakina programos pavadinimą ir piktogramą.",
+    "needsLicense": "Reikia licencijos"
   },
   },
   "about": {
   "about": {
     "title": "Apie Torqvoice",
     "title": "Apie Torqvoice",

+ 23 - 1
messages/nb/admin.json

@@ -114,7 +114,29 @@
     "resendApiKeyHint": "Hent API-nøkkelen din fra resend.com",
     "resendApiKeyHint": "Hent API-nøkkelen din fra resend.com",
     "postmarkApiKeyHint": "Hent din Server Token fra postmarkapp.com",
     "postmarkApiKeyHint": "Hent din Server Token fra postmarkapp.com",
     "mailgunApiKeyHint": "Hent API-nøkkelen din fra app.mailgun.com",
     "mailgunApiKeyHint": "Hent API-nøkkelen din fra app.mailgun.com",
-    "sendgridApiKeyHint": "Hent API-nøkkelen din fra app.sendgrid.com"
+    "sendgridApiKeyHint": "Hent API-nøkkelen din fra app.sendgrid.com",
+    "branding": {
+      "title": "Appens merkevare",
+      "description": "Endrer tittelen i nettleserfanen, favikonet og navnet og ikonet til den installerte appen på denne installasjonen. Dokumenter og resten av appen påvirkes ikke. La et felt stå tomt for å bruke standarden.",
+      "nameLabel": "Appnavn",
+      "nameHint": "Vises i nettleserfanen og som navnet på den installerte appen.",
+      "save": "Lagre navn",
+      "saved": "Appnavnet er lagret",
+      "saveFailed": "Kunne ikke lagre appnavnet",
+      "iconLabel": "Appikon",
+      "iconHint": "Et kvadratisk bilde på minst 512 × 512 piksler. PNG fungerer best.",
+      "iconAlt": "Gjeldende appikon",
+      "upload": "Last opp",
+      "replace": "Bytt ut",
+      "remove": "Fjern",
+      "uploaded": "Appikonet er oppdatert",
+      "uploadFailed": "Kunne ikke laste opp ikonet",
+      "removed": "Appikonet er fjernet",
+      "removeFailed": "Kunne ikke fjerne ikonet",
+      "lockedBadge": "Krever lisens",
+      "lockedHint": "En white-label-lisens låser opp appnavn og appikon.",
+      "lockedLink": "Åpne lisenssiden"
+    }
   },
   },
   "common": {
   "common": {
     "openMenu": "Åpne meny"
     "openMenu": "Åpne meny"

+ 8 - 1
messages/nb/settings.json

@@ -1472,7 +1472,14 @@
     "unreachable": "torqvoice.com kunne ikke nås. Lisensen som er lagret på denne serveren er uendret.",
     "unreachable": "torqvoice.com kunne ikke nås. Lisensen som er lagret på denne serveren er uendret.",
     "unverifiedHint": "Denne serveren har ikke kunnet bekrefte lisensen hos torqvoice.com på {days} dager. Torqvoice-merkingen kommer tilbake etter {max} dager uten en vellykket sjekk.",
     "unverifiedHint": "Denne serveren har ikke kunnet bekrefte lisensen hos torqvoice.com på {days} dager. Torqvoice-merkingen kommer tilbake etter {max} dager uten en vellykket sjekk.",
     "rejected": "torqvoice.com godtok ikke denne nøkkelen: {reason}",
     "rejected": "torqvoice.com godtok ikke denne nøkkelen: {reason}",
-    "signedHint": "Valideringen signeres av torqvoice.com og gjentas daglig, så serveren trenger utgående tilgang til torqvoice.com."
+    "signedHint": "Valideringen signeres av torqvoice.com og gjentas daglig, så serveren trenger utgående tilgang til torqvoice.com.",
+    "makeItYours": "Gjør den til din",
+    "makeItYoursHint": "Torqvoice-merket er borte. To steg til, så ser appen ut som din.",
+    "linkAppName": "Velg appnavn",
+    "linkAppIcon": "Legg til eget favikon og appikon",
+    "adminOnlyHint": "Bare en superadministrator kan endre appnavn og appikon. Spør den som drifter denne installasjonen.",
+    "makeItYoursLocked": "En white-label-lisens låser opp appnavn og appikon.",
+    "needsLicense": "Krever lisens"
   },
   },
   "about": {
   "about": {
     "title": "Om Torqvoice",
     "title": "Om Torqvoice",

+ 23 - 1
messages/nl/admin.json

@@ -114,7 +114,29 @@
     "resendApiKeyHint": "Haal uw API-sleutel op via resend.com",
     "resendApiKeyHint": "Haal uw API-sleutel op via resend.com",
     "postmarkApiKeyHint": "Haal uw Server Token op via postmarkapp.com",
     "postmarkApiKeyHint": "Haal uw Server Token op via postmarkapp.com",
     "mailgunApiKeyHint": "Haal uw API-sleutel op via app.mailgun.com",
     "mailgunApiKeyHint": "Haal uw API-sleutel op via app.mailgun.com",
-    "sendgridApiKeyHint": "Haal uw API-sleutel op via app.sendgrid.com"
+    "sendgridApiKeyHint": "Haal uw API-sleutel op via app.sendgrid.com",
+    "branding": {
+      "title": "App-huisstijl",
+      "description": "Wijzigt de titel van het browsertabblad, het favicon en de naam en het pictogram van de geïnstalleerde app op deze installatie. Documenten en de rest van de app veranderen niet. Laat een veld leeg om de standaard te gebruiken.",
+      "nameLabel": "Appnaam",
+      "nameHint": "Te zien in het browsertabblad en als naam van de geïnstalleerde app.",
+      "save": "Naam opslaan",
+      "saved": "Appnaam opgeslagen",
+      "saveFailed": "De appnaam kon niet worden opgeslagen",
+      "iconLabel": "App-pictogram",
+      "iconHint": "Een vierkante afbeelding van minstens 512 × 512 pixels. PNG werkt het best.",
+      "iconAlt": "Huidig app-pictogram",
+      "upload": "Uploaden",
+      "replace": "Vervangen",
+      "remove": "Verwijderen",
+      "uploaded": "App-pictogram bijgewerkt",
+      "uploadFailed": "Het pictogram kon niet worden geüpload",
+      "removed": "App-pictogram verwijderd",
+      "removeFailed": "Het pictogram kon niet worden verwijderd",
+      "lockedBadge": "Licentie nodig",
+      "lockedHint": "Een white-label-licentie ontgrendelt de appnaam en het app-pictogram.",
+      "lockedLink": "Licentiepagina openen"
+    }
   },
   },
   "common": {
   "common": {
     "openMenu": "Menu openen"
     "openMenu": "Menu openen"

+ 8 - 1
messages/nl/settings.json

@@ -1472,7 +1472,14 @@
     "unreachable": "torqvoice.com was niet bereikbaar. De licentie op deze server is ongewijzigd.",
     "unreachable": "torqvoice.com was niet bereikbaar. De licentie op deze server is ongewijzigd.",
     "unverifiedHint": "Deze server kan de licentie al {days} dagen niet bevestigen bij torqvoice.com. Na {max} dagen zonder geslaagde controle keert de Torqvoice-branding terug.",
     "unverifiedHint": "Deze server kan de licentie al {days} dagen niet bevestigen bij torqvoice.com. Na {max} dagen zonder geslaagde controle keert de Torqvoice-branding terug.",
     "rejected": "torqvoice.com heeft deze sleutel niet geaccepteerd: {reason}",
     "rejected": "torqvoice.com heeft deze sleutel niet geaccepteerd: {reason}",
-    "signedHint": "De validatie wordt door torqvoice.com ondertekend en dagelijks herhaald, dus de server heeft uitgaande toegang tot torqvoice.com nodig."
+    "signedHint": "De validatie wordt door torqvoice.com ondertekend en dagelijks herhaald, dus de server heeft uitgaande toegang tot torqvoice.com nodig.",
+    "makeItYours": "Maak het van jou",
+    "makeItYoursHint": "Het Torqvoice-merk is weg. Nog twee stappen en de app ziet eruit als die van jou.",
+    "linkAppName": "Appnaam instellen",
+    "linkAppIcon": "Eigen favicon en app-pictogram toevoegen",
+    "adminOnlyHint": "Alleen een superbeheerder kan de appnaam en het app-pictogram wijzigen. Vraag het aan degene die deze installatie beheert.",
+    "makeItYoursLocked": "Een white-label-licentie ontgrendelt de appnaam en het app-pictogram.",
+    "needsLicense": "Licentie nodig"
   },
   },
   "about": {
   "about": {
     "title": "Over Torqvoice",
     "title": "Over Torqvoice",

+ 23 - 1
messages/pl/admin.json

@@ -114,7 +114,29 @@
     "resendApiKeyHint": "Pobierz klucz API z resend.com",
     "resendApiKeyHint": "Pobierz klucz API z resend.com",
     "postmarkApiKeyHint": "Pobierz Server Token z postmarkapp.com",
     "postmarkApiKeyHint": "Pobierz Server Token z postmarkapp.com",
     "mailgunApiKeyHint": "Pobierz klucz API z app.mailgun.com",
     "mailgunApiKeyHint": "Pobierz klucz API z app.mailgun.com",
-    "sendgridApiKeyHint": "Pobierz klucz API z app.sendgrid.com"
+    "sendgridApiKeyHint": "Pobierz klucz API z app.sendgrid.com",
+    "branding": {
+      "title": "Wygląd aplikacji",
+      "description": "Zmienia tytuł karty przeglądarki, favicon oraz nazwę i ikonę zainstalowanej aplikacji w tej instalacji. Dokumenty i reszta aplikacji pozostają bez zmian. Zostaw pole puste, aby użyć wartości domyślnej.",
+      "nameLabel": "Nazwa aplikacji",
+      "nameHint": "Widoczna na karcie przeglądarki i jako nazwa zainstalowanej aplikacji.",
+      "save": "Zapisz nazwę",
+      "saved": "Nazwa aplikacji zapisana",
+      "saveFailed": "Nie udało się zapisać nazwy aplikacji",
+      "iconLabel": "Ikona aplikacji",
+      "iconHint": "Kwadratowy obraz o rozmiarze co najmniej 512 × 512 pikseli. Najlepiej w formacie PNG.",
+      "iconAlt": "Obecna ikona aplikacji",
+      "upload": "Prześlij",
+      "replace": "Zamień",
+      "remove": "Usuń",
+      "uploaded": "Ikona aplikacji zaktualizowana",
+      "uploadFailed": "Nie udało się przesłać ikony",
+      "removed": "Ikona aplikacji usunięta",
+      "removeFailed": "Nie udało się usunąć ikony",
+      "lockedBadge": "Wymaga licencji",
+      "lockedHint": "Licencja white-label odblokowuje nazwę i ikonę aplikacji.",
+      "lockedLink": "Otwórz stronę licencji"
+    }
   },
   },
   "common": {
   "common": {
     "openMenu": "Otwórz menu"
     "openMenu": "Otwórz menu"

+ 8 - 1
messages/pl/settings.json

@@ -1472,7 +1472,14 @@
     "unreachable": "Nie udało się połączyć z torqvoice.com. Licencja zapisana na tym serwerze pozostaje bez zmian.",
     "unreachable": "Nie udało się połączyć z torqvoice.com. Licencja zapisana na tym serwerze pozostaje bez zmian.",
     "unverifiedHint": "Ten serwer od {days} dni nie może potwierdzić licencji w torqvoice.com. Po {max} dniach bez udanego sprawdzenia oznaczenie Torqvoice wraca.",
     "unverifiedHint": "Ten serwer od {days} dni nie może potwierdzić licencji w torqvoice.com. Po {max} dniach bez udanego sprawdzenia oznaczenie Torqvoice wraca.",
     "rejected": "torqvoice.com nie przyjął tego klucza: {reason}",
     "rejected": "torqvoice.com nie przyjął tego klucza: {reason}",
-    "signedHint": "Walidacja jest podpisywana przez torqvoice.com i powtarzana codziennie, więc serwer potrzebuje wychodzącego dostępu do torqvoice.com."
+    "signedHint": "Walidacja jest podpisywana przez torqvoice.com i powtarzana codziennie, więc serwer potrzebuje wychodzącego dostępu do torqvoice.com.",
+    "makeItYours": "Zrób to po swojemu",
+    "makeItYoursHint": "Znak Torqvoice zniknął. Jeszcze dwa kroki, a aplikacja będzie wyglądać jak Twoja.",
+    "linkAppName": "Ustaw nazwę aplikacji",
+    "linkAppIcon": "Dodaj własny favicon i ikonę aplikacji",
+    "adminOnlyHint": "Tylko superadministrator może zmienić nazwę i ikonę aplikacji. Zwróć się do osoby prowadzącej tę instalację.",
+    "makeItYoursLocked": "Licencja white-label odblokowuje nazwę i ikonę aplikacji.",
+    "needsLicense": "Wymaga licencji"
   },
   },
   "about": {
   "about": {
     "title": "O Torqvoice",
     "title": "O Torqvoice",

+ 23 - 1
messages/pt-BR/admin.json

@@ -114,7 +114,29 @@
     "resendApiKeyHint": "Obtenha sua chave de API em resend.com",
     "resendApiKeyHint": "Obtenha sua chave de API em resend.com",
     "postmarkApiKeyHint": "Obtenha seu Server Token em postmarkapp.com",
     "postmarkApiKeyHint": "Obtenha seu Server Token em postmarkapp.com",
     "mailgunApiKeyHint": "Obtenha sua chave de API em app.mailgun.com",
     "mailgunApiKeyHint": "Obtenha sua chave de API em app.mailgun.com",
-    "sendgridApiKeyHint": "Obtenha sua chave de API em app.sendgrid.com"
+    "sendgridApiKeyHint": "Obtenha sua chave de API em app.sendgrid.com",
+    "branding": {
+      "title": "Marca do aplicativo",
+      "description": "Altera o título da aba do navegador, o favicon e o nome e o ícone do aplicativo instalado nesta instalação. Os documentos e o restante do aplicativo não mudam. Deixe um campo vazio para usar o padrão.",
+      "nameLabel": "Nome do aplicativo",
+      "nameHint": "Aparece na aba do navegador e como nome do aplicativo instalado.",
+      "save": "Salvar nome",
+      "saved": "Nome do aplicativo salvo",
+      "saveFailed": "Não foi possível salvar o nome do aplicativo",
+      "iconLabel": "Ícone do aplicativo",
+      "iconHint": "Uma imagem quadrada de pelo menos 512 × 512 pixels. PNG funciona melhor.",
+      "iconAlt": "Ícone atual do aplicativo",
+      "upload": "Enviar",
+      "replace": "Substituir",
+      "remove": "Remover",
+      "uploaded": "Ícone do aplicativo atualizado",
+      "uploadFailed": "Não foi possível enviar o ícone",
+      "removed": "Ícone do aplicativo removido",
+      "removeFailed": "Não foi possível remover o ícone",
+      "lockedBadge": "Precisa de licença",
+      "lockedHint": "Uma licença white-label libera o nome e o ícone do aplicativo.",
+      "lockedLink": "Abrir a página de licença"
+    }
   },
   },
   "common": {
   "common": {
     "openMenu": "Abrir menu"
     "openMenu": "Abrir menu"

+ 8 - 1
messages/pt-BR/settings.json

@@ -1472,7 +1472,14 @@
     "unreachable": "Não foi possível acessar torqvoice.com. A licença salva neste servidor permanece inalterada.",
     "unreachable": "Não foi possível acessar torqvoice.com. A licença salva neste servidor permanece inalterada.",
     "unverifiedHint": "Este servidor não consegue confirmar a licença com torqvoice.com há {days} dias. A marca Torqvoice volta após {max} dias sem uma verificação bem-sucedida.",
     "unverifiedHint": "Este servidor não consegue confirmar a licença com torqvoice.com há {days} dias. A marca Torqvoice volta após {max} dias sem uma verificação bem-sucedida.",
     "rejected": "torqvoice.com não aceitou esta chave: {reason}",
     "rejected": "torqvoice.com não aceitou esta chave: {reason}",
-    "signedHint": "A validação é assinada por torqvoice.com e repetida diariamente, portanto o servidor precisa de acesso de saída a torqvoice.com."
+    "signedHint": "A validação é assinada por torqvoice.com e repetida diariamente, portanto o servidor precisa de acesso de saída a torqvoice.com.",
+    "makeItYours": "Deixe com a sua cara",
+    "makeItYoursHint": "A marca Torqvoice sumiu. Mais dois passos e o aplicativo fica com a sua cara.",
+    "linkAppName": "Definir o nome do aplicativo",
+    "linkAppIcon": "Adicionar seu próprio favicon e ícone do aplicativo",
+    "adminOnlyHint": "Somente um superadministrador pode alterar o nome e o ícone do aplicativo. Fale com quem cuida desta instalação.",
+    "makeItYoursLocked": "Uma licença white-label libera o nome e o ícone do aplicativo.",
+    "needsLicense": "Precisa de licença"
   },
   },
   "about": {
   "about": {
     "title": "Sobre o Torqvoice",
     "title": "Sobre o Torqvoice",

+ 23 - 1
messages/pt-PT/admin.json

@@ -114,7 +114,29 @@
     "resendApiKeyHint": "Obtenha a sua chave de API em resend.com",
     "resendApiKeyHint": "Obtenha a sua chave de API em resend.com",
     "postmarkApiKeyHint": "Obtenha o seu Server Token em postmarkapp.com",
     "postmarkApiKeyHint": "Obtenha o seu Server Token em postmarkapp.com",
     "mailgunApiKeyHint": "Obtenha a sua chave de API em app.mailgun.com",
     "mailgunApiKeyHint": "Obtenha a sua chave de API em app.mailgun.com",
-    "sendgridApiKeyHint": "Obtenha a sua chave de API em app.sendgrid.com"
+    "sendgridApiKeyHint": "Obtenha a sua chave de API em app.sendgrid.com",
+    "branding": {
+      "title": "Marca da aplicação",
+      "description": "Altera o título do separador do navegador, o favicon e o nome e o ícone da aplicação instalada nesta instalação. Os documentos e o resto da aplicação não são afetados. Deixe um campo vazio para usar o valor predefinido.",
+      "nameLabel": "Nome da aplicação",
+      "nameHint": "Aparece no separador do navegador e como nome da aplicação instalada.",
+      "save": "Guardar nome",
+      "saved": "Nome da aplicação guardado",
+      "saveFailed": "Não foi possível guardar o nome da aplicação",
+      "iconLabel": "Ícone da aplicação",
+      "iconHint": "Uma imagem quadrada com pelo menos 512 × 512 píxeis. De preferência em PNG.",
+      "iconAlt": "Ícone atual da aplicação",
+      "upload": "Carregar",
+      "replace": "Substituir",
+      "remove": "Remover",
+      "uploaded": "Ícone da aplicação atualizado",
+      "uploadFailed": "Não foi possível carregar o ícone",
+      "removed": "Ícone da aplicação removido",
+      "removeFailed": "Não foi possível remover o ícone",
+      "lockedBadge": "Requer licença",
+      "lockedHint": "Uma licença white-label desbloqueia o nome e o ícone da aplicação.",
+      "lockedLink": "Abrir a página da licença"
+    }
   },
   },
   "common": {
   "common": {
     "openMenu": "Abrir menu"
     "openMenu": "Abrir menu"

+ 8 - 1
messages/pt-PT/settings.json

@@ -1472,7 +1472,14 @@
     "unreachable": "Não foi possível contactar torqvoice.com. A licença guardada neste servidor mantém-se inalterada.",
     "unreachable": "Não foi possível contactar torqvoice.com. A licença guardada neste servidor mantém-se inalterada.",
     "unverifiedHint": "Este servidor não consegue confirmar a licença com torqvoice.com há {days} dias. A marca Torqvoice volta a aparecer após {max} dias sem uma verificação bem-sucedida.",
     "unverifiedHint": "Este servidor não consegue confirmar a licença com torqvoice.com há {days} dias. A marca Torqvoice volta a aparecer após {max} dias sem uma verificação bem-sucedida.",
     "rejected": "torqvoice.com não aceitou esta chave: {reason}",
     "rejected": "torqvoice.com não aceitou esta chave: {reason}",
-    "signedHint": "A validação é assinada por torqvoice.com e repetida diariamente, por isso o servidor precisa de acesso de saída a torqvoice.com."
+    "signedHint": "A validação é assinada por torqvoice.com e repetida diariamente, por isso o servidor precisa de acesso de saída a torqvoice.com.",
+    "makeItYours": "Torne-a sua",
+    "makeItYoursHint": "A marca Torqvoice desapareceu. Mais dois passos e a aplicação fica com o seu aspeto.",
+    "linkAppName": "Definir o nome da aplicação",
+    "linkAppIcon": "Adicionar o seu próprio favicon e ícone da aplicação",
+    "adminOnlyHint": "Só um superadministrador pode alterar o nome e o ícone da aplicação. Fale com quem gere esta instalação.",
+    "makeItYoursLocked": "Uma licença white-label desbloqueia o nome e o ícone da aplicação.",
+    "needsLicense": "Requer licença"
   },
   },
   "about": {
   "about": {
     "title": "Sobre o Torqvoice",
     "title": "Sobre o Torqvoice",

+ 23 - 1
messages/ru/admin.json

@@ -114,7 +114,29 @@
     "resendApiKeyHint": "Получите ключ API на resend.com",
     "resendApiKeyHint": "Получите ключ API на resend.com",
     "postmarkApiKeyHint": "Получите токен сервера на postmarkapp.com",
     "postmarkApiKeyHint": "Получите токен сервера на postmarkapp.com",
     "mailgunApiKeyHint": "Получите ключ API на app.mailgun.com",
     "mailgunApiKeyHint": "Получите ключ API на app.mailgun.com",
-    "sendgridApiKeyHint": "Получите ключ API на app.sendgrid.com"
+    "sendgridApiKeyHint": "Получите ключ API на app.sendgrid.com",
+    "branding": {
+      "title": "Оформление приложения",
+      "description": "Меняет заголовок вкладки браузера, значок сайта, а также название и значок установленного приложения на этой установке. Документы и остальная часть приложения не меняются. Оставьте поле пустым, чтобы использовать значение по умолчанию.",
+      "nameLabel": "Название приложения",
+      "nameHint": "Показывается на вкладке браузера и как название установленного приложения.",
+      "save": "Сохранить название",
+      "saved": "Название приложения сохранено",
+      "saveFailed": "Не удалось сохранить название приложения",
+      "iconLabel": "Значок приложения",
+      "iconHint": "Квадратное изображение не меньше 512 × 512 пикселей. Лучше всего в PNG.",
+      "iconAlt": "Текущий значок приложения",
+      "upload": "Загрузить",
+      "replace": "Заменить",
+      "remove": "Удалить",
+      "uploaded": "Значок приложения обновлён",
+      "uploadFailed": "Не удалось загрузить значок",
+      "removed": "Значок приложения удалён",
+      "removeFailed": "Не удалось удалить значок",
+      "lockedBadge": "Нужна лицензия",
+      "lockedHint": "Лицензия white-label открывает название и значок приложения.",
+      "lockedLink": "Открыть страницу лицензии"
+    }
   },
   },
   "common": {
   "common": {
     "openMenu": "Открыть меню"
     "openMenu": "Открыть меню"

+ 8 - 1
messages/ru/settings.json

@@ -1472,7 +1472,14 @@
     "unreachable": "Не удалось связаться с torqvoice.com. Лицензия, сохранённая на этом сервере, не изменилась.",
     "unreachable": "Не удалось связаться с torqvoice.com. Лицензия, сохранённая на этом сервере, не изменилась.",
     "unverifiedHint": "Этот сервер уже {days} дн. не может подтвердить лицензию на torqvoice.com. Через {max} дн. без успешной проверки брендинг Torqvoice возвращается.",
     "unverifiedHint": "Этот сервер уже {days} дн. не может подтвердить лицензию на torqvoice.com. Через {max} дн. без успешной проверки брендинг Torqvoice возвращается.",
     "rejected": "torqvoice.com не принял этот ключ: {reason}",
     "rejected": "torqvoice.com не принял этот ключ: {reason}",
-    "signedHint": "Проверка подписывается torqvoice.com и повторяется ежедневно, поэтому серверу нужен исходящий доступ к torqvoice.com."
+    "signedHint": "Проверка подписывается torqvoice.com и повторяется ежедневно, поэтому серверу нужен исходящий доступ к torqvoice.com.",
+    "makeItYours": "Сделайте его своим",
+    "makeItYoursHint": "Знак Torqvoice убран. Ещё два шага, и приложение будет выглядеть по-вашему.",
+    "linkAppName": "Задать название приложения",
+    "linkAppIcon": "Добавить свой favicon и значок приложения",
+    "adminOnlyHint": "Изменить название и значок приложения может только суперадминистратор. Обратитесь к тому, кто обслуживает эту установку.",
+    "makeItYoursLocked": "Лицензия white-label открывает название и значок приложения.",
+    "needsLicense": "Нужна лицензия"
   },
   },
   "about": {
   "about": {
     "title": "О Torqvoice",
     "title": "О Torqvoice",

+ 23 - 1
messages/tr/admin.json

@@ -114,7 +114,29 @@
     "resendApiKeyHint": "API anahtarınızı resend.com adresinden alın",
     "resendApiKeyHint": "API anahtarınızı resend.com adresinden alın",
     "postmarkApiKeyHint": "Server Token'ınızı postmarkapp.com adresinden alın",
     "postmarkApiKeyHint": "Server Token'ınızı postmarkapp.com adresinden alın",
     "mailgunApiKeyHint": "API anahtarınızı app.mailgun.com adresinden alın",
     "mailgunApiKeyHint": "API anahtarınızı app.mailgun.com adresinden alın",
-    "sendgridApiKeyHint": "API anahtarınızı app.sendgrid.com adresinden alın"
+    "sendgridApiKeyHint": "API anahtarınızı app.sendgrid.com adresinden alın",
+    "branding": {
+      "title": "Uygulama markası",
+      "description": "Bu kurulumda tarayıcı sekmesi başlığını, site simgesini ve yüklü uygulamanın adını ve simgesini değiştirir. Belgeler ve uygulamanın geri kalanı etkilenmez. Varsayılanı kullanmak için alanı boş bırakın.",
+      "nameLabel": "Uygulama adı",
+      "nameHint": "Tarayıcı sekmesinde ve yüklü uygulamanın adı olarak görünür.",
+      "save": "Adı kaydet",
+      "saved": "Uygulama adı kaydedildi",
+      "saveFailed": "Uygulama adı kaydedilemedi",
+      "iconLabel": "Uygulama simgesi",
+      "iconHint": "En az 512 × 512 piksel boyutunda kare bir görsel. En iyi sonuç için PNG.",
+      "iconAlt": "Geçerli uygulama simgesi",
+      "upload": "Yükle",
+      "replace": "Değiştir",
+      "remove": "Kaldır",
+      "uploaded": "Uygulama simgesi güncellendi",
+      "uploadFailed": "Simge yüklenemedi",
+      "removed": "Uygulama simgesi kaldırıldı",
+      "removeFailed": "Simge kaldırılamadı",
+      "lockedBadge": "Lisans gerekir",
+      "lockedHint": "White-label lisansı uygulama adını ve simgesini açar.",
+      "lockedLink": "Lisans sayfasını aç"
+    }
   },
   },
   "common": {
   "common": {
     "openMenu": "Menüyü aç"
     "openMenu": "Menüyü aç"

+ 8 - 1
messages/tr/settings.json

@@ -1472,7 +1472,14 @@
     "unreachable": "torqvoice.com'a ulaşılamadı. Bu sunucuda kayıtlı lisans değişmedi.",
     "unreachable": "torqvoice.com'a ulaşılamadı. Bu sunucuda kayıtlı lisans değişmedi.",
     "unverifiedHint": "Bu sunucu {days} gündür lisansı torqvoice.com ile doğrulayamıyor. Başarılı bir kontrol olmadan {max} gün sonra Torqvoice markası geri döner.",
     "unverifiedHint": "Bu sunucu {days} gündür lisansı torqvoice.com ile doğrulayamıyor. Başarılı bir kontrol olmadan {max} gün sonra Torqvoice markası geri döner.",
     "rejected": "torqvoice.com bu anahtarı kabul etmedi: {reason}",
     "rejected": "torqvoice.com bu anahtarı kabul etmedi: {reason}",
-    "signedHint": "Doğrulama torqvoice.com tarafından imzalanır ve her gün yenilenir, bu nedenle sunucunun torqvoice.com'a giden erişimi olmalıdır."
+    "signedHint": "Doğrulama torqvoice.com tarafından imzalanır ve her gün yenilenir, bu nedenle sunucunun torqvoice.com'a giden erişimi olmalıdır.",
+    "makeItYours": "Kendinize göre uyarlayın",
+    "makeItYoursHint": "Torqvoice işareti kaldırıldı. İki adım daha ve uygulama sizin görünümünüze kavuşur.",
+    "linkAppName": "Uygulama adını belirleyin",
+    "linkAppIcon": "Kendi favicon ve uygulama simgenizi ekleyin",
+    "adminOnlyHint": "Uygulama adını ve simgesini yalnızca bir süper yönetici değiştirebilir. Bu kurulumu yöneten kişiye başvurun.",
+    "makeItYoursLocked": "White-label lisansı uygulama adını ve simgesini açar.",
+    "needsLicense": "Lisans gerekir"
   },
   },
   "about": {
   "about": {
     "title": "Torqvoice Hakkında",
     "title": "Torqvoice Hakkında",

+ 0 - 0
src/app/favicon.ico → public/favicon.ico


+ 217 - 0
src/__tests__/api/app-icon.test.ts

@@ -0,0 +1,217 @@
+/**
+ * @vitest-environment node
+ *
+ * The install's app icon: a public route that joins a stored setting onto a
+ * path, and an admin upload route that only a super admin on a licensed
+ * self-hosted install may use.
+ */
+import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
+import { mkdtempSync, mkdirSync, readdirSync, rmSync, writeFileSync } from 'node:fs'
+import os from 'node:os'
+import path from 'node:path'
+import sharp from 'sharp'
+
+vi.mock('@/lib/db', () => ({
+  db: {
+    systemSetting: {
+      findUnique: vi.fn(),
+      findMany: vi.fn(),
+      upsert: vi.fn(),
+      deleteMany: vi.fn(),
+    },
+    user: { findUnique: vi.fn() },
+  },
+}))
+vi.mock('@/lib/features', () => ({ isCloudMode: vi.fn(), installHasLicense: vi.fn() }))
+vi.mock('@/lib/cached-session', () => ({ getCachedSession: vi.fn() }))
+vi.mock('@/lib/session-scope', () => ({ sessionOrganizationScope: vi.fn(() => null) }))
+vi.mock('node:fs/promises', async (importOriginal) => {
+  const actual = await importOriginal<typeof import('node:fs/promises')>()
+  const fs = { ...actual, readFile: vi.fn(actual.readFile) }
+  return { ...fs, default: fs }
+})
+
+import { readFile } from 'node:fs/promises'
+import { db } from '@/lib/db'
+import { installHasLicense, isCloudMode } from '@/lib/features'
+import { getCachedSession } from '@/lib/cached-session'
+import { sessionOrganizationScope } from '@/lib/session-scope'
+import { brandingDir } from '@/lib/branding'
+import { GET } from '@/app/api/public/app-icon/route'
+import { DELETE, POST } from '@/app/api/protected/admin/app-icon/route'
+
+const mockSetting = vi.mocked(db.systemSetting.findUnique)
+const mockUpsert = vi.mocked(db.systemSetting.upsert)
+const mockDeleteMany = vi.mocked(db.systemSetting.deleteMany)
+const mockUser = vi.mocked(db.user.findUnique)
+const mockSession = vi.mocked(getCachedSession)
+const mockScope = vi.mocked(sessionOrganizationScope)
+const mockCloud = vi.mocked(isCloudMode)
+const mockLicensed = vi.mocked(installHasLicense)
+const mockReadFile = vi.mocked(readFile)
+
+const dataRoot = mkdtempSync(path.join(os.tmpdir(), 'app-icon-test-'))
+// The same path the routes write to, under this test's own data root.
+vi.stubEnv('DATA_ROOT', dataRoot)
+const iconDir = brandingDir()
+const trashDir = path.join(dataRoot, 'uploads', '.trash')
+
+afterAll(() => rmSync(dataRoot, { recursive: true, force: true }))
+
+function storedIcon(value: string | null) {
+  mockSetting.mockResolvedValue(value === null ? null : ({ value } as never))
+}
+
+beforeEach(() => {
+  vi.clearAllMocks()
+  vi.stubEnv('DATA_ROOT', dataRoot)
+  rmSync(path.join(dataRoot, 'uploads'), { recursive: true, force: true })
+  mkdirSync(iconDir, { recursive: true })
+  // Something worth stealing, one directory above the icons.
+  writeFileSync(path.join(dataRoot, 'uploads', 'system', 'secret.png'), 'secret')
+
+  mockCloud.mockReturnValue(false)
+  mockLicensed.mockResolvedValue(true)
+  mockScope.mockReturnValue(null)
+  mockSession.mockResolvedValue({ user: { id: 'u1' } } as never)
+  mockUser.mockResolvedValue({ isSuperAdmin: true } as never)
+  storedIcon(null)
+})
+
+describe('GET /api/public/app-icon', () => {
+  it('404s when no icon is set', async () => {
+    const res = await GET()
+    expect(res.status).toBe(404)
+  })
+
+  it('serves the stored icon with safe headers', async () => {
+    writeFileSync(path.join(iconDir, 'abc.png'), 'png-bytes')
+    storedIcon('abc.png')
+
+    const res = await GET()
+    expect(res.status).toBe(200)
+    expect(res.headers.get('content-type')).toBe('image/png')
+    expect(res.headers.get('cache-control')).toBe('public, max-age=3600')
+    expect(res.headers.get('x-content-type-options')).toBe('nosniff')
+    expect(Buffer.from(await res.arrayBuffer()).toString()).toBe('png-bytes')
+  })
+
+  it('404s when the setting names a file that is gone', async () => {
+    storedIcon('gone.png')
+    expect((await GET()).status).toBe(404)
+  })
+
+  it.each([
+    '../secret.png',
+    '../../etc/passwd',
+    'x/y.png',
+    '..\\secret.png',
+    'icon.svg',
+    '/etc/passwd',
+  ])('refuses %s without reading anything', async (value) => {
+    storedIcon(value)
+
+    const res = await GET()
+    expect([400, 404]).toContain(res.status)
+    expect(mockReadFile).not.toHaveBeenCalled()
+  })
+})
+
+function upload(file: File | null) {
+  const form = new FormData()
+  if (file) form.append('file', file)
+  return new Request('http://localhost/api/protected/admin/app-icon', {
+    method: 'POST',
+    body: form,
+  })
+}
+
+async function pngFile(width: number, height: number) {
+  const data = await sharp({
+    create: { width, height, channels: 4, background: { r: 200, g: 30, b: 30, alpha: 1 } },
+  })
+    .png()
+    .toBuffer()
+  return new File([new Uint8Array(data)], 'icon.png', { type: 'image/png' })
+}
+
+describe('POST /api/protected/admin/app-icon', () => {
+  it('refuses a signed-out caller', async () => {
+    mockSession.mockResolvedValue(null as never)
+    expect((await POST(upload(await pngFile(10, 10)))).status).toBe(401)
+  })
+
+  it('refuses someone who is not a super admin', async () => {
+    mockUser.mockResolvedValue({ isSuperAdmin: false } as never)
+    expect((await POST(upload(await pngFile(10, 10)))).status).toBe(403)
+    expect(mockUpsert).not.toHaveBeenCalled()
+  })
+
+  it('refuses a super admin on a workshop-bound session', async () => {
+    mockScope.mockReturnValue('org-1' as never)
+    expect((await POST(upload(await pngFile(10, 10)))).status).toBe(403)
+  })
+
+  it('refuses in cloud mode', async () => {
+    mockCloud.mockReturnValue(true)
+    expect((await POST(upload(await pngFile(10, 10)))).status).toBe(403)
+    expect(mockUpsert).not.toHaveBeenCalled()
+  })
+
+  it('refuses on an unlicensed install', async () => {
+    mockLicensed.mockResolvedValue(false)
+    expect((await POST(upload(await pngFile(10, 10)))).status).toBe(403)
+    expect(mockUpsert).not.toHaveBeenCalled()
+  })
+
+  it('refuses an SVG', async () => {
+    const svg = new File(['<svg/>'], 'icon.svg', { type: 'image/svg+xml' })
+    expect((await POST(upload(svg))).status).toBe(400)
+  })
+
+  it('refuses bytes that are not an image', async () => {
+    const fake = new File(['not a png'], 'icon.png', { type: 'image/png' })
+    expect((await POST(upload(fake))).status).toBe(400)
+  })
+
+  it('stores a square PNG, saves the name and removes the previous icon', async () => {
+    writeFileSync(path.join(iconDir, 'old.png'), 'old')
+    storedIcon('old.png')
+
+    const res = await POST(upload(await pngFile(300, 100)))
+    expect(res.status).toBe(200)
+    const body = await res.json()
+    expect(body.fileName).toMatch(/^[0-9a-f-]{36}\.png$/)
+    expect(body.url).toBe(`/api/public/app-icon?v=${body.fileName}`)
+
+    expect(mockUpsert).toHaveBeenCalledWith(
+      expect.objectContaining({ update: { value: body.fileName } })
+    )
+    expect(readdirSync(iconDir)).toEqual([body.fileName])
+
+    const meta = await sharp(path.join(iconDir, body.fileName)).metadata()
+    expect(meta).toMatchObject({ width: 512, height: 512, format: 'png' })
+  })
+})
+
+describe('DELETE /api/protected/admin/app-icon', () => {
+  it('refuses someone who is not a super admin', async () => {
+    mockUser.mockResolvedValue({ isSuperAdmin: false } as never)
+    expect((await DELETE()).status).toBe(403)
+    expect(mockDeleteMany).not.toHaveBeenCalled()
+  })
+
+  it('removes the file and the setting', async () => {
+    writeFileSync(path.join(iconDir, 'old.png'), 'old')
+    storedIcon('old.png')
+
+    const res = await DELETE()
+    expect(res.status).toBe(200)
+    expect(await res.json()).toEqual({ ok: true })
+    expect(mockDeleteMany).toHaveBeenCalled()
+    expect(readdirSync(iconDir)).toEqual([])
+    // Released through the file manager, so it waits in the trash like any upload.
+    const [day] = readdirSync(trashDir)
+    expect(readdirSync(path.join(trashDir, day, 'system', 'branding'))).toEqual(['old.png'])
+  })
+})

+ 161 - 0
src/__tests__/features/admin/branding-card.test.tsx

@@ -0,0 +1,161 @@
+/**
+ * The admin card that names the install in the browser: tab title, favicon,
+ * installed app. The name saves through the settings action, the icon goes
+ * straight to its own upload route.
+ */
+
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
+import { render, screen, waitFor } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+
+const setSystemSettings = vi.fn()
+vi.mock('@/features/admin/Actions/setSystemSettings', () => ({
+  setSystemSettings: (input: Record<string, string>) => setSystemSettings(input),
+}))
+
+const refresh = vi.fn()
+vi.mock('next/navigation', () => ({
+  useRouter: () => ({ refresh, push: vi.fn(), replace: vi.fn() }),
+}))
+
+const toastSuccess = vi.fn()
+const toastError = vi.fn()
+vi.mock('sonner', () => ({
+  toast: {
+    success: (...args: unknown[]) => toastSuccess(...args),
+    error: (...args: unknown[]) => toastError(...args),
+  },
+}))
+
+vi.mock('next/image', () => ({
+  default: ({ src, alt }: { src: string; alt: string }) => <img src={src} alt={alt} />,
+}))
+
+const { BrandingCard } = await import('@/features/admin/Components/branding-card')
+
+const fetchMock = vi.fn()
+
+function jsonResponse(body: unknown, status = 200) {
+  return new Response(JSON.stringify(body), {
+    status,
+    headers: { 'Content-Type': 'application/json' },
+  })
+}
+
+beforeEach(() => {
+  setSystemSettings.mockReset()
+  refresh.mockReset()
+  toastSuccess.mockReset()
+  toastError.mockReset()
+  fetchMock.mockReset()
+  vi.stubGlobal('fetch', fetchMock)
+})
+
+afterEach(() => {
+  vi.unstubAllGlobals()
+})
+
+describe('BrandingCard', () => {
+  it('shows everything but allows nothing without a licence', () => {
+    render(<BrandingCard locked initialName="" initialIconUrl={null} />)
+    expect(screen.getByText('Needs a licence')).toBeInTheDocument()
+    expect(screen.getByLabelText('App name')).toBeDisabled()
+    expect(screen.getByRole('button', { name: 'Upload' })).toBeDisabled()
+    expect(screen.getByRole('button', { name: 'Save name' })).toBeDisabled()
+    expect(screen.getByRole('link', { name: 'Open the License page' })).toHaveAttribute(
+      'href',
+      '/settings/license'
+    )
+  })
+
+  it('shows no lock once licensed', () => {
+    render(<BrandingCard initialName="" initialIconUrl={null} />)
+    expect(screen.queryByText('Needs a licence')).not.toBeInTheDocument()
+    expect(screen.getByLabelText('App name')).toBeEnabled()
+  })
+
+  it('shows the saved name and icon', () => {
+    render(<BrandingCard initialName="Bilverksted" initialIconUrl="/api/public/app-icon?v=a.png" />)
+    expect(screen.getByLabelText('App name')).toHaveValue('Bilverksted')
+    expect(screen.getByAltText('Current app icon')).toHaveAttribute(
+      'src',
+      '/api/public/app-icon?v=a.png'
+    )
+    expect(screen.getByRole('button', { name: 'Replace' })).toBeInTheDocument()
+  })
+
+  it('keeps Save disabled until the name changes', async () => {
+    const user = userEvent.setup()
+    render(<BrandingCard initialName="Bilverksted" initialIconUrl={null} />)
+    const save = screen.getByRole('button', { name: 'Save name' })
+    expect(save).toBeDisabled()
+
+    await user.type(screen.getByLabelText('App name'), ' AS')
+    expect(save).toBeEnabled()
+  })
+
+  it('saves the name through the settings action', async () => {
+    setSystemSettings.mockResolvedValue({ success: true, data: { updated: 1 } })
+    const user = userEvent.setup()
+    render(<BrandingCard initialName="" initialIconUrl={null} />)
+
+    await user.type(screen.getByLabelText('App name'), '  Bilverksted  ')
+    await user.click(screen.getByRole('button', { name: 'Save name' }))
+
+    await waitFor(() => expect(toastSuccess).toHaveBeenCalledWith('App name saved'))
+    expect(setSystemSettings).toHaveBeenCalledWith({ 'branding.appName': 'Bilverksted' })
+    expect(refresh).toHaveBeenCalled()
+  })
+
+  it('shows the server error when an upload is refused', async () => {
+    fetchMock.mockResolvedValue(jsonResponse({ error: 'The image must be square' }, 400))
+    const user = userEvent.setup()
+    render(<BrandingCard initialName="" initialIconUrl={null} />)
+
+    const file = new File(['x'], 'icon.png', { type: 'image/png' })
+    await user.upload(screen.getByTestId('branding-icon-input'), file)
+
+    await waitFor(() => expect(toastError).toHaveBeenCalledWith('The image must be square'))
+    const [url, init] = fetchMock.mock.calls[0]
+    expect(url).toBe('/api/protected/admin/app-icon')
+    expect(init.method).toBe('POST')
+    expect((init.body as FormData).get('file')).toBeInstanceOf(File)
+    expect(screen.queryByAltText('Current app icon')).not.toBeInTheDocument()
+    expect(refresh).not.toHaveBeenCalled()
+  })
+
+  it('swaps the preview to the uploaded icon', async () => {
+    fetchMock.mockResolvedValue(
+      jsonResponse({ fileName: 'b.png', url: '/api/public/app-icon?v=b.png' })
+    )
+    const user = userEvent.setup()
+    render(<BrandingCard initialName="" initialIconUrl={null} />)
+
+    await user.upload(
+      screen.getByTestId('branding-icon-input'),
+      new File(['x'], 'icon.png', { type: 'image/png' })
+    )
+
+    await waitFor(() =>
+      expect(screen.getByAltText('Current app icon')).toHaveAttribute(
+        'src',
+        '/api/public/app-icon?v=b.png'
+      )
+    )
+    expect(refresh).toHaveBeenCalled()
+  })
+
+  it('removes the icon and clears the preview', async () => {
+    fetchMock.mockResolvedValue(jsonResponse({ ok: true }))
+    const user = userEvent.setup()
+    render(<BrandingCard initialName="" initialIconUrl="/api/public/app-icon?v=a.png" />)
+
+    await user.click(screen.getByRole('button', { name: 'Remove' }))
+
+    await waitFor(() => expect(screen.queryByAltText('Current app icon')).not.toBeInTheDocument())
+    expect(fetchMock).toHaveBeenCalledWith('/api/protected/admin/app-icon', { method: 'DELETE' })
+    expect(toastSuccess).toHaveBeenCalledWith('App icon removed')
+    expect(refresh).toHaveBeenCalled()
+    expect(screen.getByRole('button', { name: 'Upload' })).toBeInTheDocument()
+  })
+})

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

@@ -30,6 +30,17 @@ vi.mock('@/lib/demo', () => ({
   demoGuard: () => undefined,
   demoGuard: () => undefined,
 }))
 }))
 
 
+// Browser branding is gated on a licensed self-hosted install.
+const features = { cloud: false, licensed: true }
+vi.mock('@/lib/features', () => ({
+  isCloudMode: () => features.cloud,
+  installHasLicense: async () => features.licensed,
+}))
+const invalidateAppBranding = vi.fn()
+vi.mock('@/lib/branding', () => ({
+  invalidateAppBranding: () => invalidateAppBranding(),
+}))
+
 const { setSystemSettings } = await import('@/features/admin/Actions/setSystemSettings')
 const { setSystemSettings } = await import('@/features/admin/Actions/setSystemSettings')
 
 
 /** The key/value pairs the action actually wrote. */
 /** The key/value pairs the action actually wrote. */
@@ -44,6 +55,9 @@ function written() {
 
 
 beforeEach(() => {
 beforeEach(() => {
   upsert.mockClear()
   upsert.mockClear()
+  invalidateAppBranding.mockClear()
+  features.cloud = false
+  features.licensed = true
 })
 })
 
 
 describe('posting a notice', () => {
 describe('posting a notice', () => {
@@ -96,3 +110,39 @@ describe('posting a notice', () => {
     expect(written()).toEqual({})
     expect(written()).toEqual({})
   })
   })
 })
 })
+
+describe('browser branding', () => {
+  it('trims and caps the app name', async () => {
+    await setSystemSettings({ 'branding.appName': `  ${'y'.repeat(90)}  ` })
+    expect(written()['branding.appName']).toBe('y'.repeat(40))
+  })
+
+  it('clears the branding cache after saving a branding key', async () => {
+    await setSystemSettings({ 'branding.appName': 'Bilverksted' })
+    expect(invalidateAppBranding).toHaveBeenCalledTimes(1)
+  })
+
+  it('leaves the branding cache alone for other settings', async () => {
+    await setSystemSettings({ 'smtp.host': 'mail.example.com' })
+    expect(invalidateAppBranding).not.toHaveBeenCalled()
+  })
+
+  it('refuses branding keys in cloud mode', async () => {
+    // The card is hidden there, but a client can still post the key.
+    features.cloud = true
+    const result = await setSystemSettings({ 'branding.appName': 'Not TorqVoice' })
+    expect(result.success).toBe(false)
+    expect(written()).toEqual({})
+    expect(invalidateAppBranding).not.toHaveBeenCalled()
+  })
+
+  it('refuses branding keys without a white-label licence', async () => {
+    features.licensed = false
+    const result = await setSystemSettings({
+      'branding.appIcon': 'icon.png',
+      'smtp.host': 'mail.example.com',
+    })
+    expect(result.success).toBe(false)
+    expect(written()).toEqual({})
+  })
+})

+ 90 - 0
src/__tests__/features/settings/license-settings.test.tsx

@@ -0,0 +1,90 @@
+/**
+ * The licence page's "make it yours" list. It is always there, so a workshop
+ * sees what the licence unlocks: the app name and icon rows are locked until
+ * the licence is valid, then link a super admin to the admin page. Members
+ * are told only a super admin can change those two.
+ */
+
+import { describe, it, expect, vi } from 'vitest'
+import { render, screen, within } from '@testing-library/react'
+import type { LicenseTokenStatus } from '@/lib/license/token'
+
+vi.mock('next/navigation', () => ({
+  useRouter: () => ({ refresh: vi.fn(), push: vi.fn(), replace: vi.fn() }),
+}))
+vi.mock('@/features/settings/Actions/validateLicense', () => ({ validateLicense: vi.fn() }))
+vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
+
+const { LicenseSettings } = await import('@/features/settings/Components/license-settings')
+
+const ADMIN_HREF = '/admin/settings#app-branding'
+const SUPER_ADMIN_NOTE = /only a super admin can change the app name and icon/i
+
+function renderPage(status: LicenseTokenStatus, brandingHref: string | null) {
+  return render(
+    <LicenseSettings
+      initialKey="TV-KEY"
+      initialStatus={status}
+      initialExpiresAt=""
+      initialIssuedAt=""
+      initialCheckedAt=""
+      brandingHref={brandingHref}
+    />
+  )
+}
+
+describe('LicenseSettings next steps', () => {
+  it('links a super admin to the app branding once the licence is valid', () => {
+    renderPage('valid', ADMIN_HREF)
+
+    expect(screen.getByText('Make it yours')).toBeInTheDocument()
+    expect(screen.getByRole('link', { name: /set your app name/i })).toHaveAttribute(
+      'href',
+      ADMIN_HREF
+    )
+    expect(
+      screen.getByRole('link', { name: /add your own favicon and app icon/i })
+    ).toHaveAttribute('href', ADMIN_HREF)
+    // Those two and nothing else: the list is about what the licence unlocks.
+    expect(screen.getAllByRole('link')).toHaveLength(2)
+    expect(screen.queryByText('Needs a licence')).not.toBeInTheDocument()
+    expect(screen.queryByText(SUPER_ADMIN_NOTE)).not.toBeInTheDocument()
+  })
+
+  it('lists the admin steps without links for a member, and says who can change them', () => {
+    renderPage('valid', null)
+
+    expect(screen.getByText('Set your app name')).toBeInTheDocument()
+    expect(screen.queryByRole('link', { name: /set your app name/i })).not.toBeInTheDocument()
+    expect(screen.queryByRole('link', { name: /favicon/i })).not.toBeInTheDocument()
+    expect(screen.getByText(SUPER_ADMIN_NOTE)).toBeInTheDocument()
+  })
+
+  it.each([
+    'missing',
+    'stale',
+    'expired',
+    'invalid',
+  ] satisfies LicenseTokenStatus[])('keeps the rows in view but locked while the licence is %s', (status) => {
+    renderPage(status, ADMIN_HREF)
+
+    expect(screen.getByText('Make it yours')).toBeInTheDocument()
+    expect(screen.getByText(/licence unlocks the app name and icon/i)).toBeInTheDocument()
+    expect(screen.getByText('Set your app name')).toBeInTheDocument()
+    expect(screen.getByText('Add your own favicon and app icon')).toBeInTheDocument()
+    expect(screen.getAllByText('Needs a licence')).toHaveLength(2)
+    expect(screen.queryByRole('link', { name: /set your app name/i })).not.toBeInTheDocument()
+    expect(screen.queryByRole('link', { name: /favicon/i })).not.toBeInTheDocument()
+    // The purchase button above the list is the only link on the page.
+    const steps = within(document.getElementById('license-next-steps') as HTMLElement)
+    expect(steps.queryByRole('link')).not.toBeInTheDocument()
+    expect(screen.queryByText(SUPER_ADMIN_NOTE)).not.toBeInTheDocument()
+  })
+
+  it('tells a member without a licence both things: locked, and who can change it later', () => {
+    renderPage('missing', null)
+
+    expect(screen.getAllByText('Needs a licence')).toHaveLength(2)
+    expect(screen.getByText(SUPER_ADMIN_NOTE)).toBeInTheDocument()
+  })
+})

+ 147 - 0
src/__tests__/lib/branding.test.ts

@@ -0,0 +1,147 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+
+vi.mock('@/lib/db', () => ({
+  db: {
+    systemSetting: { findMany: vi.fn() },
+  },
+}))
+
+vi.mock('@/lib/features', () => ({
+  isCloudMode: vi.fn(),
+  installHasLicense: vi.fn(),
+}))
+
+import { db } from '@/lib/db'
+import { installHasLicense, isCloudMode } from '@/lib/features'
+import {
+  DEFAULT_APP_NAME,
+  getAppBranding,
+  invalidateAppBranding,
+  isSafeIconFileName,
+} from '@/lib/branding'
+import { BRANDING_APP_NAME_MAX_LENGTH } from '@/features/admin/Schema/systemSettingsSchema'
+
+const mockFindMany = vi.mocked(db.systemSetting.findMany)
+const mockCloud = vi.mocked(isCloudMode)
+const mockLicensed = vi.mocked(installHasLicense)
+
+const DEFAULT = { name: DEFAULT_APP_NAME, iconUrl: null, custom: false }
+
+function settings(rows: Record<string, string>) {
+  mockFindMany.mockResolvedValue(
+    Object.entries(rows).map(([key, value]) => ({ key, value })) as never
+  )
+}
+
+beforeEach(() => {
+  vi.resetAllMocks()
+  invalidateAppBranding()
+  mockCloud.mockReturnValue(false)
+  mockLicensed.mockResolvedValue(true)
+  settings({})
+})
+
+describe('getAppBranding', () => {
+  it('is TorqVoice in cloud mode, whatever is stored', async () => {
+    mockCloud.mockReturnValue(true)
+    settings({ 'branding.appName': 'Garage One' })
+
+    expect(await getAppBranding()).toEqual(DEFAULT)
+    expect(mockFindMany).not.toHaveBeenCalled()
+  })
+
+  it('is TorqVoice without a licence and does not read the branding settings', async () => {
+    mockLicensed.mockResolvedValue(false)
+    settings({ 'branding.appName': 'Garage One', 'branding.appIcon': 'a.png' })
+
+    expect(await getAppBranding()).toEqual(DEFAULT)
+    expect(mockFindMany).not.toHaveBeenCalled()
+  })
+
+  it('is TorqVoice on a licensed install with nothing set', async () => {
+    expect(await getAppBranding()).toEqual(DEFAULT)
+  })
+
+  it('uses the name alone when only a name is set', async () => {
+    settings({ 'branding.appName': 'Garage One' })
+
+    expect(await getAppBranding()).toEqual({ name: 'Garage One', iconUrl: null, custom: true })
+  })
+
+  it('serves an icon from the public route with the file name as cache-buster', async () => {
+    settings({ 'branding.appIcon': '3f2a-icon.png' })
+
+    expect(await getAppBranding()).toEqual({
+      name: DEFAULT_APP_NAME,
+      iconUrl: '/api/public/app-icon?v=3f2a-icon.png',
+      custom: true,
+    })
+  })
+
+  it('trims the name and caps its length', async () => {
+    settings({ 'branding.appName': `   ${'x'.repeat(BRANDING_APP_NAME_MAX_LENGTH + 20)}   ` })
+
+    const branding = await getAppBranding()
+    expect(branding.name).toBe('x'.repeat(BRANDING_APP_NAME_MAX_LENGTH))
+  })
+
+  it('treats a blank name as unset', async () => {
+    settings({ 'branding.appName': '   ' })
+
+    expect(await getAppBranding()).toEqual(DEFAULT)
+  })
+
+  it('caches the answer until invalidated', async () => {
+    settings({ 'branding.appName': 'Garage One' })
+    expect((await getAppBranding()).name).toBe('Garage One')
+
+    settings({ 'branding.appName': 'Garage Two' })
+    expect((await getAppBranding()).name).toBe('Garage One')
+    expect(mockFindMany).toHaveBeenCalledTimes(1)
+    expect(mockLicensed).toHaveBeenCalledTimes(1)
+
+    invalidateAppBranding()
+    expect((await getAppBranding()).name).toBe('Garage Two')
+    expect(mockFindMany).toHaveBeenCalledTimes(2)
+  })
+
+  it('expires the cache after a minute', async () => {
+    vi.useFakeTimers()
+    try {
+      settings({ 'branding.appName': 'Garage One' })
+      await getAppBranding()
+      settings({ 'branding.appName': 'Garage Two' })
+
+      vi.advanceTimersByTime(61_000)
+      expect((await getAppBranding()).name).toBe('Garage Two')
+    } finally {
+      vi.useRealTimers()
+    }
+  })
+
+  it('falls back to the default when the database fails', async () => {
+    mockFindMany.mockRejectedValue(new Error('down'))
+    vi.spyOn(console, 'error').mockImplementation(() => undefined)
+
+    expect(await getAppBranding()).toEqual(DEFAULT)
+  })
+})
+
+describe('isSafeIconFileName', () => {
+  it.each([
+    ['3f2a.png', true],
+    ['3f2a.JPG', true],
+    ['a.webp', true],
+    ['../../etc/passwd', false],
+    ['x/y.png', false],
+    ['x\\y.png', false],
+    ['..png', false],
+    ['.png', false],
+    ['icon.svg', false],
+    ['icon.toString', false],
+    ['icon', false],
+    ['', false],
+  ])('%s → %s', (value, expected) => {
+    expect(isSafeIconFileName(value)).toBe(expected)
+  })
+})

+ 37 - 0
src/__tests__/lib/files/manager.test.ts

@@ -70,6 +70,7 @@ import {
   purgeTrash,
   purgeTrash,
   releaseFiles,
   releaseFiles,
   releaseFilesNotRestored,
   releaseFilesNotRestored,
+  releaseInstallFile,
   removeOrganizationFiles,
   removeOrganizationFiles,
   sweepOrphanFiles,
   sweepOrphanFiles,
 } from '@/lib/files/manager'
 } from '@/lib/files/manager'
@@ -600,3 +601,39 @@ describe('purgeTrash', () => {
     expect(existsSync(stray)).toBe(true)
     expect(existsSync(stray)).toBe(true)
   })
   })
 })
 })
+
+describe('releaseInstallFile', () => {
+  const NAME = 'icon.png'
+  async function plantInstallFile() {
+    const dir = path.join(roots.current, 'system', 'branding')
+    await mkdir(dir, { recursive: true })
+    const target = path.join(dir, NAME)
+    await writeFile(target, 'png')
+    return target
+  }
+
+  it('moves an install-owned file into the trash under system/', async () => {
+    const target = await plantInstallFile()
+    const now = Date.UTC(2026, 9, 30)
+    expect(await releaseInstallFile('branding', NAME, { now })).toBe(true)
+    expect(existsSync(target)).toBe(false)
+    expect(
+      existsSync(path.join(roots.current, '.trash', '2026-10-30', 'system', 'branding', NAME))
+    ).toBe(true)
+  })
+
+  it('refuses a name that could leave the folder', async () => {
+    await plantInstallFile()
+    expect(await releaseInstallFile('branding', '../secret.png')).toBe(false)
+  })
+
+  it('answers false and leaves the file when the trash cannot be written', async () => {
+    // The setting that pointed at the file is already saved by the time the
+    // caller gets here, so a failed move must not turn into an error.
+    const target = await plantInstallFile()
+    vi.mocked(rename).mockRejectedValueOnce(Object.assign(new Error('denied'), { code: 'EACCES' }))
+    await expect(releaseInstallFile('branding', NAME)).resolves.toBe(false)
+    expect(existsSync(target)).toBe(true)
+    expect(console.error).toHaveBeenCalled()
+  })
+})

+ 16 - 2
src/app/(authenticated)/admin/settings/page.tsx

@@ -1,10 +1,24 @@
 import { getSystemSettings } from '@/features/admin/Actions/getSystemSettings'
 import { getSystemSettings } from '@/features/admin/Actions/getSystemSettings'
 import { AdminSettings } from '@/features/admin/Components/admin-settings'
 import { AdminSettings } from '@/features/admin/Components/admin-settings'
-import { isCloudMode } from '@/lib/features'
+import { SYSTEM_SETTING_KEYS } from '@/features/admin/Schema/systemSettingsSchema'
+import { appIconUrl } from '@/lib/branding'
+import { installHasLicense, isCloudMode } from '@/lib/features'
 
 
 export default async function AdminSettingsPage() {
 export default async function AdminSettingsPage() {
   const result = await getSystemSettings()
   const result = await getSystemSettings()
   const data = result.data ?? {}
   const data = result.data ?? {}
 
 
-  return <AdminSettings initial={data} mode={isCloudMode() ? 'cloud' : 'self-hosted'} />
+  // Browser branding is a white-label licence feature on self-hosted installs.
+  const brandingAvailable = !isCloudMode() && (await installHasLicense())
+  const iconFile = data[SYSTEM_SETTING_KEYS.BRANDING_APP_ICON]
+  const brandingIconUrl = iconFile ? appIconUrl(iconFile) : null
+
+  return (
+    <AdminSettings
+      initial={data}
+      mode={isCloudMode() ? 'cloud' : 'self-hosted'}
+      brandingAvailable={brandingAvailable}
+      brandingIconUrl={brandingIconUrl}
+    />
+  )
 }
 }

+ 6 - 1
src/app/(authenticated)/layout.tsx

@@ -23,6 +23,7 @@ import { UpdateWatcher } from '@/components/update-watcher'
 import { buildVersion } from '@/lib/build-version.server'
 import { buildVersion } from '@/lib/build-version.server'
 import { redirectToSignIn } from '@/lib/sign-in-redirect.server'
 import { redirectToSignIn } from '@/lib/sign-in-redirect.server'
 import { InstallBanner } from '@/components/pwa-install-prompt'
 import { InstallBanner } from '@/components/pwa-install-prompt'
+import { getAppBranding } from '@/lib/branding'
 import { MobileBottomNav } from '@/components/mobile-bottom-nav'
 import { MobileBottomNav } from '@/components/mobile-bottom-nav'
 import { SupportBubble } from '@/features/support/Components/SupportBubble'
 import { SupportBubble } from '@/features/support/Components/SupportBubble'
 import { isSupportEnabled } from '@/lib/support'
 import { isSupportEnabled } from '@/lib/support'
@@ -77,6 +78,7 @@ export default async function DashboardLayout({ children }: { children: React.Re
   }
   }
 
 
   const features = await getFeatures(data.organizationId)
   const features = await getFeatures(data.organizationId)
+  const appBranding = await getAppBranding()
   const showWhiteLabelCta = !isCloudMode() && !features.brandingRemoved && !isDemoMode
   const showWhiteLabelCta = !isCloudMode() && !features.brandingRemoved && !isDemoMode
   // Cloud only, and off until a platform admin turns it on. Resolved here so
   // Cloud only, and off until a platform admin turns it on. Resolved here so
   // the widget's code never reaches a self-hosted install's page.
   // the widget's code never reaches a self-hosted install's page.
@@ -392,7 +394,10 @@ export default async function DashboardLayout({ children }: { children: React.Re
                               {isOwnerOrAdmin && <NotificationInitializer />}
                               {isOwnerOrAdmin && <NotificationInitializer />}
                               <OnlineTracker />
                               <OnlineTracker />
                               <UpdateWatcher version={buildVersion()} />
                               <UpdateWatcher version={buildVersion()} />
-                              <InstallBanner />
+                              <InstallBanner
+                                appName={appBranding.name}
+                                iconUrl={appBranding.iconUrl}
+                              />
                             </TimeClockProvider>
                             </TimeClockProvider>
                           </RealtimeProvider>
                           </RealtimeProvider>
                         </FeatureHintProvider>
                         </FeatureHintProvider>

+ 3 - 0
src/app/(authenticated)/settings/license/page.tsx

@@ -39,6 +39,9 @@ export default async function LicensePage() {
       initialExpiresAt={verification.payload?.expiresAt ?? ''}
       initialExpiresAt={verification.payload?.expiresAt ?? ''}
       initialIssuedAt={verification.payload?.issuedAt ?? ''}
       initialIssuedAt={verification.payload?.issuedAt ?? ''}
       initialCheckedAt={map.get(SETTING_KEYS.LICENSE_CHECKED_AT) || ''}
       initialCheckedAt={map.get(SETTING_KEYS.LICENSE_CHECKED_AT) || ''}
+      // The app name and icon live on the super-admin page; anyone else is
+      // told who to ask instead of being sent to a redirect.
+      brandingHref={authContext.role === 'super_admin' ? '/admin/settings#app-branding' : null}
       demoMode={isDemoMode}
       demoMode={isDemoMode}
     />
     />
   )
   )

+ 150 - 0
src/app/api/protected/admin/app-icon/route.ts

@@ -0,0 +1,150 @@
+import { NextResponse } from 'next/server'
+import { mkdir, writeFile } from 'node:fs/promises'
+import path from 'node:path'
+import { randomUUID } from 'node:crypto'
+import sharp from 'sharp'
+import { db } from '@/lib/db'
+import { getCachedSession } from '@/lib/cached-session'
+import { sessionOrganizationScope } from '@/lib/session-scope'
+import { installHasLicense, isCloudMode } from '@/lib/features'
+import { demoGuard } from '@/lib/demo'
+import { cleanImage } from '@/lib/image-upload.server'
+import { appIconUrl, brandingDir, invalidateAppBranding, isSafeIconFileName } from '@/lib/branding'
+import { releaseInstallFile } from '@/lib/files/manager'
+import { SYSTEM_SETTING_KEYS } from '@/features/admin/Schema/systemSettingsSchema'
+
+/** Square, and large enough for every place a browser shows an app icon. */
+const ICON_SIDE = 512
+
+/**
+ * The install's app icon, set from the admin panel. The same gate as
+ * `withSuperAdmin`, and only where the branding is honoured at all: a licensed
+ * self-hosted install.
+ */
+async function refusal(): Promise<NextResponse | null> {
+  const session = await getCachedSession()
+  if (!session?.user?.id) {
+    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
+  }
+
+  const user = await db.user.findUnique({
+    where: { id: session.user.id },
+    select: { isSuperAdmin: true },
+  })
+  // A session a workshop minted without a password carries no platform rights.
+  if (!user?.isSuperAdmin || sessionOrganizationScope(session)) {
+    return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
+  }
+
+  if (isCloudMode() || !(await installHasLicense())) {
+    return NextResponse.json(
+      { error: 'A white-label licence is required to change the app icon' },
+      { status: 403 }
+    )
+  }
+
+  try {
+    demoGuard()
+  } catch (error) {
+    return NextResponse.json({ error: (error as Error).message }, { status: 403 })
+  }
+
+  return null
+}
+
+async function currentIconFileName(): Promise<string | null> {
+  const row = await db.systemSetting.findUnique({
+    where: { key: SYSTEM_SETTING_KEYS.BRANDING_APP_ICON },
+    select: { value: true },
+  })
+  const value = row?.value?.trim()
+  return value || null
+}
+
+/** Moves a stored icon to the trash. Only names we could have written are touched. */
+async function removeIconFile(fileName: string | null) {
+  if (!fileName || !isSafeIconFileName(fileName)) return
+  await releaseInstallFile('branding', fileName)
+}
+
+export async function POST(request: Request) {
+  try {
+    const refused = await refusal()
+    if (refused) return refused
+
+    const formData = await request.formData()
+    const file = formData.get('file')
+
+    if (!(file instanceof File)) {
+      return NextResponse.json({ error: 'No file provided' }, { status: 400 })
+    }
+
+    if (!file.type.startsWith('image/') || file.type === 'image/svg+xml') {
+      return NextResponse.json({ error: 'Upload a PNG, JPEG or WebP image' }, { status: 400 })
+    }
+
+    if (file.size > 2 * 1024 * 1024) {
+      return NextResponse.json({ error: 'File size must be under 2MB' }, { status: 400 })
+    }
+
+    // cleanImage is the safety check (decoded, re-encoded, no SVG). It only
+    // fits inside a box, so the result is then centred on a transparent
+    // square PNG: the manifest declares 512x512 and iOS wants a square.
+    let data: Buffer
+    try {
+      const clean = await cleanImage(Buffer.from(await file.arrayBuffer()), { maxSide: ICON_SIDE })
+      data = await sharp(clean.data)
+        .resize({
+          width: ICON_SIDE,
+          height: ICON_SIDE,
+          fit: 'contain',
+          background: { r: 0, g: 0, b: 0, alpha: 0 },
+        })
+        .png({ compressionLevel: 9 })
+        .toBuffer()
+    } catch {
+      return NextResponse.json({ error: 'Not an image we can read' }, { status: 400 })
+    }
+
+    const fileName = `${randomUUID()}.png`
+    const dir = brandingDir()
+    await mkdir(dir, { recursive: true })
+    await writeFile(path.join(dir, fileName), data)
+
+    const previous = await currentIconFileName()
+    await db.systemSetting.upsert({
+      where: { key: SYSTEM_SETTING_KEYS.BRANDING_APP_ICON },
+      create: { key: SYSTEM_SETTING_KEYS.BRANDING_APP_ICON, value: fileName },
+      update: { value: fileName },
+    })
+    // Install-owned, outside every workshop's folder, but the file manager
+    // still does the moving so the old icon waits in the trash like any
+    // other released file.
+    if (previous !== fileName) await removeIconFile(previous)
+
+    invalidateAppBranding()
+
+    return NextResponse.json({ fileName, url: appIconUrl(fileName) })
+  } catch (error) {
+    console.error('[App Icon Upload] Error:', error)
+    return NextResponse.json({ error: 'Failed to upload the app icon' }, { status: 500 })
+  }
+}
+
+export async function DELETE() {
+  try {
+    const refused = await refusal()
+    if (refused) return refused
+
+    const previous = await currentIconFileName()
+    await db.systemSetting.deleteMany({ where: { key: SYSTEM_SETTING_KEYS.BRANDING_APP_ICON } })
+    await removeIconFile(previous)
+
+    invalidateAppBranding()
+
+    return NextResponse.json({ ok: true })
+  } catch (error) {
+    console.error('[App Icon Delete] Error:', error)
+    return NextResponse.json({ error: 'Failed to remove the app icon' }, { status: 500 })
+  }
+}

+ 49 - 0
src/app/api/public/app-icon/route.ts

@@ -0,0 +1,49 @@
+import { NextResponse } from 'next/server'
+import { readFile } from 'node:fs/promises'
+import path from 'node:path'
+import { db } from '@/lib/db'
+import { APP_ICON_MIME_TYPES, brandingDir, isSafeIconFileName } from '@/lib/branding'
+import { SYSTEM_SETTING_KEYS } from '@/features/admin/Schema/systemSettingsSchema'
+
+/**
+ * The install's own app icon. No sign-in: a browser fetches the favicon and
+ * the home-screen icon before anyone has signed in. The `v` query the pages
+ * add is only a cache-buster; which file is served comes from the setting.
+ */
+export async function GET() {
+  const setting = await db.systemSetting.findUnique({
+    where: { key: SYSTEM_SETTING_KEYS.BRANDING_APP_ICON },
+    select: { value: true },
+  })
+  const fileName = setting?.value?.trim() ?? ''
+
+  if (!fileName) {
+    return NextResponse.json({ error: 'No icon' }, { status: 404 })
+  }
+  if (!isSafeIconFileName(fileName)) {
+    return NextResponse.json({ error: 'Invalid' }, { status: 400 })
+  }
+
+  const dir = brandingDir()
+  const filePath = path.join(dir, fileName)
+  // Belt and braces after the name check: never read outside the directory.
+  if (path.dirname(filePath) !== dir) {
+    return NextResponse.json({ error: 'Invalid' }, { status: 400 })
+  }
+
+  let buffer: Buffer
+  try {
+    buffer = await readFile(filePath)
+  } catch {
+    return NextResponse.json({ error: 'Not found' }, { status: 404 })
+  }
+
+  const ext = fileName.split('.').pop()?.toLowerCase() ?? ''
+  return new NextResponse(new Uint8Array(buffer), {
+    headers: {
+      'Content-Type': APP_ICON_MIME_TYPES[ext],
+      'Cache-Control': 'public, max-age=3600',
+      'X-Content-Type-Options': 'nosniff',
+    },
+  })
+}

+ 31 - 2
src/app/layout.tsx

@@ -27,6 +27,7 @@ import { DemoBanner } from '@/components/demo-banner'
 import { BroadcastBanner } from '@/components/broadcast-banner'
 import { BroadcastBanner } from '@/components/broadcast-banner'
 import { BannerSlotProvider } from '@/components/banner-slot'
 import { BannerSlotProvider } from '@/components/banner-slot'
 import { getBroadcast, isCustomerFacingPath } from '@/lib/broadcast'
 import { getBroadcast, isCustomerFacingPath } from '@/lib/broadcast'
+import { DEFAULT_APP_NAME, getAppBranding } from '@/lib/branding'
 import { headers } from 'next/headers'
 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'
@@ -111,7 +112,9 @@ const atkinsonMono = Atkinson_Hyperlegible_Mono({
   fallback: ['ui-monospace', 'monospace'],
   fallback: ['ui-monospace', 'monospace'],
 })
 })
 
 
-export const metadata: Metadata = {
+// What every page says about itself when the install carries no branding of
+// its own. Link previews (openGraph, twitter) stay TorqVoice even when it does.
+const defaultMetadata: Metadata = {
   metadataBase: new URL(process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'),
   metadataBase: new URL(process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'),
   appleWebApp: {
   appleWebApp: {
     capable: true,
     capable: true,
@@ -164,6 +167,30 @@ export const metadata: Metadata = {
   },
   },
 }
 }
 
 
+// The favicon lives in public/ rather than as app/favicon.ico: Next always
+// emits a file-convention favicon first, ahead of any icon set here, and the
+// browser would keep picking our 16px icon over a workshop's own.
+const DEFAULT_ICONS: Metadata['icons'] = {
+  icon: [{ url: '/favicon.ico', sizes: '16x16', type: 'image/x-icon' }],
+}
+
+export async function generateMetadata(): Promise<Metadata> {
+  const branding = await getAppBranding()
+  if (!branding.custom) return { ...defaultMetadata, icons: DEFAULT_ICONS }
+
+  const { name, iconUrl } = branding
+  const metadata: Metadata = {
+    ...defaultMetadata,
+    icons: iconUrl ? { icon: iconUrl, apple: iconUrl, shortcut: iconUrl } : DEFAULT_ICONS,
+  }
+  // An icon on its own keeps the default titles.
+  if (name !== DEFAULT_APP_NAME) {
+    metadata.title = { default: name, template: `%s | ${name}` }
+    metadata.appleWebApp = { capable: true, statusBarStyle: 'black-translucent', title: name }
+  }
+  return metadata
+}
+
 export default async function RootLayout({
 export default async function RootLayout({
   children,
   children,
 }: Readonly<{
 }: Readonly<{
@@ -178,13 +205,15 @@ export default async function RootLayout({
   // would be both off-brand and none of the customer's business.
   // would be both off-brand and none of the customer's business.
   const pathname = (await headers()).get('x-pathname')
   const pathname = (await headers()).get('x-pathname')
   const broadcast = isCustomerFacingPath(pathname) ? null : await getBroadcast()
   const broadcast = isCustomerFacingPath(pathname) ? null : await getBroadcast()
+  // A custom icon is declared through generateMetadata, apple icon included.
+  const branding = await getAppBranding()
 
 
   return (
   return (
     <html lang={locale} translate="no" suppressHydrationWarning>
     <html lang={locale} translate="no" suppressHydrationWarning>
       <head>
       <head>
         <meta name="google" content="notranslate" />
         <meta name="google" content="notranslate" />
         <meta name="theme-color" content="#09090b" />
         <meta name="theme-color" content="#09090b" />
-        <link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" />
+        {!branding.iconUrl && <link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" />}
         <script
         <script
           dangerouslySetInnerHTML={{
           dangerouslySetInnerHTML={{
             __html: `(function(){try{var c=document.documentElement.classList;var p=location.pathname;if(p.indexOf('/share/')===0||p.indexOf('/portal')===0){c.add('light');return}var M={light:'light',dark:'dark',graphite:'light',ocean:'light',forest:'light',midnight:'dark',carbon:'dark'};var t=localStorage.getItem('torqvoice-theme')||'dark';if(t==='system'){t=matchMedia('(prefers-color-scheme:dark)').matches?'dark':'light'}var m=M[t];if(!m){t='dark';m='dark'}c.add(m);if(t!==m){c.add('theme-'+t)}var f=localStorage.getItem('torqvoice-font');if(f==='workshop'||f==='precise'||f==='legible'){c.add('font-set-'+f)}}catch(e){}})()`,
             __html: `(function(){try{var c=document.documentElement.classList;var p=location.pathname;if(p.indexOf('/share/')===0||p.indexOf('/portal')===0){c.add('light');return}var M={light:'light',dark:'dark',graphite:'light',ocean:'light',forest:'light',midnight:'dark',carbon:'dark'};var t=localStorage.getItem('torqvoice-theme')||'dark';if(t==='system'){t=matchMedia('(prefers-color-scheme:dark)').matches?'dark':'light'}var m=M[t];if(!m){t='dark';m='dark'}c.add(m);if(t!==m){c.add('theme-'+t)}var f=localStorage.getItem('torqvoice-font');if(f==='workshop'||f==='precise'||f==='legible'){c.add('font-set-'+f)}}catch(e){}})()`,

+ 33 - 7
src/app/manifest.ts

@@ -1,10 +1,29 @@
 import type { MetadataRoute } from 'next'
 import type { MetadataRoute } from 'next'
+import { APP_ICON_MIME_TYPES, DEFAULT_APP_NAME, getAppBranding } from '@/lib/branding'
+
+// Read per request: the build would otherwise bake the default branding into
+// a static file, and a workshop's own name and icon would never reach it.
+export const dynamic = 'force-dynamic'
+
+const DEFAULT_ICONS: MetadataRoute.Manifest['icons'] = [
+  { src: '/icons/icon-192.png', sizes: '192x192', type: 'image/png' },
+  { src: '/icons/icon-512.png', sizes: '512x512', type: 'image/png' },
+]
+
+function iconType(url: string): string {
+  const ext = new URL(url, 'http://local').searchParams.get('v')?.split('.').pop()?.toLowerCase()
+  return (ext && APP_ICON_MIME_TYPES[ext]) || 'image/png'
+}
+
+export default async function manifest(): Promise<MetadataRoute.Manifest> {
+  const branding = await getAppBranding()
+  // An icon on its own keeps the full default name.
+  const named = branding.custom && branding.name !== DEFAULT_APP_NAME
 
 
-export default function manifest(): MetadataRoute.Manifest {
   return {
   return {
     id: '/',
     id: '/',
-    name: 'TorqVoice - Workshop Management',
-    short_name: 'TorqVoice',
+    name: named ? branding.name : 'TorqVoice - Workshop Management',
+    short_name: named ? branding.name : 'TorqVoice',
     description:
     description:
       'Workshop management platform for automotive service businesses. Manage work orders, invoices, customers, inventory, and vehicle service history.',
       'Workshop management platform for automotive service businesses. Manage work orders, invoices, customers, inventory, and vehicle service history.',
     start_url: '/',
     start_url: '/',
@@ -18,9 +37,16 @@ export default function manifest(): MetadataRoute.Manifest {
     orientation: 'any',
     orientation: 'any',
     background_color: '#09090b',
     background_color: '#09090b',
     theme_color: '#09090b',
     theme_color: '#09090b',
-    icons: [
-      { src: '/icons/icon-192.png', sizes: '192x192', type: 'image/png' },
-      { src: '/icons/icon-512.png', sizes: '512x512', type: 'image/png' },
-    ],
+    // The upload route stores every icon as a 512px square PNG.
+    icons: branding.iconUrl
+      ? [
+          {
+            src: branding.iconUrl,
+            sizes: '512x512',
+            type: iconType(branding.iconUrl),
+            purpose: 'any',
+          },
+        ]
+      : DEFAULT_ICONS,
   }
   }
 }
 }

+ 21 - 4
src/components/pwa-install-prompt.tsx

@@ -128,9 +128,23 @@ export function useInstallPrompt() {
   }
   }
 }
 }
 
 
-export function InstallBanner() {
+const DEFAULT_APP_NAME = 'TorqVoice'
+
+interface InstallBannerProps {
+  /** The install's own name on a licensed self-hosted install. */
+  appName?: string
+  iconUrl?: string | null
+}
+
+export function InstallBanner({ appName = DEFAULT_APP_NAME, iconUrl = null }: InstallBannerProps) {
   const t = useTranslations('common.shared')
   const t = useTranslations('common.shared')
   const { canInstall, dismissed, isIOS, install, dismiss } = useInstallPrompt()
   const { canInstall, dismissed, isIOS, install, dismiss } = useInstallPrompt()
+  // The brand word is spelled the same in every locale's installTorqvoice, so
+  // swapping it keeps the translated verb around the workshop's own name.
+  const title =
+    appName === DEFAULT_APP_NAME
+      ? t('installTorqvoice')
+      : t('installTorqvoice').replace(/Torq[Vv]oice/g, () => appName)
 
 
   // The banner is noise while developing; the settings page still works there.
   // The banner is noise while developing; the settings page still works there.
   if (process.env.NODE_ENV === 'development') return null
   if (process.env.NODE_ENV === 'development') return null
@@ -141,14 +155,17 @@ export function InstallBanner() {
       <div className="border-t bg-background/80 backdrop-blur-lg">
       <div className="border-t bg-background/80 backdrop-blur-lg">
         <div className="mx-auto flex max-w-xl items-center gap-3 px-4 py-3">
         <div className="mx-auto flex max-w-xl items-center gap-3 px-4 py-3">
           <Image
           <Image
-            src="/icons/icon-192.png"
-            alt="TorqVoice"
+            src={iconUrl ?? '/icons/icon-192.png'}
+            alt={appName}
             width={40}
             width={40}
             height={40}
             height={40}
             className="shrink-0 rounded-lg"
             className="shrink-0 rounded-lg"
+            // Served by our own route with its own cache headers; the image
+            // optimiser would only cache it again under the old icon.
+            unoptimized={iconUrl !== null}
           />
           />
           <div className="min-w-0 flex-1">
           <div className="min-w-0 flex-1">
-            <p className="text-sm font-medium">{t('installTorqvoice')}</p>
+            <p className="text-sm font-medium">{title}</p>
             <p className="truncate text-xs text-muted-foreground">
             <p className="truncate text-xs text-muted-foreground">
               {isIOS ? t('iosInstallHint') : t('installDescription')}
               {isIOS ? t('iosInstallHint') : t('installDescription')}
             </p>
             </p>

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

@@ -8,10 +8,13 @@ import {
   SYSTEM_SETTING_KEYS,
   SYSTEM_SETTING_KEYS,
   BROADCAST_LEVELS,
   BROADCAST_LEVELS,
   BROADCAST_MAX_LENGTH,
   BROADCAST_MAX_LENGTH,
+  BRANDING_APP_NAME_MAX_LENGTH,
   type BroadcastLevel,
   type BroadcastLevel,
 } from '../Schema/systemSettingsSchema'
 } from '../Schema/systemSettingsSchema'
 import { demoGuard } from '@/lib/demo'
 import { demoGuard } from '@/lib/demo'
 import { notificationBus } from '@/lib/notification-bus'
 import { notificationBus } from '@/lib/notification-bus'
+import { installHasLicense, isCloudMode } from '@/lib/features'
+import { invalidateAppBranding } from '@/lib/branding'
 
 
 export async function setSystemSettings(input: Record<string, string>) {
 export async function setSystemSettings(input: Record<string, string>) {
   return withSuperAdmin(async () => {
   return withSuperAdmin(async () => {
@@ -25,6 +28,21 @@ export async function setSystemSettings(input: Record<string, string>) {
       return true
       return true
     })
     })
 
 
+    const touchesBranding = entries.some(([key]) => key.startsWith('branding.'))
+    if (touchesBranding) {
+      // The card is hidden without a licence, but a client can post the key
+      // anyway, so the licence is checked again here where it counts.
+      if (isCloudMode() || !(await installHasLicense())) {
+        throw new Error('Custom app branding needs a white-label licence on a self-hosted install')
+      }
+      const appName = entries.find(([key]) => key === SYSTEM_SETTING_KEYS.BRANDING_APP_NAME)
+      if (appName) {
+        // Capped here because this is the only door, and the name lands in
+        // every browser tab on the install.
+        appName[1] = appName[1].trim().slice(0, BRANDING_APP_NAME_MAX_LENGTH)
+      }
+    }
+
     const broadcast = entries.find(([key]) => key === SYSTEM_SETTING_KEYS.BROADCAST_MESSAGE)
     const broadcast = entries.find(([key]) => key === SYSTEM_SETTING_KEYS.BROADCAST_MESSAGE)
     if (broadcast) {
     if (broadcast) {
       // Trimmed and capped here rather than in the form, because this is the
       // Trimmed and capped here rather than in the form, because this is the
@@ -54,6 +72,10 @@ export async function setSystemSettings(input: Record<string, string>) {
       )
       )
     )
     )
 
 
+    // The layout caches the branding for a minute; drop it so the admin sees
+    // the new name on the next render rather than after the cache runs out.
+    if (touchesBranding) invalidateAppBranding()
+
     // Pushed to everyone already looking at a screen. A notice about an
     // Pushed to everyone already looking at a screen. A notice about an
     // outage that only appears on the next navigation reaches people slowest
     // outage that only appears on the next navigation reaches people slowest
     // at exactly the moment it matters most.
     // at exactly the moment it matters most.

+ 18 - 0
src/features/admin/Components/admin-settings.tsx

@@ -14,15 +14,22 @@ import { SYSTEM_SETTING_KEYS, type BroadcastLevel } from '../Schema/systemSettin
 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 { BroadcastCard } from './broadcast-card'
+import { BrandingCard } from './branding-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'
 
 
 export function AdminSettings({
 export function AdminSettings({
   initial,
   initial,
+  mode,
+  brandingAvailable,
+  brandingIconUrl,
 }: {
 }: {
   initial: SystemSettingsMap
   initial: SystemSettingsMap
   mode: 'cloud' | 'self-hosted'
   mode: 'cloud' | 'self-hosted'
+  /** Whether the install holds a white-label licence; without one the branding card is locked. */
+  brandingAvailable: boolean
+  brandingIconUrl: string | null
 }) {
 }) {
   const router = useRouter()
   const router = useRouter()
   const t = useTranslations('admin')
   const t = useTranslations('admin')
@@ -208,6 +215,17 @@ export function AdminSettings({
         }
         }
       />
       />
 
 
+      {/* Shown on every self-hosted install so the operator can see what the
+          licence unlocks; the fields only work once it holds one. Cloud never
+          brands the browser, so the card is absent there. */}
+      {mode === 'self-hosted' && (
+        <BrandingCard
+          locked={!brandingAvailable}
+          initialName={initial[SYSTEM_SETTING_KEYS.BRANDING_APP_NAME] ?? ''}
+          initialIconUrl={brandingIconUrl}
+        />
+      )}
+
       {/* Platform Settings */}
       {/* Platform Settings */}
       <AppCard
       <AppCard
         title={t('adminSettings.platformTitle')}
         title={t('adminSettings.platformTitle')}

+ 223 - 0
src/features/admin/Components/branding-card.tsx

@@ -0,0 +1,223 @@
+'use client'
+
+import { useRef, useState, useTransition } from 'react'
+import Image from 'next/image'
+import Link from 'next/link'
+import { useRouter } from 'next/navigation'
+import { useTranslations } from 'next-intl'
+import { toast } from 'sonner'
+import { AppWindow, ImageIcon, Loader2, Lock, Trash2, Upload } from 'lucide-react'
+import { AppCard } from '@/components/app-card'
+import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
+import { Label } from '@/components/ui/label'
+import { setSystemSettings } from '../Actions/setSystemSettings'
+import { BRANDING_APP_NAME_MAX_LENGTH, SYSTEM_SETTING_KEYS } from '../Schema/systemSettingsSchema'
+
+const ICON_ROUTE = '/api/protected/admin/app-icon'
+
+/** The server's own error text when it sent one, else the fallback. */
+async function errorText(res: Response, fallback: string): Promise<string> {
+  try {
+    const body = (await res.json()) as { error?: unknown }
+    return typeof body.error === 'string' && body.error ? body.error : fallback
+  } catch {
+    return fallback
+  }
+}
+
+/**
+ * What the browser calls this install: tab title, favicon, installed app.
+ *
+ * Saves on its own like the broadcast card. The name and the icon are
+ * separate writes (a settings row and an uploaded file), and neither belongs
+ * to the mail configuration the page's main Save button rewrites.
+ */
+export function BrandingCard({
+  initialName,
+  initialIconUrl,
+  locked = false,
+}: {
+  initialName: string
+  initialIconUrl: string | null
+  /** no white-label licence on this install: everything shown, nothing editable */
+  locked?: boolean
+}) {
+  const t = useTranslations('admin.adminSettings')
+  const router = useRouter()
+  const [name, setName] = useState(initialName)
+  const [savedName, setSavedName] = useState(initialName)
+  const [iconUrl, setIconUrl] = useState(initialIconUrl)
+  const [iconBusy, setIconBusy] = useState(false)
+  const [isPending, startTransition] = useTransition()
+  const fileRef = useRef<HTMLInputElement>(null)
+
+  const unchanged = name.trim() === savedName.trim()
+
+  const saveName = () => {
+    const value = name.trim()
+    startTransition(async () => {
+      const result = await setSystemSettings({ [SYSTEM_SETTING_KEYS.BRANDING_APP_NAME]: value })
+      if (!result.success) {
+        toast.error(result.error ?? t('branding.saveFailed'))
+        return
+      }
+      setName(value)
+      setSavedName(value)
+      toast.success(t('branding.saved'))
+      router.refresh()
+    })
+  }
+
+  const uploadIcon = async (file: File) => {
+    setIconBusy(true)
+    try {
+      const formData = new FormData()
+      formData.append('file', file)
+      const res = await fetch(ICON_ROUTE, { method: 'POST', body: formData })
+      if (!res.ok) {
+        toast.error(await errorText(res, t('branding.uploadFailed')))
+        return
+      }
+      const data = (await res.json()) as { url: string }
+      setIconUrl(data.url)
+      toast.success(t('branding.uploaded'))
+      router.refresh()
+    } catch {
+      toast.error(t('branding.uploadFailed'))
+    } finally {
+      setIconBusy(false)
+    }
+  }
+
+  const removeIcon = async () => {
+    setIconBusy(true)
+    try {
+      const res = await fetch(ICON_ROUTE, { method: 'DELETE' })
+      if (!res.ok) {
+        toast.error(await errorText(res, t('branding.removeFailed')))
+        return
+      }
+      setIconUrl(null)
+      toast.success(t('branding.removed'))
+      router.refresh()
+    } catch {
+      toast.error(t('branding.removeFailed'))
+    } finally {
+      setIconBusy(false)
+    }
+  }
+
+  return (
+    <AppCard
+      id="app-branding"
+      icon={AppWindow}
+      title={t('branding.title')}
+      description={t('branding.description')}
+      action={
+        locked ? (
+          <span className="inline-flex shrink-0 items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs text-muted-foreground">
+            <Lock className="h-3.5 w-3.5" aria-hidden="true" />
+            {t('branding.lockedBadge')}
+          </span>
+        ) : undefined
+      }
+      contentClassName="space-y-6"
+    >
+      {locked && (
+        <p className="text-sm text-muted-foreground">
+          {t('branding.lockedHint')}{' '}
+          <Link
+            href="/settings/license"
+            className="font-medium text-foreground underline underline-offset-4"
+          >
+            {t('branding.lockedLink')}
+          </Link>
+        </p>
+      )}
+      {/* A disabled fieldset disables every field and button inside it. */}
+      <fieldset disabled={locked} className={locked ? 'space-y-6 opacity-60' : 'space-y-6'}>
+        <div className="space-y-2">
+          <Label htmlFor="branding-app-name">{t('branding.nameLabel')}</Label>
+          <div className="flex flex-col gap-2 sm:flex-row">
+            <Input
+              id="branding-app-name"
+              value={name}
+              onChange={(event) => setName(event.target.value)}
+              maxLength={BRANDING_APP_NAME_MAX_LENGTH}
+              placeholder="TorqVoice"
+              className="sm:max-w-xs"
+            />
+            <Button onClick={saveName} disabled={isPending || unchanged}>
+              {isPending && <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />}
+              {t('branding.save')}
+            </Button>
+          </div>
+          <p className="text-xs text-muted-foreground">{t('branding.nameHint')}</p>
+        </div>
+
+        <div className="space-y-2">
+          <Label>{t('branding.iconLabel')}</Label>
+          <div className="flex flex-wrap items-center gap-4">
+            <div className="flex h-16 w-16 shrink-0 items-center justify-center overflow-hidden rounded-lg border bg-muted/50">
+              {iconUrl ? (
+                <Image
+                  src={iconUrl}
+                  alt={t('branding.iconAlt')}
+                  width={64}
+                  height={64}
+                  unoptimized
+                  className="object-contain"
+                />
+              ) : (
+                <ImageIcon className="h-6 w-6 text-muted-foreground/50" />
+              )}
+            </div>
+            <div className="flex flex-wrap items-center gap-2">
+              <Button
+                type="button"
+                variant="outline"
+                size="sm"
+                onClick={() => fileRef.current?.click()}
+                disabled={iconBusy}
+              >
+                {iconBusy ? (
+                  <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
+                ) : (
+                  <Upload className="mr-1.5 h-3.5 w-3.5" />
+                )}
+                {iconUrl ? t('branding.replace') : t('branding.upload')}
+              </Button>
+              {iconUrl && (
+                <Button
+                  type="button"
+                  variant="ghost"
+                  size="sm"
+                  className="text-destructive hover:text-destructive"
+                  onClick={removeIcon}
+                  disabled={iconBusy}
+                >
+                  <Trash2 className="mr-1.5 h-3.5 w-3.5" />
+                  {t('branding.remove')}
+                </Button>
+              )}
+            </div>
+            <input
+              ref={fileRef}
+              type="file"
+              accept="image/png,image/jpeg,image/webp"
+              className="hidden"
+              data-testid="branding-icon-input"
+              onChange={(event) => {
+                const file = event.target.files?.[0]
+                if (file) uploadIcon(file)
+                event.target.value = ''
+              }}
+            />
+          </div>
+          <p className="text-xs text-muted-foreground">{t('branding.iconHint')}</p>
+        </div>
+      </fieldset>
+    </AppCard>
+  )
+}

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

@@ -67,8 +67,20 @@ export const SYSTEM_SETTING_KEYS = {
   // platform decision, not one the workshop makes for itself.
   // platform decision, not one the workshop makes for itself.
   SUPPORT_ENABLED: 'support.enabled',
   SUPPORT_ENABLED: 'support.enabled',
   SUPPORT_RECIPIENT_EMAIL: 'support.recipientEmail',
   SUPPORT_RECIPIENT_EMAIL: 'support.recipientEmail',
+
+  // What the browser calls this install: the tab title, favicon, and the
+  // name and icon of the installed app. Install-wide, because a white-label
+  // licence can run several workshops and the tab can only say one thing.
+  // Honoured only on a licensed self-hosted install; everywhere else the
+  // app stays TorqVoice. The icon value is the stored file name, served
+  // from /api/public/app-icon.
+  BRANDING_APP_NAME: 'branding.appName',
+  BRANDING_APP_ICON: 'branding.appIcon',
 } as const
 } as const
 
 
+/** Long enough for a workshop name, short enough for a browser tab. */
+export const BRANDING_APP_NAME_MAX_LENGTH = 40
+
 export type SystemSettingKey = (typeof SYSTEM_SETTING_KEYS)[keyof typeof SYSTEM_SETTING_KEYS]
 export type SystemSettingKey = (typeof SYSTEM_SETTING_KEYS)[keyof typeof SYSTEM_SETTING_KEYS]
 
 
 export const ALL_SYSTEM_KEYS = Object.values(SYSTEM_SETTING_KEYS)
 export const ALL_SYSTEM_KEYS = Object.values(SYSTEM_SETTING_KEYS)

+ 102 - 1
src/features/settings/Components/license-settings.tsx

@@ -1,6 +1,7 @@
 'use client'
 'use client'
 
 
 import { useState } from 'react'
 import { useState } from 'react'
+import Link from 'next/link'
 import { useRouter } from 'next/navigation'
 import { useRouter } from 'next/navigation'
 import { useTranslations } from 'next-intl'
 import { useTranslations } from 'next-intl'
 import { toast } from 'sonner'
 import { toast } from 'sonner'
@@ -9,7 +10,17 @@ import { Button } from '@/components/ui/button'
 import { Label } from '@/components/ui/label'
 import { Label } from '@/components/ui/label'
 import { Badge } from '@/components/ui/badge'
 import { Badge } from '@/components/ui/badge'
 import { AppCard } from '@/components/app-card'
 import { AppCard } from '@/components/app-card'
-import { ExternalLink, Key, Loader2 } from 'lucide-react'
+import {
+  ArrowRight,
+  ExternalLink,
+  ImageIcon,
+  Info,
+  Key,
+  Loader2,
+  Lock,
+  type LucideIcon,
+  Type,
+} from 'lucide-react'
 import { validateLicense } from '../Actions/validateLicense'
 import { validateLicense } from '../Actions/validateLicense'
 import {
 import {
   LICENSE_TOKEN_MAX_AGE_DAYS,
   LICENSE_TOKEN_MAX_AGE_DAYS,
@@ -19,12 +30,72 @@ import {
 
 
 const DAY_MS = 24 * 60 * 60 * 1000
 const DAY_MS = 24 * 60 * 60 * 1000
 
 
+/**
+ * One line of the "make it yours" list. The list is always shown, so a
+ * workshop sees what the licence unlocks before buying one: a locked row
+ * says so, a row without a destination is for a member who is not the
+ * install's administrator, and the rest are links.
+ */
+function NextStep({
+  href,
+  icon: Icon,
+  label,
+  lockedLabel,
+}: {
+  href: string | null
+  icon: LucideIcon
+  label: string
+  /** shown instead of the arrow while the licence is missing */
+  lockedLabel?: string
+}) {
+  const content = (
+    <span className="flex items-center gap-3">
+      <Icon className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden="true" />
+      <span>{label}</span>
+    </span>
+  )
+  if (lockedLabel) {
+    return (
+      <div
+        aria-disabled="true"
+        className="flex items-center justify-between gap-4 px-4 py-3 text-sm text-muted-foreground/70"
+      >
+        {content}
+        <span className="flex shrink-0 items-center gap-1.5 text-xs">
+          <Lock className="h-3.5 w-3.5" aria-hidden="true" />
+          {lockedLabel}
+        </span>
+      </div>
+    )
+  }
+  if (!href) {
+    return (
+      <div className="flex items-center justify-between gap-4 px-4 py-3 text-sm text-muted-foreground">
+        {content}
+      </div>
+    )
+  }
+  return (
+    <Link
+      href={href}
+      className="group flex items-center justify-between gap-4 px-4 py-3 text-sm transition-colors hover:bg-muted/60"
+    >
+      {content}
+      <ArrowRight
+        className="h-4 w-4 shrink-0 text-muted-foreground transition-transform group-hover:translate-x-0.5"
+        aria-hidden="true"
+      />
+    </Link>
+  )
+}
+
 export function LicenseSettings({
 export function LicenseSettings({
   initialKey,
   initialKey,
   initialStatus,
   initialStatus,
   initialExpiresAt,
   initialExpiresAt,
   initialIssuedAt,
   initialIssuedAt,
   initialCheckedAt,
   initialCheckedAt,
+  brandingHref,
   demoMode = false,
   demoMode = false,
 }: {
 }: {
   initialKey: string
   initialKey: string
@@ -35,6 +106,8 @@ export function LicenseSettings({
   initialIssuedAt: string
   initialIssuedAt: string
   /** when this server last tried, reachable or not */
   /** when this server last tried, reachable or not */
   initialCheckedAt: string
   initialCheckedAt: string
+  /** the admin page that names the install in the browser; null for anyone who may not open it */
+  brandingHref: string | null
   demoMode?: boolean
   demoMode?: boolean
 }) {
 }) {
   const router = useRouter()
   const router = useRouter()
@@ -155,6 +228,34 @@ export function LicenseSettings({
         </div>
         </div>
         <p className="text-xs text-muted-foreground">{t('license.keyHint')}</p>
         <p className="text-xs text-muted-foreground">{t('license.keyHint')}</p>
         <p className="text-xs text-muted-foreground">{t('license.signedHint')}</p>
         <p className="text-xs text-muted-foreground">{t('license.signedHint')}</p>
+        <div id="license-next-steps" className="space-y-3 pt-2">
+          <div className="space-y-1">
+            <p className="text-sm font-medium">{t('license.makeItYours')}</p>
+            <p className="text-xs text-muted-foreground">
+              {licenseValid ? t('license.makeItYoursHint') : t('license.makeItYoursLocked')}
+            </p>
+          </div>
+          <div className="divide-y overflow-hidden rounded-lg border bg-muted/20">
+            <NextStep
+              href={brandingHref}
+              icon={Type}
+              label={t('license.linkAppName')}
+              lockedLabel={licenseValid ? undefined : t('license.needsLicense')}
+            />
+            <NextStep
+              href={brandingHref}
+              icon={ImageIcon}
+              label={t('license.linkAppIcon')}
+              lockedLabel={licenseValid ? undefined : t('license.needsLicense')}
+            />
+          </div>
+          {brandingHref === null && (
+            <p className="flex items-start gap-1.5 text-xs text-muted-foreground">
+              <Info className="mt-0.5 h-3.5 w-3.5 shrink-0" aria-hidden="true" />
+              <span>{t('license.adminOnlyHint')}</span>
+            </p>
+          )}
+        </div>
       </AppCard>
       </AppCard>
     </div>
     </div>
   )
   )

+ 113 - 0
src/lib/branding.ts

@@ -0,0 +1,113 @@
+import 'server-only'
+
+import path from 'node:path'
+import { db } from '@/lib/db'
+import { uploadsRoot } from '@/lib/upload-root'
+import { installHasLicense, isCloudMode } from '@/lib/features'
+import {
+  BRANDING_APP_NAME_MAX_LENGTH,
+  SYSTEM_SETTING_KEYS,
+} from '@/features/admin/Schema/systemSettingsSchema'
+
+/**
+ * What the browser calls this install: tab title, favicon, home-screen and
+ * installed-app name and icon. Only a licensed self-hosted install may change
+ * it; everywhere else it is TorqVoice. Documents, link previews and the rest
+ * of the app are not touched by this.
+ */
+
+export const DEFAULT_APP_NAME = 'TorqVoice'
+
+export type AppBranding = {
+  name: string
+  iconUrl: string | null
+  /** True when the install is licensed and has a name or an icon set. */
+  custom: boolean
+}
+
+const DEFAULT_BRANDING: AppBranding = { name: DEFAULT_APP_NAME, iconUrl: null, custom: false }
+
+// The root layout asks on every page, and the licence check verifies signed
+// tokens, so the answer is kept for a minute. Saving a branding setting clears it.
+const CACHE_TTL_MS = 60_000
+
+// On globalThis: Next can load this module more than once, and each copy
+// would otherwise keep its own stale answer.
+const globalForBranding = globalThis as unknown as {
+  __appBranding?: { value: AppBranding; expiresAt: number }
+}
+
+/** Install-owned, never referenced by a workshop's records. */
+export function brandingDir(): string {
+  return path.join(uploadsRoot(), 'system', 'branding')
+}
+
+export const APP_ICON_MIME_TYPES: Record<string, string> = {
+  png: 'image/png',
+  jpg: 'image/jpeg',
+  jpeg: 'image/jpeg',
+  webp: 'image/webp',
+  avif: 'image/avif',
+}
+
+/**
+ * Whether a stored icon name is a bare file name we would have written. The
+ * setting is read from the database and joined onto a path, so anything that
+ * could climb out of the branding directory is refused here.
+ */
+export function isSafeIconFileName(value: string): boolean {
+  if (!value || value !== path.basename(value)) return false
+  if (value.includes('..') || value.includes('/') || value.includes('\\')) return false
+  const ext = value.split('.').pop()?.toLowerCase() ?? ''
+  return Object.hasOwn(APP_ICON_MIME_TYPES, ext) && value.length > ext.length + 1
+}
+
+/** The public URL of a stored icon; the file name doubles as a cache-buster. */
+export function appIconUrl(fileName: string): string {
+  return `/api/public/app-icon?v=${encodeURIComponent(fileName)}`
+}
+
+async function readBranding(): Promise<AppBranding> {
+  if (isCloudMode()) return DEFAULT_BRANDING
+  if (!(await installHasLicense())) return DEFAULT_BRANDING
+
+  const rows = await db.systemSetting.findMany({
+    where: {
+      key: { in: [SYSTEM_SETTING_KEYS.BRANDING_APP_NAME, SYSTEM_SETTING_KEYS.BRANDING_APP_ICON] },
+    },
+    select: { key: true, value: true },
+  })
+  const value = (key: string) => rows.find((row) => row.key === key)?.value?.trim() ?? ''
+
+  const name = value(SYSTEM_SETTING_KEYS.BRANDING_APP_NAME).slice(0, BRANDING_APP_NAME_MAX_LENGTH)
+  const icon = value(SYSTEM_SETTING_KEYS.BRANDING_APP_ICON)
+
+  return {
+    name: name || DEFAULT_APP_NAME,
+    iconUrl: icon ? appIconUrl(icon) : null,
+    custom: Boolean(name || icon),
+  }
+}
+
+export async function getAppBranding(): Promise<AppBranding> {
+  const cached = globalForBranding.__appBranding
+  if (cached && cached.expiresAt > Date.now()) return cached.value
+
+  let value: AppBranding
+  try {
+    value = await readBranding()
+  } catch (error) {
+    // A tab title is not worth failing a page over, and the build renders
+    // without a database.
+    console.error('[branding] Falling back to the default:', error)
+    return DEFAULT_BRANDING
+  }
+
+  globalForBranding.__appBranding = { value, expiresAt: Date.now() + CACHE_TTL_MS }
+  return value
+}
+
+/** Forget the cached branding, so the next page shows what was just saved. */
+export function invalidateAppBranding(): void {
+  globalForBranding.__appBranding = undefined
+}

+ 29 - 0
src/lib/files/manager.ts

@@ -325,6 +325,35 @@ export async function removeOrganizationFiles(
   }
   }
 }
 }
 
 
+/**
+ * A file the install itself owns, under `<uploads>/system/<folder>` and
+ * outside every workshop's folder: today only the app icon a white-label
+ * install sets. No workshop row points at it and no export carries it, so
+ * there is nothing to count; a replaced or removed one still goes to the
+ * trash like any other release, filed under `system` where a workshop id
+ * would be, and purgeTrash expires it with the rest.
+ */
+export async function releaseInstallFile(
+  folder: string,
+  name: string,
+  { now = Date.now() }: { now?: number } = {}
+): Promise<boolean> {
+  if (!FILE_NAME.test(name) || name.includes('..')) return false
+  const file: StoredFile = { organizationId: 'system', folder, name, suffix: `/${folder}/${name}` }
+  const root = uploadsRoot()
+  const target = pathUnder(root, file)
+  if (!target) return false
+  // The caller has already saved the change this file belonged to, so a
+  // trash that cannot be written is logged and leaves the file behind for
+  // the sweep, never an error for a setting that is already in place.
+  try {
+    return await trashFile(root, target, file, now)
+  } catch (err) {
+    console.error(`[files] could not release install file ${file.suffix}:`, err)
+    return false
+  }
+}
+
 /**
 /**
  * After a backup has written its files into one of the workshop's folders:
  * After a backup has written its files into one of the workshop's folders:
  * the files there that the backup did not bring back go through the same
  * the files there that the backup did not bring back go through the same