From f4203c498a1d09d0a4a7fd21d7b336b990a8501f Mon Sep 17 00:00:00 2001 From: Andrea Leardini Date: Tue, 1 Sep 2026 15:51:17 +0200 Subject: [PATCH 01/23] feat(ui): add rebranding ui --- frontend/package-lock.json | 2 +- frontend/package.json | 6 + .../rebranding_previews/nethvoice-favicon.ico | Bin 0 -> 4682 bytes .../nethvoice-login-background.webp | Bin 0 -> 9512 bytes .../nethvoice-logo-dark.svg | 20 ++ .../nethvoice-logo-light.svg | 20 ++ .../nethvoice-logo-square-dark.svg | 4 + .../nethvoice-logo-square-light.svg | 4 + .../applications/ApplicationInfoCard.vue | 2 +- .../components/customers/CustomerInfoCard.vue | 67 ++++- .../distributors/DistributorInfoCard.vue | 69 ++++- .../AddCompaniesToRebrandingDrawer.vue | 171 +++++++++++ .../rebranding/RebrandingAssetField.vue | 111 +++++++ .../RebrandingConfigurationForm.vue | 123 ++++++++ .../RebrandingConfigurationPanel.vue | 126 ++++++++ .../RebrandingOrganizationsTable.vue | 275 ++++++++++++++++++ .../rebranding/RebrandingOwnerPanel.vue | 62 ++++ .../rebranding/RebrandingSummaryCards.vue | 55 ++++ .../rebranding/RemoveFromRebrandingModal.vue | 87 ++++++ .../rebranding/preview/LoginScreenPreview.vue | 91 ++++++ .../preview/RebrandingPreviewPanel.vue | 172 +++++++++++ .../rebranding/preview/TopBarPreview.vue | 70 +++++ .../components/resellers/ResellerInfoCard.vue | 67 ++++- frontend/src/components/shell/SideMenu.vue | 19 +- .../src/components/systems/SystemInfoCard.vue | 2 +- .../useAvailableRebrandingOrganizations.ts | 71 +++++ .../src/composables/useRebrandingAssetUrls.ts | 65 +++++ .../composables/useRebrandingConfiguration.ts | 192 ++++++++++++ frontend/src/i18n/en/translation.json | 65 +++++ frontend/src/lib/organizations/customers.ts | 3 + .../src/lib/organizations/distributors.ts | 3 + frontend/src/lib/organizations/resellers.ts | 3 + frontend/src/lib/permissions.ts | 21 ++ .../src/lib/rebranding/previewTheme.test.ts | 159 ++++++++++ frontend/src/lib/rebranding/previewTheme.ts | 133 +++++++++ frontend/src/lib/rebranding/rebranding.ts | 175 +++++++++++ .../lib/rebranding/rebrandingAssets.test.ts | 268 +++++++++++++++++ .../src/lib/rebranding/rebrandingAssets.ts | 157 ++++++++++ .../rebrandingOrganizations.test.ts | 116 ++++++++ .../lib/rebranding/rebrandingOrganizations.ts | 199 +++++++++++++ .../queries/rebranding/myRebrandingStatus.ts | 42 +++ .../rebranding/rebrandingOrganizations.ts | 118 ++++++++ .../queries/rebranding/rebrandingProducts.ts | 34 +++ .../queries/rebranding/rebrandingSummary.ts | 20 ++ frontend/src/router/index.ts | 5 + frontend/src/views/RebrandingView.vue | 71 +++++ 46 files changed, 3535 insertions(+), 10 deletions(-) create mode 100644 frontend/src/assets/rebranding_previews/nethvoice-favicon.ico create mode 100644 frontend/src/assets/rebranding_previews/nethvoice-login-background.webp create mode 100644 frontend/src/assets/rebranding_previews/nethvoice-logo-dark.svg create mode 100644 frontend/src/assets/rebranding_previews/nethvoice-logo-light.svg create mode 100644 frontend/src/assets/rebranding_previews/nethvoice-logo-square-dark.svg create mode 100644 frontend/src/assets/rebranding_previews/nethvoice-logo-square-light.svg create mode 100644 frontend/src/components/rebranding/AddCompaniesToRebrandingDrawer.vue create mode 100644 frontend/src/components/rebranding/RebrandingAssetField.vue create mode 100644 frontend/src/components/rebranding/RebrandingConfigurationForm.vue create mode 100644 frontend/src/components/rebranding/RebrandingConfigurationPanel.vue create mode 100644 frontend/src/components/rebranding/RebrandingOrganizationsTable.vue create mode 100644 frontend/src/components/rebranding/RebrandingOwnerPanel.vue create mode 100644 frontend/src/components/rebranding/RebrandingSummaryCards.vue create mode 100644 frontend/src/components/rebranding/RemoveFromRebrandingModal.vue create mode 100644 frontend/src/components/rebranding/preview/LoginScreenPreview.vue create mode 100644 frontend/src/components/rebranding/preview/RebrandingPreviewPanel.vue create mode 100644 frontend/src/components/rebranding/preview/TopBarPreview.vue create mode 100644 frontend/src/composables/useAvailableRebrandingOrganizations.ts create mode 100644 frontend/src/composables/useRebrandingAssetUrls.ts create mode 100644 frontend/src/composables/useRebrandingConfiguration.ts create mode 100644 frontend/src/lib/rebranding/previewTheme.test.ts create mode 100644 frontend/src/lib/rebranding/previewTheme.ts create mode 100644 frontend/src/lib/rebranding/rebranding.ts create mode 100644 frontend/src/lib/rebranding/rebrandingAssets.test.ts create mode 100644 frontend/src/lib/rebranding/rebrandingAssets.ts create mode 100644 frontend/src/lib/rebranding/rebrandingOrganizations.test.ts create mode 100644 frontend/src/lib/rebranding/rebrandingOrganizations.ts create mode 100644 frontend/src/queries/rebranding/myRebrandingStatus.ts create mode 100644 frontend/src/queries/rebranding/rebrandingOrganizations.ts create mode 100644 frontend/src/queries/rebranding/rebrandingProducts.ts create mode 100644 frontend/src/queries/rebranding/rebrandingSummary.ts create mode 100644 frontend/src/views/RebrandingView.vue diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 162055ee1..8b47fb894 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1621,7 +1621,7 @@ }, "node_modules/@nethesis/nethesis-light-svg-icons": { "version": "6.2.1", - "resolved": "git+ssh://git@github.com/nethesis/Font-Awesome.git#b701b3a6018f3327e0cedcda9650a621eeb2cbb2", + "resolved": "git+ssh://git@github.com/nethesis/Font-Awesome.git#581a497e2f8e45634e9aef3f5cfc199489cc455f", "hasInstallScript": true, "license": "UNLICENSED", "dependencies": { diff --git a/frontend/package.json b/frontend/package.json index bc1c1fc38..302b52d1e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -83,5 +83,11 @@ "vite-plugin-vue-devtools": "^7.7.2", "vitest": "^4.1.11", "vue-tsc": "^2.2.8" + }, + "allowScripts": { + "vue-demi@0.14.10": true, + "github:nethesis/Font-Awesome#16419000ca62bc35db676d033ef00b8ef9771024": true, + "esbuild@0.25.9": true, + "github:nethesis/Font-Awesome#581a497e2f8e45634e9aef3f5cfc199489cc455f": true } } diff --git a/frontend/src/assets/rebranding_previews/nethvoice-favicon.ico b/frontend/src/assets/rebranding_previews/nethvoice-favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..004d5e3625a944be7b72a12717440bc5a39b1046 GIT binary patch literal 4682 zcmeHJOHRWu5S^-VKw{AV66Ucbzy$zYqG1Sb&?rvBIt4gC$xH>z zWE3z87zK<1|55=S9S_}Af666WdJ$U}b|=_9Us;nwibB|k`{+ux(O8W8{=T_zVFeo8 z1}{&tn0S}D_#rr$=e8WveXBcyQC$s;U&Vy=Iu~qAIOSYiu%*9t938&uyJSf#mSE4S z|B*G}WTHQ#ye?vv&yyB}UB=(;<5|+T{)UJf#X9<)@G$?g(o%%Nk}S+{>?c~&v3Uq% zjJ4O3YmAsly@oB{q7PVmk+TK95^|+ItpspE++EDk$Xn#scrUUS^_;t|v1c=#CKaB@ zyG#3BdC)!8b9_VeW@>w1NAxhARoZB;*-Kc`S;$T_*L_M13Is!ga7~l literal 0 HcmV?d00001 diff --git a/frontend/src/assets/rebranding_previews/nethvoice-login-background.webp b/frontend/src/assets/rebranding_previews/nethvoice-login-background.webp new file mode 100644 index 0000000000000000000000000000000000000000..b6a3a312be08fa55b92a1c3bdcfd335223091836 GIT binary patch literal 9512 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 BbyolY literal 0 HcmV?d00001 diff --git a/frontend/src/assets/rebranding_previews/nethvoice-logo-dark.svg b/frontend/src/assets/rebranding_previews/nethvoice-logo-dark.svg new file mode 100644 index 000000000..14c586c23 --- /dev/null +++ b/frontend/src/assets/rebranding_previews/nethvoice-logo-dark.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/assets/rebranding_previews/nethvoice-logo-light.svg b/frontend/src/assets/rebranding_previews/nethvoice-logo-light.svg new file mode 100644 index 000000000..2839f9c1c --- /dev/null +++ b/frontend/src/assets/rebranding_previews/nethvoice-logo-light.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/assets/rebranding_previews/nethvoice-logo-square-dark.svg b/frontend/src/assets/rebranding_previews/nethvoice-logo-square-dark.svg new file mode 100644 index 000000000..b6fbebb66 --- /dev/null +++ b/frontend/src/assets/rebranding_previews/nethvoice-logo-square-dark.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/src/assets/rebranding_previews/nethvoice-logo-square-light.svg b/frontend/src/assets/rebranding_previews/nethvoice-logo-square-light.svg new file mode 100644 index 000000000..fa600ad28 --- /dev/null +++ b/frontend/src/assets/rebranding_previews/nethvoice-logo-square-light.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/src/components/applications/ApplicationInfoCard.vue b/frontend/src/components/applications/ApplicationInfoCard.vue index bf450b8d9..c86da53b9 100644 --- a/frontend/src/components/applications/ApplicationInfoCard.vue +++ b/frontend/src/components/applications/ApplicationInfoCard.vue @@ -156,7 +156,7 @@ function getKebabMenuItems() {
-
+
{{ $t('common.notes') }}
{{
diff --git a/frontend/src/components/customers/CustomerInfoCard.vue b/frontend/src/components/customers/CustomerInfoCard.vue
index 598d21002..472015f8a 100644
--- a/frontend/src/components/customers/CustomerInfoCard.vue
+++ b/frontend/src/components/customers/CustomerInfoCard.vue
@@ -16,7 +16,7 @@ import { useCustomerDetail } from '@/queries/organizations/customerDetail'
 import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
 import { getOrganizationIcon } from '@/lib/organizations/organizations'
 import DataItem from '../common/DataItem.vue'
-import { ref } from 'vue'
+import { computed, ref } from 'vue'
 import NotesModal from '../common/NotesModal.vue'
 import { canManageCustomers } from '@/lib/permissions'
 import {
@@ -24,6 +24,7 @@ import {
   faCirclePause,
   faCirclePlay,
   faCircleCheck,
+  faCircleXmark,
   faBoxArchive,
 } from '@fortawesome/free-solid-svg-icons'
 import { useI18n } from 'vue-i18n'
@@ -32,9 +33,23 @@ import SuspendCustomerModal from './SuspendCustomerModal.vue'
 import ReactivateCustomerModal from './ReactivateCustomerModal.vue'
 import { getLanguageLabel } from '@/lib/locale'
 import { formatPhoneForDisplay } from '@/lib/phone'
+import UserAvatar from '../users/UserAvatar.vue'
 
 const { t } = useI18n()
 const { state: customerDetail, asyncStatus } = useCustomerDetail()
+
+const rebrandingEnabled = computed(() => customerDetail.value.data?.rebranding_enabled === true)
+const rebrandingText = computed(() =>
+  rebrandingEnabled.value ? t('common.enabled') : t('common.disabled'),
+)
+// Same icon-and-text shape as the status row above: green when on, the muted
+// neutral the row uses for every not-active state when off.
+const rebrandingIcon = computed(() => (rebrandingEnabled.value ? faCircleCheck : faCircleXmark))
+const rebrandingIconClasses = computed(() =>
+  rebrandingEnabled.value
+    ? 'text-green-600 dark:text-green-400'
+    : 'text-gray-700 dark:text-gray-400',
+)
 const isNotesModalShown = ref(false)
 const isShownCreateOrEditCustomerDrawer = ref(false)
 const isShownSuspendCustomerModal = ref(false)
@@ -215,9 +230,57 @@ function getKebabMenuItems() {
             }}
           
         
+        
+        
+          
+          
+        
+        
+        
+          
+          
+        
         
         
-
+
{{ $t('common.notes') }}
{{
diff --git a/frontend/src/components/distributors/DistributorInfoCard.vue b/frontend/src/components/distributors/DistributorInfoCard.vue
index ccb6c15ff..ec7f7c0ca 100644
--- a/frontend/src/components/distributors/DistributorInfoCard.vue
+++ b/frontend/src/components/distributors/DistributorInfoCard.vue
@@ -18,7 +18,7 @@ import { useDistributorDetail } from '@/queries/organizations/distributorDetail'
 import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
 import { getOrganizationIcon } from '@/lib/organizations/organizations'
 import DataItem from '../common/DataItem.vue'
-import { ref } from 'vue'
+import { computed, ref } from 'vue'
 import NotesModal from '../common/NotesModal.vue'
 import { canManageDistributors } from '@/lib/permissions'
 import {
@@ -26,6 +26,7 @@ import {
   faCirclePause,
   faCirclePlay,
   faCircleCheck,
+  faCircleXmark,
   faBoxArchive,
 } from '@fortawesome/free-solid-svg-icons'
 import { useI18n } from 'vue-i18n'
@@ -38,6 +39,19 @@ import UserAvatar from '../users/UserAvatar.vue'
 
 const { t, locale } = useI18n()
 const { state: distributorDetail, asyncStatus } = useDistributorDetail()
+
+const rebrandingEnabled = computed(() => distributorDetail.value.data?.rebranding_enabled === true)
+const rebrandingText = computed(() =>
+  rebrandingEnabled.value ? t('common.enabled') : t('common.disabled'),
+)
+// Same icon-and-text shape as the status row above: green when on, the muted
+// neutral the row uses for every not-active state when off.
+const rebrandingIcon = computed(() => (rebrandingEnabled.value ? faCircleCheck : faCircleXmark))
+const rebrandingIconClasses = computed(() =>
+  rebrandingEnabled.value
+    ? 'text-green-600 dark:text-green-400'
+    : 'text-gray-700 dark:text-gray-400',
+)
 const isNotesModalShown = ref(false)
 const isShownCreateOrEditDistributorDrawer = ref(false)
 const isShownSuspendDistributorModal = ref(false)
@@ -252,9 +266,60 @@ function getKebabMenuItems() {
             
+ + + + + + + + + +
-
+
{{ $t('common.notes') }}
{{
diff --git a/frontend/src/components/rebranding/AddCompaniesToRebrandingDrawer.vue b/frontend/src/components/rebranding/AddCompaniesToRebrandingDrawer.vue
new file mode 100644
index 000000000..5e53246df
--- /dev/null
+++ b/frontend/src/components/rebranding/AddCompaniesToRebrandingDrawer.vue
@@ -0,0 +1,171 @@
+
+
+
+
+
diff --git a/frontend/src/components/rebranding/RebrandingAssetField.vue b/frontend/src/components/rebranding/RebrandingAssetField.vue
new file mode 100644
index 000000000..6a8b40dff
--- /dev/null
+++ b/frontend/src/components/rebranding/RebrandingAssetField.vue
@@ -0,0 +1,111 @@
+
+
+
+
+
diff --git a/frontend/src/components/rebranding/RebrandingConfigurationForm.vue b/frontend/src/components/rebranding/RebrandingConfigurationForm.vue
new file mode 100644
index 000000000..05e4120d1
--- /dev/null
+++ b/frontend/src/components/rebranding/RebrandingConfigurationForm.vue
@@ -0,0 +1,123 @@
+
+
+
+
+
diff --git a/frontend/src/components/rebranding/RebrandingConfigurationPanel.vue b/frontend/src/components/rebranding/RebrandingConfigurationPanel.vue
new file mode 100644
index 000000000..70e09299a
--- /dev/null
+++ b/frontend/src/components/rebranding/RebrandingConfigurationPanel.vue
@@ -0,0 +1,126 @@
+
+
+
+
+
diff --git a/frontend/src/components/rebranding/RebrandingOrganizationsTable.vue b/frontend/src/components/rebranding/RebrandingOrganizationsTable.vue
new file mode 100644
index 000000000..162e8fdbc
--- /dev/null
+++ b/frontend/src/components/rebranding/RebrandingOrganizationsTable.vue
@@ -0,0 +1,275 @@
+
+
+
+
+
diff --git a/frontend/src/components/rebranding/RebrandingOwnerPanel.vue b/frontend/src/components/rebranding/RebrandingOwnerPanel.vue
new file mode 100644
index 000000000..756d57e50
--- /dev/null
+++ b/frontend/src/components/rebranding/RebrandingOwnerPanel.vue
@@ -0,0 +1,62 @@
+
+
+
+
+
diff --git a/frontend/src/components/rebranding/RebrandingSummaryCards.vue b/frontend/src/components/rebranding/RebrandingSummaryCards.vue
new file mode 100644
index 000000000..8b14ad210
--- /dev/null
+++ b/frontend/src/components/rebranding/RebrandingSummaryCards.vue
@@ -0,0 +1,55 @@
+
+
+
+
+
diff --git a/frontend/src/components/rebranding/RemoveFromRebrandingModal.vue b/frontend/src/components/rebranding/RemoveFromRebrandingModal.vue
new file mode 100644
index 000000000..62df839b5
--- /dev/null
+++ b/frontend/src/components/rebranding/RemoveFromRebrandingModal.vue
@@ -0,0 +1,87 @@
+
+
+
+
+
diff --git a/frontend/src/components/rebranding/preview/LoginScreenPreview.vue b/frontend/src/components/rebranding/preview/LoginScreenPreview.vue
new file mode 100644
index 000000000..7c5b3813d
--- /dev/null
+++ b/frontend/src/components/rebranding/preview/LoginScreenPreview.vue
@@ -0,0 +1,91 @@
+
+
+
+
+
+
+
diff --git a/frontend/src/components/rebranding/preview/RebrandingPreviewPanel.vue b/frontend/src/components/rebranding/preview/RebrandingPreviewPanel.vue
new file mode 100644
index 000000000..07402122f
--- /dev/null
+++ b/frontend/src/components/rebranding/preview/RebrandingPreviewPanel.vue
@@ -0,0 +1,172 @@
+
+
+
+
+
diff --git a/frontend/src/components/rebranding/preview/TopBarPreview.vue b/frontend/src/components/rebranding/preview/TopBarPreview.vue
new file mode 100644
index 000000000..ca824d08e
--- /dev/null
+++ b/frontend/src/components/rebranding/preview/TopBarPreview.vue
@@ -0,0 +1,70 @@
+
+
+
+
+
+
+
diff --git a/frontend/src/components/resellers/ResellerInfoCard.vue b/frontend/src/components/resellers/ResellerInfoCard.vue
index b607067ab..31f2c7be7 100644
--- a/frontend/src/components/resellers/ResellerInfoCard.vue
+++ b/frontend/src/components/resellers/ResellerInfoCard.vue
@@ -16,7 +16,7 @@ import { useResellerDetail } from '@/queries/organizations/resellerDetail'
 import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
 import { getOrganizationIcon } from '@/lib/organizations/organizations'
 import DataItem from '../common/DataItem.vue'
-import { ref } from 'vue'
+import { computed, ref } from 'vue'
 import NotesModal from '../common/NotesModal.vue'
 import { canManageResellers, canPromoteOrganizations } from '@/lib/permissions'
 import {
@@ -24,6 +24,7 @@ import {
   faCirclePause,
   faCirclePlay,
   faCircleCheck,
+  faCircleXmark,
   faBoxArchive,
   faCircleUp,
 } from '@fortawesome/free-solid-svg-icons'
@@ -34,9 +35,23 @@ import ReactivateResellerModal from './ReactivateResellerModal.vue'
 import PromoteResellerModal from './PromoteResellerModal.vue'
 import { getLanguageLabel } from '@/lib/locale'
 import { formatPhoneForDisplay } from '@/lib/phone'
+import UserAvatar from '../users/UserAvatar.vue'
 
 const { t } = useI18n()
 const { state: resellerDetail, asyncStatus } = useResellerDetail()
+
+const rebrandingEnabled = computed(() => resellerDetail.value.data?.rebranding_enabled === true)
+const rebrandingText = computed(() =>
+  rebrandingEnabled.value ? t('common.enabled') : t('common.disabled'),
+)
+// Same icon-and-text shape as the status row above: green when on, the muted
+// neutral the row uses for every not-active state when off.
+const rebrandingIcon = computed(() => (rebrandingEnabled.value ? faCircleCheck : faCircleXmark))
+const rebrandingIconClasses = computed(() =>
+  rebrandingEnabled.value
+    ? 'text-green-600 dark:text-green-400'
+    : 'text-gray-700 dark:text-gray-400',
+)
 const isNotesModalShown = ref(false)
 const isShownCreateOrEditResellerDrawer = ref(false)
 const isShownSuspendResellerModal = ref(false)
@@ -230,9 +245,57 @@ function getKebabMenuItems() {
             }}
           
         
+        
+        
+          
+          
+        
+        
+        
+          
+          
+        
         
         
-
+
{{ $t('common.notes') }}
{{
diff --git a/frontend/src/components/shell/SideMenu.vue b/frontend/src/components/shell/SideMenu.vue
index b72f47213..c29eb798a 100644
--- a/frontend/src/components/shell/SideMenu.vue
+++ b/frontend/src/components/shell/SideMenu.vue
@@ -23,6 +23,7 @@ import {
   faServer as fasServer,
   faTriangleExclamation,
   faPuzzlePiece,
+  faPalette,
 } from '@fortawesome/free-solid-svg-icons'
 import { faGridOne as fasGridOne } from '@nethesis/nethesis-solid-svg-icons'
 import {
@@ -35,17 +36,21 @@ import {
   faGrid2 as falGrid2,
   faTriangleExclamation as falTriangleExclamation,
   faPuzzlePiece as falPuzzlePiece,
+  faPalette as falPalette,
 } from '@nethesis/nethesis-light-svg-icons'
 import {
   canReadApplications,
   canReadCustomers,
   canReadDistributors,
+  canReadRebranding,
+  isRebrandingAdmin,
   canReadResellers,
   canReadSystems,
   canReadUsers,
   isAddonAdmin,
 } from '@/lib/permissions'
 import { isUserCustomer } from '@/lib/organizations/organizations'
+import { useMyRebrandingStatus } from '@/queries/rebranding/myRebrandingStatus'
 
 type MenuItem = {
   name: string
@@ -64,13 +69,14 @@ const { t } = useI18n()
 const route = useRoute()
 const loginStore = useLoginStore()
 const menuItemsExpandedLoaded = ref(false)
+const { isEnabled: isMyRebrandingEnabled } = useMyRebrandingStatus()
 
 const menuExpanded: Ref> = ref({
   distributors: false,
   resellers: false,
 })
 
-const systemsManagementRoutes = ['alerts', 'systems', 'applications', 'addons']
+const systemsManagementRoutes = ['alerts', 'systems', 'applications', 'addons', 'rebranding']
 const companiesAndUsersRoutes = ['distributors', 'resellers', 'customers', 'users']
 
 const navigation = computed(() => {
@@ -117,6 +123,17 @@ const navigation = computed(() => {
     })
   }
 
+  // Owner-level users decide who may rebrand, so they always reach the page;
+  // everybody else only once their own company has been enabled.
+  if (canReadRebranding() && (isRebrandingAdmin() || isMyRebrandingEnabled.value)) {
+    menuItems.push({
+      name: 'rebranding.title',
+      to: 'rebranding',
+      solidIcon: faPalette,
+      lightIcon: falPalette,
+    })
+  }
+
   if (canReadDistributors()) {
     menuItems.push({
       name: 'distributors.title',
diff --git a/frontend/src/components/systems/SystemInfoCard.vue b/frontend/src/components/systems/SystemInfoCard.vue
index 17453b5de..5b2d0636d 100644
--- a/frontend/src/components/systems/SystemInfoCard.vue
+++ b/frontend/src/components/systems/SystemInfoCard.vue
@@ -304,7 +304,7 @@ function getKebabMenuItems() {
         
         
         
-
+
{{ $t('common.notes') }}
{{
diff --git a/frontend/src/composables/useAvailableRebrandingOrganizations.ts b/frontend/src/composables/useAvailableRebrandingOrganizations.ts
new file mode 100644
index 000000000..42b99a241
--- /dev/null
+++ b/frontend/src/composables/useAvailableRebrandingOrganizations.ts
@@ -0,0 +1,71 @@
+//  Copyright (C) 2026 Nethesis S.r.l.
+//  SPDX-License-Identifier: GPL-3.0-or-later
+
+import { OPTIONS_PAGE_SIZE } from '@/lib/common'
+import { getOrganizationIcon } from '@/lib/organizations/organizations'
+import { canReadRebranding } from '@/lib/permissions'
+import { REBRANDING_AVAILABLE_KEY } from '@/lib/rebranding/rebranding'
+import { getAvailableRebrandingOrganizations } from '@/lib/rebranding/rebrandingOrganizations'
+import { useLoginStore } from '@/stores/login'
+import { useQuery } from '@pinia/colada'
+import { useDebounceFn } from '@vueuse/core'
+import { computed, ref, toValue, watch, type MaybeRefOrGetter } from 'vue'
+import type { NeMultiselectComboboxOption } from '@nethesis/vue-components'
+import { useI18n } from 'vue-i18n'
+
+/**
+ * Server-searched options for the "Add companies to rebranding" picker.
+ *
+ * The endpoint already excludes the organizations that are enabled and the
+ * owner organization itself, so whatever comes back can be offered as is. It
+ * has no pagination and caps at 200 rows, which is why the search runs on the
+ * server rather than over a prefetched list.
+ *
+ * @param enabled gates the request; pass the drawer's visibility so nothing is
+ *   fetched while it is closed. Defaults to always on.
+ */
+export function useAvailableRebrandingOrganizations(enabled?: MaybeRefOrGetter) {
+  const { t } = useI18n()
+  const loginStore = useLoginStore()
+  const searchInput = ref('')
+  const debouncedSearch = ref('')
+
+  watch(
+    () => searchInput.value,
+    useDebounceFn(() => {
+      debouncedSearch.value = searchInput.value
+    }, 300),
+  )
+
+  const { state, asyncStatus } = useQuery({
+    key: () => [REBRANDING_AVAILABLE_KEY, debouncedSearch.value],
+    enabled: () => !!loginStore.jwtToken && canReadRebranding() && toValue(enabled ?? true),
+    query: () => getAvailableRebrandingOrganizations(debouncedSearch.value, OPTIONS_PAGE_SIZE),
+  })
+
+  const organizations = computed(() => state.value.data ?? [])
+
+  // The id is the Logto id, which is what the enable endpoint resolves against:
+  // sending the database id answers 400 "organization_ids unknown".
+  const options = computed(() =>
+    organizations.value.map((organization) => ({
+      id: organization.logto_id,
+      label: organization.name,
+      description: t(`organizations.${organization.organization_type}`),
+      icon: getOrganizationIcon(organization.organization_type),
+    })),
+  )
+
+  const loading = computed(() => asyncStatus.value === 'loading')
+
+  function onSearch(query: string) {
+    searchInput.value = query
+  }
+
+  function resetSearch() {
+    searchInput.value = ''
+    debouncedSearch.value = ''
+  }
+
+  return { options, organizations, loading, onSearch, resetSearch, currentSearch: searchInput }
+}
diff --git a/frontend/src/composables/useRebrandingAssetUrls.ts b/frontend/src/composables/useRebrandingAssetUrls.ts
new file mode 100644
index 000000000..955f65dc1
--- /dev/null
+++ b/frontend/src/composables/useRebrandingAssetUrls.ts
@@ -0,0 +1,65 @@
+//  Copyright (C) 2026 Nethesis S.r.l.
+//  SPDX-License-Identifier: GPL-3.0-or-later
+
+import { useObjectUrl } from '@vueuse/core'
+import { computed, toValue, type ComputedRef, type MaybeRefOrGetter } from 'vue'
+import {
+  getRebrandingAssetUrl,
+  REBRANDING_ASSET_NAMES,
+  type RebrandingAssetName,
+} from '@/lib/rebranding/rebranding'
+import type { AssetSlots } from '@/lib/rebranding/rebrandingAssets'
+
+export type RebrandingAssetUrls = Record
+
+/**
+ * Where each asset can currently be seen: the blob of a file picked in this
+ * session, otherwise the public URL of what the server holds, otherwise
+ * nothing. Callers add their own fallback artwork on top.
+ *
+ * Resolve this once per screen and share it — every call mints its own blob
+ * URLs for the pending files.
+ */
+export function useRebrandingAssetUrls(
+  slots: MaybeRefOrGetter,
+  organizationId: MaybeRefOrGetter,
+  productId: MaybeRefOrGetter,
+): ComputedRef {
+  // One call per asset, at setup top level: the asset set is a fixed-size
+  // constant, and useObjectUrl revokes each blob when its file changes or the
+  // component unmounts.
+  const objectUrls: Record> = {
+    logo_light_rect: useObjectUrl(() => toValue(slots).logo_light_rect.file ?? undefined),
+    logo_dark_rect: useObjectUrl(() => toValue(slots).logo_dark_rect.file ?? undefined),
+    logo_light_square: useObjectUrl(() => toValue(slots).logo_light_square.file ?? undefined),
+    logo_dark_square: useObjectUrl(() => toValue(slots).logo_dark_square.file ?? undefined),
+    favicon: useObjectUrl(() => toValue(slots).favicon.file ?? undefined),
+    background_image: useObjectUrl(() => toValue(slots).background_image.file ?? undefined),
+  }
+
+  return computed(() => {
+    const currentSlots = toValue(slots)
+    const currentOrganizationId = toValue(organizationId)
+    const currentProductId = toValue(productId)
+    const urls = {} as RebrandingAssetUrls
+
+    for (const name of REBRANDING_ASSET_NAMES) {
+      const slot = currentSlots[name]
+      const objectUrl = objectUrls[name].value
+
+      if (objectUrl) {
+        urls[name] = objectUrl
+      } else if (slot.existing && !slot.cleared && currentOrganizationId && currentProductId) {
+        urls[name] = getRebrandingAssetUrl(
+          currentOrganizationId,
+          currentProductId,
+          name,
+          slot.existing.updated_at,
+        )
+      } else {
+        urls[name] = null
+      }
+    }
+    return urls
+  })
+}
diff --git a/frontend/src/composables/useRebrandingConfiguration.ts b/frontend/src/composables/useRebrandingConfiguration.ts
new file mode 100644
index 000000000..b63986dda
--- /dev/null
+++ b/frontend/src/composables/useRebrandingConfiguration.ts
@@ -0,0 +1,192 @@
+//  Copyright (C) 2026 Nethesis S.r.l.
+//  SPDX-License-Identifier: GPL-3.0-or-later
+
+import { useMutation, useQueryCache } from '@pinia/colada'
+import { byteFormat1024 } from '@nethesis/vue-components'
+import type { AxiosError } from 'axios'
+import { computed, ref, toValue, watch, type MaybeRefOrGetter } from 'vue'
+import { useI18n } from 'vue-i18n'
+import { getValidationIssues } from '@/lib/validation'
+import {
+  MAX_BRAND_NAME_LENGTH,
+  REBRANDING_ORGANIZATIONS_KEY,
+  REBRANDING_STATUS_KEY,
+  type RebrandingAssetName,
+} from '@/lib/rebranding/rebranding'
+import {
+  buildRebrandingFormData,
+  createAssetSlots,
+  getAssetFileError,
+  getRebrandingUploadError,
+  hasRebrandingChanges,
+  putRebrandingProduct,
+  type AssetSlots,
+} from '@/lib/rebranding/rebrandingAssets'
+import { useMyRebrandingStatus } from '@/queries/rebranding/myRebrandingStatus'
+import { useNotificationsStore } from '@/stores/notifications'
+
+/**
+ * The rebranding configuration draft of one product, for the caller's own
+ * organization: what the server holds, what the user changed, and the single
+ * request that reconciles the two.
+ */
+export function useRebrandingConfiguration(productId: MaybeRefOrGetter) {
+  const { t } = useI18n()
+  const queryCache = useQueryCache()
+  const notificationsStore = useNotificationsStore()
+  const { state, organizationId } = useMyRebrandingStatus()
+
+  const brandName = ref('')
+  const savedBrandName = ref('')
+  const slots = ref(createAssetSlots(undefined))
+  const assetErrors = ref>>({})
+  const validationIssues = ref>({})
+
+  const productStatus = computed(() =>
+    state.value.data?.products.find((product) => product.product_id === toValue(productId)),
+  )
+
+  const productDisplayName = computed(
+    () => productStatus.value?.product_display_name ?? toValue(productId),
+  )
+
+  const loadingStatus = computed(() => state.value.status === 'pending')
+
+  // Re-seed from the server on first load, on a product change, and after a
+  // save invalidates the status — which is what drops stale files and pending
+  // removals once they have been applied.
+  watch(
+    productStatus,
+    (product) => {
+      brandName.value = product?.product_name ?? ''
+      savedBrandName.value = brandName.value
+      slots.value = createAssetSlots(product)
+      assetErrors.value = {}
+      validationIssues.value = {}
+    },
+    { immediate: true },
+  )
+
+  const hasChanges = computed(() =>
+    hasRebrandingChanges(brandName.value, savedBrandName.value, slots.value),
+  )
+
+  const brandNameInvalidMessage = computed(() => {
+    if (brandName.value.trim().length > MAX_BRAND_NAME_LENGTH) {
+      return t('rebranding.brand_name_too_long')
+    }
+    const issue = validationIssues.value.product_name?.[0]
+    return issue ? t(issue) : ''
+  })
+
+  const {
+    mutate: saveMutate,
+    isLoading: saving,
+    reset: saveReset,
+    error: saveError,
+  } = useMutation({
+    mutation: (vars: { organizationId: string; productId: string; formData: FormData }) =>
+      putRebrandingProduct(vars.organizationId, vars.productId, vars.formData),
+    onSuccess() {
+      notificationsStore.createNotification({
+        kind: 'success',
+        title: t('rebranding.configuration_saved'),
+        description: t('rebranding.configuration_saved_description', {
+          product: productDisplayName.value,
+        }),
+      })
+    },
+    onError: (error) => {
+      console.error('Error saving rebranding configuration:', error)
+
+      // Asset rejections do not use the standard validation envelope.
+      const uploadError = getRebrandingUploadError(error as AxiosError)
+
+      if (uploadError) {
+        assetErrors.value[uploadError.field] =
+          uploadError.kind === 'size'
+            ? t('rebranding.asset_too_large', {
+                maxSize: byteFormat1024(uploadError.maxSize ?? 0),
+              })
+            : t('rebranding.asset_invalid_type', { type: uploadError.contentType ?? '' })
+        return
+      }
+      validationIssues.value = getValidationIssues(error as AxiosError, 'rebranding')
+    },
+    onSettled: () => {
+      queryCache.invalidateQueries({ key: [REBRANDING_STATUS_KEY] })
+      // keeps the owner's "Last updated" column and product badges honest
+      queryCache.invalidateQueries({ key: [REBRANDING_ORGANIZATIONS_KEY] })
+    },
+  })
+
+  function selectAsset(name: RebrandingAssetName, file: File | null) {
+    if (!file) {
+      slots.value[name] = { ...slots.value[name], file: null }
+      assetErrors.value[name] = ''
+      return
+    }
+
+    const issue = getAssetFileError(name, file)
+
+    if (issue) {
+      assetErrors.value[name] = t(
+        issue.key,
+        issue.key === 'rebranding.asset_too_large'
+          ? { maxSize: byteFormat1024(Number(issue.params.maxSize)) }
+          : issue.params,
+      )
+      slots.value[name] = { ...slots.value[name], file: null }
+      return
+    }
+
+    assetErrors.value[name] = ''
+    // Picking a file after removing the stored asset is a replacement, so the
+    // pending removal is dropped rather than sent alongside.
+    slots.value[name] = { ...slots.value[name], file, cleared: false }
+  }
+
+  function clearAsset(name: RebrandingAssetName) {
+    slots.value[name] = { ...slots.value[name], file: null, cleared: true }
+    assetErrors.value[name] = ''
+  }
+
+  function clearErrors() {
+    saveReset()
+    assetErrors.value = {}
+    validationIssues.value = {}
+  }
+
+  function save() {
+    clearErrors()
+
+    if (brandNameInvalidMessage.value || !organizationId.value) {
+      return
+    }
+    saveMutate({
+      organizationId: organizationId.value,
+      productId: toValue(productId),
+      formData: buildRebrandingFormData(brandName.value, slots.value),
+    })
+  }
+
+  return {
+    organizationId,
+    productStatus,
+    productDisplayName,
+    loadingStatus,
+    statusState: state,
+    brandName,
+    savedBrandName,
+    slots,
+    assetErrors,
+    hasChanges,
+    brandNameInvalidMessage,
+    saving,
+    saveError,
+    selectAsset,
+    clearAsset,
+    clearErrors,
+    save,
+  }
+}
diff --git a/frontend/src/i18n/en/translation.json b/frontend/src/i18n/en/translation.json
index 70a1034cf..937cc4f89 100644
--- a/frontend/src/i18n/en/translation.json
+++ b/frontend/src/i18n/en/translation.json
@@ -216,6 +216,7 @@
     "resellers_lc": "reseller | resellers",
     "customer": "Customer",
     "customers_lc": "customer | customers",
+    "rebranding": "Rebranding",
     "created_on_behalf_of": "Created on behalf of",
     "created_on_behalf_of_helper": "Assign this {companyType} to another company in your hierarchy if you're creating it on their behalf. Leave this field empty to assign the {companyType} to your own company.",
     "name_cannot_be_empty": "Company name is required",
@@ -979,6 +980,70 @@
     "on_sale_label": "Availability on NethShop",
     "on_sale_helper": "An add-on not on sale cannot be bought, but stays visible where it is granted."
   },
+  "rebranding": {
+    "title": "Rebranding",
+    "owner_description": "Manage the companies that can configure and use custom branding for supported products.",
+    "configuration_description": "Customize the branding applied to supported products by uploading your company logo, favicon, background image and brand name.",
+    "not_available": "Rebranding is not available for your company",
+    "not_available_description": "Ask the owner organization to enable custom branding for your company.",
+    "companies_with_rebranding": "Companies with rebranding",
+    "add_companies": "Add companies",
+    "add_companies_to_rebranding": "Add companies to rebranding",
+    "companies": "Companies",
+    "choose_company": "Choose company",
+    "choose_company_helper": "Selected companies can configure custom branding for supported products",
+    "filter_companies": "Filter companies",
+    "type": "Type",
+    "branded_products": "Branded products",
+    "last_updated": "Last updated",
+    "company": "Company",
+    "no_company_with_rebranding": "No companies added to rebranding",
+    "no_company_with_rebranding_description": "Add companies that can configure and use custom branding for supported products.",
+    "no_company_found": "No companies found",
+    "remove_from_rebranding": "Remove from rebranding",
+    "remove_from_rebranding_title": "Remove {name} from rebranding?",
+    "remove_from_rebranding_message": "The company will no longer be able to configure custom branding, and every asset it uploaded will be deleted. This cannot be undone.",
+    "company_removed": "Company removed from rebranding",
+    "company_removed_description": "{name} can no longer configure custom branding.",
+    "companies_enabled": "Companies added to rebranding",
+    "companies_enabled_description": "{count} company can now configure custom branding. | {count} companies can now configure custom branding.",
+    "select_at_least_one_company": "Select at least one company",
+    "cannot_retrieve_rebranding_organizations": "Cannot retrieve companies with rebranding",
+    "cannot_retrieve_rebranding_summary": "Cannot retrieve rebranding counters",
+    "cannot_retrieve_rebranding_status": "Cannot retrieve rebranding configuration",
+    "cannot_retrieve_rebranding_products": "Cannot retrieve rebrandable products",
+    "cannot_enable_rebranding": "Cannot add companies to rebranding",
+    "cannot_remove_from_rebranding": "Cannot remove company from rebranding",
+    "organization_ids_unknown": "One or more of the selected companies no longer exists",
+    "product": "Product",
+    "configuration_title": "Rebranding configuration",
+    "brand_name": "Brand name",
+    "brand_name_helper": "Replaces the product name across the rebranded product. Leave empty to keep {product}.",
+    "brand_name_too_long": "Brand name cannot exceed 100 characters",
+    "brand_assets": "Brand assets",
+    "brand_assets_description": "Upload only the assets you want to customize. Recommended format: SVG",
+    "logo_light": "Logo light",
+    "logo_dark": "Logo dark",
+    "square_logo_light": "Square logo light",
+    "square_logo_dark": "Square logo dark",
+    "favicon": "Favicon",
+    "background_image": "Background image",
+    "remove_asset": "Remove",
+    "asset_too_large": "File is too large. Maximum size: {maxSize}",
+    "asset_invalid_type": "File type not supported: {type}",
+    "configuration_saved": "Rebranding configuration saved",
+    "configuration_saved_description": "The branding of {product} has been updated.",
+    "cannot_save_configuration": "Cannot save rebranding configuration",
+    "preview": "Preview",
+    "preview_view": "View",
+    "preview_login_screen": "Login screen",
+    "preview_top_bar": "Top bar",
+    "preview_light_theme": "Light theme",
+    "preview_dark_theme": "Dark theme",
+    "preview_login_aria_label": "Preview of the {product} login screen for {brand}",
+    "preview_top_bar_aria_label": "Preview of the {product} top bar for {brand}",
+    "preview_sign_in": "Sign in"
+  },
   "forbidden": {
     "title": "Restricted page",
     "description": "It seems that you don't have the necessary permissions to view this page."
diff --git a/frontend/src/lib/organizations/customers.ts b/frontend/src/lib/organizations/customers.ts
index 68912be15..8c2882c60 100644
--- a/frontend/src/lib/organizations/customers.ts
+++ b/frontend/src/lib/organizations/customers.ts
@@ -57,6 +57,9 @@ export const CustomerSchema = v.object({
   deleted_at: v.optional(v.string()),
   systems_count: v.number(),
   customers_count: v.number(),
+  // Resolved rebranding flag: true when this organization inherits or holds a
+  // rebranding grant. Populated by the detail and list handlers alike.
+  rebranding_enabled: v.optional(v.boolean()),
   created_by: v.optional(
     v.object({
       user_id: v.string(),
diff --git a/frontend/src/lib/organizations/distributors.ts b/frontend/src/lib/organizations/distributors.ts
index dcdf1b366..0230ed457 100644
--- a/frontend/src/lib/organizations/distributors.ts
+++ b/frontend/src/lib/organizations/distributors.ts
@@ -58,6 +58,9 @@ export const DistributorSchema = v.object({
   systems_count: v.number(),
   resellers_count: v.number(),
   customers_count: v.number(),
+  // Resolved rebranding flag: true when this organization inherits or holds a
+  // rebranding grant. Populated by the detail and list handlers alike.
+  rebranding_enabled: v.optional(v.boolean()),
   created_by: v.optional(
     v.object({
       user_id: v.string(),
diff --git a/frontend/src/lib/organizations/resellers.ts b/frontend/src/lib/organizations/resellers.ts
index 41bc3c9c1..41f1ac372 100644
--- a/frontend/src/lib/organizations/resellers.ts
+++ b/frontend/src/lib/organizations/resellers.ts
@@ -57,6 +57,9 @@ export const ResellerSchema = v.object({
   deleted_at: v.optional(v.string()),
   systems_count: v.number(),
   customers_count: v.number(),
+  // Resolved rebranding flag: true when this organization inherits or holds a
+  // rebranding grant. Populated by the detail and list handlers alike.
+  rebranding_enabled: v.optional(v.boolean()),
   created_by: v.optional(
     v.object({
       user_id: v.string(),
diff --git a/frontend/src/lib/permissions.ts b/frontend/src/lib/permissions.ts
index 7fb6a6a2a..8b2f1f410 100644
--- a/frontend/src/lib/permissions.ts
+++ b/frontend/src/lib/permissions.ts
@@ -26,6 +26,9 @@ const MANAGE_ALERTS = 'manage:alerts'
 // The add-on permissions are still spelled "entitlements" on the wire
 const READ_ADDONS = 'read:entitlements'
 const MANAGE_ADDONS = 'manage:entitlements'
+const READ_REBRANDING = 'read:rebranding'
+const MANAGE_REBRANDING = 'manage:rebranding'
+
 // "Owner-level authority": any user of the Owner organization (Owner or Staff
 // role). Not a permission — it is the threshold above the manage:* scopes
 // every distributor already holds. Named once because several unrelated gates
@@ -165,3 +168,21 @@ export const isAddonAdmin = () => hasOwnerLevelAuthority()
 // does; owner-level users grant it outright instead, so offering them the shop
 // on top would be a second way to do a thing they already did better.
 export const canBuyAddons = () => canManageAddons() && !isAddonAdmin()
+
+export const canReadRebranding = () => {
+  const loginStore = useLoginStore()
+  return loginStore.permissions.includes(READ_REBRANDING)
+}
+
+export const canManageRebranding = () => {
+  const loginStore = useLoginStore()
+  return loginStore.permissions.includes(MANAGE_REBRANDING)
+}
+
+// Deciding which companies may rebrand is an administrative duty, not something
+// every distributor takes part in: owner organization or Super Admin, the same
+// population the add-on catalog answers to. The scope is checked alongside it
+// because the route sits behind manage:rebranding as well, and an owner-org
+// user holding only a read role would otherwise be offered a button the API
+// refuses.
+export const isRebrandingAdmin = () => hasOwnerLevelAuthority() && canManageRebranding()
diff --git a/frontend/src/lib/rebranding/previewTheme.test.ts b/frontend/src/lib/rebranding/previewTheme.test.ts
new file mode 100644
index 000000000..da7e35c0d
--- /dev/null
+++ b/frontend/src/lib/rebranding/previewTheme.test.ts
@@ -0,0 +1,159 @@
+//  Copyright (C) 2026 Nethesis S.r.l.
+//  SPDX-License-Identifier: GPL-3.0-or-later
+
+import { describe, expect, it } from 'vitest'
+import {
+  getPreviewPalette,
+  getProductAccentClasses,
+  getProductAccentTextClasses,
+  getProductSubtitle,
+  pickLogoAsset,
+  type PreviewPalette,
+} from './previewTheme'
+
+const PALETTE_KEYS: (keyof PreviewPalette)[] = [
+  'canvas',
+  'surface',
+  'surfaceMuted',
+  'border',
+  'heading',
+  'body',
+  'muted',
+  'surfaceRing',
+  'chrome',
+  'chromeControl',
+]
+
+describe('getPreviewPalette', () => {
+  it('defines every key in both themes, so nothing renders unstyled', () => {
+    for (const theme of ['light', 'dark'] as const) {
+      const palette = getPreviewPalette(theme)
+
+      for (const key of PALETTE_KEYS) {
+        expect(palette[key], `${theme}.${key}`).toBeTruthy()
+      }
+    }
+  })
+
+  // The preview shows one theme while the app shows another, so a `dark:`
+  // variant here would follow the application and defeat the whole panel.
+  it('never uses the dark variant', () => {
+    for (const theme of ['light', 'dark'] as const) {
+      for (const value of Object.values(getPreviewPalette(theme))) {
+        expect(value).not.toContain('dark:')
+      }
+    }
+  })
+
+  it('gives the two themes distinct surfaces', () => {
+    expect(getPreviewPalette('light').canvas).not.toBe(getPreviewPalette('dark').canvas)
+    expect(getPreviewPalette('light').heading).not.toBe(getPreviewPalette('dark').heading)
+  })
+})
+
+describe('pickLogoAsset', () => {
+  it('prefers the logo matching the previewed theme', () => {
+    expect(pickLogoAsset('light', 'rect').preferred).toBe('logo_light_rect')
+    expect(pickLogoAsset('dark', 'rect').preferred).toBe('logo_dark_rect')
+    expect(pickLogoAsset('light', 'square').preferred).toBe('logo_light_square')
+    expect(pickLogoAsset('dark', 'square').preferred).toBe('logo_dark_square')
+  })
+
+  // A partner who uploaded only one variant must still see a logo.
+  it('falls back to the other variant of the same shape', () => {
+    expect(pickLogoAsset('light', 'rect').fallback).toBe('logo_dark_rect')
+    expect(pickLogoAsset('dark', 'rect').fallback).toBe('logo_light_rect')
+    expect(pickLogoAsset('light', 'square').fallback).toBe('logo_dark_square')
+    expect(pickLogoAsset('dark', 'square').fallback).toBe('logo_light_square')
+  })
+
+  it('never mixes shapes', () => {
+    for (const theme of ['light', 'dark'] as const) {
+      const rect = pickLogoAsset(theme, 'rect')
+      const square = pickLogoAsset(theme, 'square')
+
+      expect(rect.preferred.endsWith('_rect') && rect.fallback.endsWith('_rect')).toBe(true)
+      expect(square.preferred.endsWith('_square') && square.fallback.endsWith('_square')).toBe(true)
+    }
+  })
+})
+
+describe('getProductAccentClasses', () => {
+  // Sampled from the design: NethVoice's sign-in button is emerald, not this
+  // application's primary.
+  it('gives NethVoice its own brand colour in both themes', () => {
+    expect(getProductAccentClasses('nethvoice', 'light')).toBe('bg-emerald-700 text-white')
+    expect(getProductAccentClasses('nethvoice', 'dark')).toBe('bg-emerald-500 text-gray-950')
+  })
+
+  // A light button on a light card, or a dark one on a dark card, would vanish.
+  it('uses the 700 shade on light and the 500 shade on dark', () => {
+    for (const id of ['nethvoice', 'unknown-product']) {
+      expect(getProductAccentClasses(id, 'light'), id).toMatch(/-700\b/)
+      expect(getProductAccentClasses(id, 'light'), id).toContain('text-white')
+      expect(getProductAccentClasses(id, 'dark'), id).toMatch(/-500\b/)
+      expect(getProductAccentClasses(id, 'dark'), id).toContain('text-gray-950')
+    }
+  })
+
+  it('keeps the same hue across themes, changing only the shade', () => {
+    const hue = (t: 'light' | 'dark') =>
+      getProductAccentClasses('nethvoice', t).match(/bg-([a-z]+)-\d+/)?.[1]
+
+    expect(hue('light')).toBe(hue('dark'))
+  })
+
+  // Only NethVoice is brandable today, so every other id must reach the
+  // fallback rather than a stale colour left behind by an earlier catalogue.
+  it('falls back to the application primary for every other product', () => {
+    for (const id of ['nsec', 'webtop', 'ns8', 'something-new']) {
+      expect(getProductAccentClasses(id, 'light'), id).toBe('bg-primary-700 text-white')
+      expect(getProductAccentClasses(id, 'dark'), id).toBe('bg-primary-500 text-gray-950')
+    }
+  })
+})
+
+describe('getProductAccentTextClasses', () => {
+  it('draws the subtitle in the product brand colour, shaded per theme', () => {
+    expect(getProductAccentTextClasses('nethvoice', 'light')).toBe('text-emerald-700')
+    expect(getProductAccentTextClasses('nethvoice', 'dark')).toBe('text-emerald-500')
+  })
+
+  // The subtitle sits on the card, so it needs the same shade discipline as the
+  // button: the darker tone on a light card, the brighter one on a dark card.
+  it('matches the hue and shade of the sign-in button', () => {
+    for (const theme of ['light', 'dark'] as const) {
+      const button = getProductAccentClasses('nethvoice', theme).match(/bg-([a-z]+-\d+)/)?.[1]
+
+      expect(getProductAccentTextClasses('nethvoice', theme), theme).toBe(`text-${button}`)
+    }
+  })
+
+  it('falls back to the application primary for an unknown product', () => {
+    expect(getProductAccentTextClasses('something-new', 'light')).toBe('text-primary-700')
+    expect(getProductAccentTextClasses('something-new', 'dark')).toBe('text-primary-500')
+  })
+
+  // Same reason as the palette: a dark: variant would follow the application
+  // theme instead of the previewed one.
+  it('never uses the dark variant', () => {
+    for (const id of ['nethvoice', 'something-new']) {
+      for (const theme of ['light', 'dark'] as const) {
+        expect(getProductAccentTextClasses(id, theme)).not.toContain('dark:')
+      }
+    }
+  })
+})
+
+describe('getProductSubtitle', () => {
+  it('gives NethVoice the line it prints under its own logo', () => {
+    expect(getProductSubtitle('nethvoice')).toBe('CTI')
+  })
+
+  // A product with no subtitle must render nothing, not an empty line that
+  // still takes up space under the logo.
+  it('returns null for a product that prints nothing there', () => {
+    expect(getProductSubtitle('something-new')).toBeNull()
+    expect(getProductSubtitle('')).toBeNull()
+  })
+})
diff --git a/frontend/src/lib/rebranding/previewTheme.ts b/frontend/src/lib/rebranding/previewTheme.ts
new file mode 100644
index 000000000..6c67b08bd
--- /dev/null
+++ b/frontend/src/lib/rebranding/previewTheme.ts
@@ -0,0 +1,133 @@
+//  Copyright (C) 2026 Nethesis S.r.l.
+//  SPDX-License-Identifier: GPL-3.0-or-later
+
+import type { RebrandingAssetName } from './rebranding'
+
+export type PreviewTheme = 'light' | 'dark'
+
+export interface PreviewPalette {
+  canvas: string
+  surface: string
+  surfaceMuted: string
+  border: string
+  heading: string
+  body: string
+  muted: string
+  // ring colour for a dot that overlaps a surface
+  surfaceRing: string
+  // browser chrome behind the tab: a shade off the page so the tab reads as
+  // sitting in front of it rather than merging with it
+  chrome: string
+  // the window buttons drawn on that chrome. Its own key rather than `muted`,
+  // which paints page content and would drag the top bar search text along.
+  chromeControl: string
+}
+
+// The preview shows one theme while the application around it shows another,
+// so it cannot use the `dark:` variant: main.css defines it as
+// `&:where(.dark, .dark *)`, which matches any ancestor. A wrapper can force
+// dark, but nothing can force light inside a dark document — and that is the
+// case that matters, a partner on dark mode checking their light login screen.
+// Hence literal, fully spelled class strings picked in TypeScript. They must
+// stay literal for the Tailwind v4 source scanner to emit them.
+const LIGHT_PALETTE: PreviewPalette = {
+  canvas: 'bg-gray-50',
+  surface: 'bg-white',
+  surfaceMuted: 'bg-gray-100',
+  border: 'border-gray-200',
+  heading: 'text-gray-900',
+  body: 'text-gray-700',
+  muted: 'text-gray-500',
+  surfaceRing: 'ring-white',
+  chrome: 'bg-gray-200',
+  chromeControl: 'text-gray-600',
+}
+
+const DARK_PALETTE: PreviewPalette = {
+  canvas: 'bg-gray-900',
+  surface: 'bg-gray-950',
+  surfaceMuted: 'bg-gray-800',
+  border: 'border-gray-700',
+  heading: 'text-gray-50',
+  body: 'text-gray-200',
+  muted: 'text-gray-400',
+  surfaceRing: 'ring-gray-950',
+  chrome: 'bg-gray-800',
+  chromeControl: 'text-gray-400',
+}
+
+export const getPreviewPalette = (theme: PreviewTheme): PreviewPalette =>
+  theme === 'dark' ? DARK_PALETTE : LIGHT_PALETTE
+
+export type PreviewLogoShape = 'rect' | 'square'
+
+export interface PreviewLogoChoice {
+  preferred: RebrandingAssetName
+  fallback: RebrandingAssetName
+}
+
+// A partner who uploaded only a light logo must still see something in the
+// dark preview, so each theme names a fallback rather than leaving a hole.
+export const pickLogoAsset = (theme: PreviewTheme, shape: PreviewLogoShape): PreviewLogoChoice => {
+  if (shape === 'square') {
+    return theme === 'dark'
+      ? { preferred: 'logo_dark_square', fallback: 'logo_light_square' }
+      : { preferred: 'logo_light_square', fallback: 'logo_dark_square' }
+  }
+  return theme === 'dark'
+    ? { preferred: 'logo_dark_rect', fallback: 'logo_light_rect' }
+    : { preferred: 'logo_light_rect', fallback: 'logo_dark_rect' }
+}
+
+// The preview reproduces the product's real login screen, where the sign-in
+// button wears the product's own brand colour rather than this application's.
+// Only NethVoice is brandable today, so it is the only entry: the fallback
+// below covers anything the catalogue gains before this map does.
+// Each variant is named in full because Tailwind scans for literal class
+// strings, so a `bg-${color}-700` built at runtime would never be emitted.
+const PRODUCT_ACCENT_CLASSES: Record> = {
+  nethvoice: {
+    light: 'bg-emerald-700 text-white',
+    dark: 'bg-emerald-500 text-gray-950',
+  },
+}
+
+// An unknown product falls back to this application's own primary, which is
+// honest about not knowing the brand rather than guessing at it.
+const PRODUCT_ACCENT_FALLBACK: Record = {
+  light: 'bg-primary-700 text-white',
+  dark: 'bg-primary-500 text-gray-950',
+}
+
+export const getProductAccentClasses = (productId: string, theme: PreviewTheme): string =>
+  (PRODUCT_ACCENT_CLASSES[productId] ?? PRODUCT_ACCENT_FALLBACK)[theme]
+
+// The same brand colour as the sign-in button, but as a text colour: the
+// product subtitle under the logo is drawn in it. Kept as its own map rather
+// than derived from the accent classes, because those pair a background with a
+// contrasting foreground and neither half is the colour wanted here.
+const PRODUCT_ACCENT_TEXT_CLASSES: Record> = {
+  nethvoice: {
+    light: 'text-emerald-700',
+    dark: 'text-emerald-500',
+  },
+}
+
+const PRODUCT_ACCENT_TEXT_FALLBACK: Record = {
+  light: 'text-primary-700',
+  dark: 'text-primary-500',
+}
+
+export const getProductAccentTextClasses = (productId: string, theme: PreviewTheme): string =>
+  (PRODUCT_ACCENT_TEXT_CLASSES[productId] ?? PRODUCT_ACCENT_TEXT_FALLBACK)[theme]
+
+// The line the product prints under its logo on its own login screen. It is a
+// product name, not UI copy, so it stays out of the translation catalogue for
+// the same reason "NethVoice" does. A product without one renders no subtitle
+// rather than an empty line.
+const PRODUCT_SUBTITLES: Record = {
+  nethvoice: 'CTI',
+}
+
+export const getProductSubtitle = (productId: string): string | null =>
+  PRODUCT_SUBTITLES[productId] ?? null
diff --git a/frontend/src/lib/rebranding/rebranding.ts b/frontend/src/lib/rebranding/rebranding.ts
new file mode 100644
index 000000000..87f173ca5
--- /dev/null
+++ b/frontend/src/lib/rebranding/rebranding.ts
@@ -0,0 +1,175 @@
+//  Copyright (C) 2026 Nethesis S.r.l.
+//  SPDX-License-Identifier: GPL-3.0-or-later
+
+import axios from 'axios'
+import * as v from 'valibot'
+import { API_URL } from '../config'
+import { useLoginStore } from '@/stores/login'
+
+export const REBRANDING_ORGANIZATIONS_KEY = 'rebrandingOrganizations'
+export const REBRANDING_SUMMARY_KEY = 'rebrandingSummary'
+export const REBRANDING_PRODUCTS_KEY = 'rebrandingProducts'
+export const REBRANDING_STATUS_KEY = 'rebrandingStatus'
+export const REBRANDING_AVAILABLE_KEY = 'rebrandingAvailableOrganizations'
+export const REBRANDING_TABLE_ID = 'rebrandingTable'
+
+// The catalogue seeds four products, but only NethVoice has a preview design,
+// so the configuration view offers just this one. Widening it later is a
+// matter of dropping the filter, not of changing the save path.
+export const NETHVOICE_PRODUCT_ID = 'nethvoice'
+
+// Field names of the multipart upload; each doubles as the asset name in the
+// status response and in the `clear` list.
+export const REBRANDING_ASSET_NAMES = [
+  'logo_light_rect',
+  'logo_dark_rect',
+  'logo_light_square',
+  'logo_dark_square',
+  'favicon',
+  'background_image',
+] as const
+
+export type RebrandingAssetName = (typeof REBRANDING_ASSET_NAMES)[number]
+
+export const MAX_BRAND_NAME_LENGTH = 100
+
+// Each product wears its own brand colour in the "Branded products" column, so
+// a row can be read at a glance. The shape mirrors NeBadgeV2's own kinds
+// (bg-X-100 text-X-800 dark:bg-X-700 dark:text-X-100); the palettes the design
+// uses — emerald, cyan, orange — are not among them, hence kind="custom".
+const REBRANDING_PRODUCT_BADGE_CLASSES: Record = {
+  nethvoice: 'bg-emerald-100 text-emerald-800 dark:bg-emerald-700 dark:text-emerald-50',
+  nsec: 'bg-cyan-100 text-cyan-800 dark:bg-cyan-700 dark:text-cyan-100',
+  webtop: 'bg-orange-100 text-orange-800 dark:bg-orange-700 dark:text-orange-100',
+  ns8: 'bg-blue-100 text-blue-800 dark:bg-blue-700 dark:text-blue-100',
+}
+
+// Gray is the honest answer for a product the catalogue gains before the
+// frontend learns its colour, rather than borrowing another product's.
+const REBRANDING_PRODUCT_BADGE_FALLBACK =
+  'bg-gray-200 text-gray-800 dark:bg-gray-600 dark:text-gray-100'
+
+export const getRebrandingProductBadgeClasses = (productId: string): string =>
+  REBRANDING_PRODUCT_BADGE_CLASSES[productId] ?? REBRANDING_PRODUCT_BADGE_FALLBACK
+
+export interface RebrandingAssetConstraint {
+  maxSize: number
+  mimeTypes: string[]
+  // Value for the file input's `accept` attribute.
+  accept: string
+}
+
+const LOGO_MIME_TYPES = ['image/png', 'image/svg+xml', 'image/webp']
+const FAVICON_MIME_TYPES = [
+  'image/png',
+  'image/x-icon',
+  'image/vnd.microsoft.icon',
+  'image/svg+xml',
+]
+const BACKGROUND_MIME_TYPES = ['image/png', 'image/jpeg', 'image/webp', 'image/svg+xml']
+
+const LOGO_CONSTRAINT: RebrandingAssetConstraint = {
+  maxSize: 2 * 1024 * 1024,
+  mimeTypes: LOGO_MIME_TYPES,
+  accept: LOGO_MIME_TYPES.join(','),
+}
+
+// Mirrors assetConfigs in backend/methods/rebranding.go: the backend rejects
+// anything outside these bounds, so checking here only saves a round trip.
+export const REBRANDING_ASSET_CONSTRAINTS: Record =
+  {
+    logo_light_rect: LOGO_CONSTRAINT,
+    logo_dark_rect: LOGO_CONSTRAINT,
+    logo_light_square: LOGO_CONSTRAINT,
+    logo_dark_square: LOGO_CONSTRAINT,
+    favicon: {
+      maxSize: 512 * 1024,
+      mimeTypes: FAVICON_MIME_TYPES,
+      // .ico is listed explicitly: browsers do not reliably match it by MIME.
+      accept: `${FAVICON_MIME_TYPES.join(',')},.ico`,
+    },
+    background_image: {
+      maxSize: 5 * 1024 * 1024,
+      mimeTypes: BACKGROUND_MIME_TYPES,
+      accept: BACKGROUND_MIME_TYPES.join(','),
+    },
+  }
+
+// The backend sends JSON null for these, so v.nullable and not v.optional:
+// v.optional would reject null and make a parse throw.
+export const RebrandingProductSchema = v.object({
+  id: v.string(),
+  display_name: v.string(),
+  type: v.picklist(['application', 'system']),
+  created_at: v.string(),
+})
+
+export const RebrandingAssetInfoSchema = v.object({
+  name: v.picklist(REBRANDING_ASSET_NAMES),
+  filename: v.nullable(v.string()),
+  mime_type: v.string(),
+  size: v.number(),
+  updated_at: v.string(),
+})
+
+export const RebrandingProductStatusSchema = v.object({
+  product_id: v.string(),
+  product_display_name: v.string(),
+  product_type: v.string(),
+  product_name: v.nullable(v.string()),
+  assets: v.array(RebrandingAssetInfoSchema),
+  updated_at: v.nullable(v.string()),
+})
+
+export const RebrandingOrgStatusSchema = v.object({
+  enabled: v.boolean(),
+  products: v.array(RebrandingProductStatusSchema),
+})
+
+export type RebrandingProduct = v.InferOutput
+export type RebrandingAssetInfo = v.InferOutput
+export type RebrandingProductStatus = v.InferOutput
+export type RebrandingOrgStatus = v.InferOutput
+
+interface RebrandingProductsResponse {
+  code: number
+  message: string
+  data: { products: RebrandingProduct[] }
+}
+
+interface RebrandingOrgStatusResponse {
+  code: number
+  message: string
+  data: RebrandingOrgStatus
+}
+
+export const getRebrandingProducts = (): Promise => {
+  const loginStore = useLoginStore()
+
+  return axios
+    .get(`${API_URL}/rebranding/products`, {
+      headers: { Authorization: `Bearer ${loginStore.jwtToken}` },
+    })
+    .then((res) => res.data.data.products)
+}
+
+export const getRebrandingStatus = (organizationId: string): Promise => {
+  const loginStore = useLoginStore()
+
+  return axios
+    .get(`${API_URL}/rebranding/${organizationId}/status`, {
+      headers: { Authorization: `Bearer ${loginStore.jwtToken}` },
+    })
+    .then((res) => res.data.data)
+}
+
+// The public asset route needs no token and answers with an ETag, so the
+// preview can point a plain  at it. updatedAt is the cache-buster: the
+// URL changes only when the asset does, which keeps the caching useful.
+export const getRebrandingAssetUrl = (
+  organizationId: string,
+  productId: string,
+  asset: RebrandingAssetName,
+  updatedAt: string,
+): string =>
+  `${API_URL}/public/rebranding/${organizationId}/products/${productId}/${asset}?v=${encodeURIComponent(updatedAt)}`
diff --git a/frontend/src/lib/rebranding/rebrandingAssets.test.ts b/frontend/src/lib/rebranding/rebrandingAssets.test.ts
new file mode 100644
index 000000000..462bae17b
--- /dev/null
+++ b/frontend/src/lib/rebranding/rebrandingAssets.test.ts
@@ -0,0 +1,268 @@
+//  Copyright (C) 2026 Nethesis S.r.l.
+//  SPDX-License-Identifier: GPL-3.0-or-later
+
+import { describe, expect, it } from 'vitest'
+import { AxiosError, AxiosHeaders, type AxiosResponse } from 'axios'
+import {
+  buildRebrandingFormData,
+  createAssetSlots,
+  getAssetFileError,
+  getRebrandingUploadError,
+  hasRebrandingChanges,
+  type AssetSlots,
+} from './rebrandingAssets'
+import {
+  REBRANDING_ASSET_NAMES,
+  type RebrandingAssetInfo,
+  type RebrandingAssetName,
+  type RebrandingProductStatus,
+} from './rebranding'
+
+const KB = 1024
+const MB = 1024 * 1024
+
+const makeFile = (bytes: number, name: string, type: string): File =>
+  new File([new Uint8Array(bytes)], name, { type })
+
+const assetInfo = (name: RebrandingAssetName): RebrandingAssetInfo => ({
+  name,
+  filename: `${name}.svg`,
+  mime_type: 'image/svg+xml',
+  size: 1234,
+  updated_at: '2026-05-30T10:00:00Z',
+})
+
+const productStatus = (assets: RebrandingAssetName[]): RebrandingProductStatus => ({
+  product_id: 'nethvoice',
+  product_display_name: 'NethVoice',
+  product_type: 'application',
+  product_name: 'Acme Voice',
+  assets: assets.map(assetInfo),
+  updated_at: '2026-05-30T10:00:00Z',
+})
+
+const emptySlots = (): AssetSlots => createAssetSlots(undefined)
+
+const axiosErrorWithData = (data: unknown, status = 400): AxiosError => {
+  const error = new AxiosError('failed')
+  error.response = {
+    data,
+    status,
+    statusText: '',
+    headers: new AxiosHeaders(),
+    config: { headers: new AxiosHeaders() },
+  } as AxiosResponse
+  return error
+}
+
+describe('createAssetSlots', () => {
+  it('yields one clean slot per asset when nothing is configured', () => {
+    const slots = createAssetSlots(undefined)
+
+    expect(Object.keys(slots).sort()).toEqual([...REBRANDING_ASSET_NAMES].sort())
+    for (const name of REBRANDING_ASSET_NAMES) {
+      expect(slots[name]).toEqual({ existing: null, file: null, cleared: false })
+    }
+  })
+
+  it('maps stored assets onto the right names and leaves the rest empty', () => {
+    const slots = createAssetSlots(productStatus(['logo_light_rect', 'favicon']))
+
+    expect(slots.logo_light_rect.existing?.name).toBe('logo_light_rect')
+    expect(slots.favicon.existing?.name).toBe('favicon')
+    expect(slots.logo_dark_rect.existing).toBeNull()
+    expect(slots.background_image.existing).toBeNull()
+  })
+})
+
+describe('buildRebrandingFormData', () => {
+  it('sends nothing for an untouched slot, so the server-side merge keeps it', () => {
+    const slots = createAssetSlots(productStatus(['logo_light_rect']))
+    const formData = buildRebrandingFormData('', slots)
+
+    expect(formData.getAll('logo_light_rect')).toEqual([])
+    expect(formData.getAll('clear')).toEqual([])
+  })
+
+  it('sends a picked file under its exact asset field name', () => {
+    const slots = emptySlots()
+    const file = makeFile(10, 'logo.png', 'image/png')
+    slots.logo_dark_rect.file = file
+
+    const formData = buildRebrandingFormData('', slots)
+    const sent = formData.getAll('logo_dark_rect')
+
+    expect(sent).toHaveLength(1)
+    expect((sent[0] as File).name).toBe('logo.png')
+  })
+
+  it('clears a stored asset the user removed', () => {
+    const slots = createAssetSlots(productStatus(['favicon']))
+    slots.favicon.cleared = true
+
+    expect(buildRebrandingFormData('', slots).getAll('clear')).toEqual(['favicon'])
+  })
+
+  it('does not clear an asset that was never stored', () => {
+    const slots = emptySlots()
+    slots.favicon.cleared = true
+
+    expect(buildRebrandingFormData('', slots).getAll('clear')).toEqual([])
+  })
+
+  it('lets a newly picked file win over a pending clear', () => {
+    const slots = createAssetSlots(productStatus(['logo_light_rect']))
+    slots.logo_light_rect.cleared = true
+    slots.logo_light_rect.file = makeFile(10, 'new.svg', 'image/svg+xml')
+
+    const formData = buildRebrandingFormData('', slots)
+
+    expect(formData.getAll('logo_light_rect')).toHaveLength(1)
+    expect(formData.getAll('clear')).toEqual([])
+  })
+
+  it('emits several clears in asset order', () => {
+    const slots = createAssetSlots(productStatus(['favicon', 'logo_light_rect']))
+    slots.favicon.cleared = true
+    slots.logo_light_rect.cleared = true
+
+    expect(buildRebrandingFormData('', slots).getAll('clear')).toEqual([
+      'logo_light_rect',
+      'favicon',
+    ])
+  })
+
+  // Absent means "leave the stored name alone"; present and empty clears it, so
+  // an emptied input has to reach the backend as an empty value, not as nothing.
+  it('always sends the brand name, empty included', () => {
+    expect(buildRebrandingFormData('', emptySlots()).get('product_name')).toBe('')
+    expect(buildRebrandingFormData('   ', emptySlots()).get('product_name')).toBe('')
+    expect(buildRebrandingFormData('Acme Voice', emptySlots()).get('product_name')).toBe(
+      'Acme Voice',
+    )
+  })
+
+  it('trims the brand name it does send', () => {
+    expect(buildRebrandingFormData('  Acme Voice  ', emptySlots()).get('product_name')).toBe(
+      'Acme Voice',
+    )
+  })
+})
+
+describe('hasRebrandingChanges', () => {
+  it('is false for a pristine form', () => {
+    expect(hasRebrandingChanges('Acme Voice', 'Acme Voice', emptySlots())).toBe(false)
+  })
+
+  it('is true when a stored brand name is blanked, which clears it', () => {
+    expect(hasRebrandingChanges('', 'Acme Voice', emptySlots())).toBe(true)
+    expect(hasRebrandingChanges('   ', 'Acme Voice', emptySlots())).toBe(true)
+  })
+
+  it('is false when an empty brand name stays empty', () => {
+    expect(hasRebrandingChanges('', '', emptySlots())).toBe(false)
+    expect(hasRebrandingChanges('   ', '', emptySlots())).toBe(false)
+  })
+
+  it('is true when the brand name is replaced', () => {
+    expect(hasRebrandingChanges('Acme Phone', 'Acme Voice', emptySlots())).toBe(true)
+  })
+
+  it('is true when a file is picked', () => {
+    const slots = emptySlots()
+    slots.favicon.file = makeFile(10, 'f.png', 'image/png')
+
+    expect(hasRebrandingChanges('', '', slots)).toBe(true)
+  })
+
+  it('is true when a stored asset is removed, false when a missing one is', () => {
+    const stored = createAssetSlots(productStatus(['favicon']))
+    stored.favicon.cleared = true
+    expect(hasRebrandingChanges('', '', stored)).toBe(true)
+
+    const missing = emptySlots()
+    missing.favicon.cleared = true
+    expect(hasRebrandingChanges('', '', missing)).toBe(false)
+  })
+})
+
+describe('getAssetFileError', () => {
+  it('applies the per-asset size cap', () => {
+    const threeMbPng = makeFile(3 * MB, 'bg.png', 'image/png')
+
+    expect(getAssetFileError('background_image', threeMbPng)).toBeNull()
+    expect(getAssetFileError('logo_light_rect', threeMbPng)?.key).toBe('rebranding.asset_too_large')
+    expect(getAssetFileError('favicon', makeFile(600 * KB, 'f.png', 'image/png'))?.key).toBe(
+      'rebranding.asset_too_large',
+    )
+  })
+
+  it('rejects a type no asset accepts', () => {
+    for (const name of REBRANDING_ASSET_NAMES) {
+      expect(getAssetFileError(name, makeFile(10, 'a.gif', 'image/gif'))?.key).toBe(
+        'rebranding.asset_invalid_type',
+      )
+    }
+  })
+
+  it('accepts image/x-icon only for the favicon', () => {
+    const icon = makeFile(10, 'f.ico', 'image/x-icon')
+
+    expect(getAssetFileError('favicon', icon)).toBeNull()
+    expect(getAssetFileError('logo_light_rect', icon)?.key).toBe('rebranding.asset_invalid_type')
+  })
+
+  it('accepts image/jpeg only for the background', () => {
+    const jpeg = makeFile(10, 'bg.jpg', 'image/jpeg')
+
+    expect(getAssetFileError('background_image', jpeg)).toBeNull()
+    expect(getAssetFileError('logo_dark_rect', jpeg)?.key).toBe('rebranding.asset_invalid_type')
+  })
+
+  it('accepts SVG everywhere', () => {
+    const svg = makeFile(10, 'a.svg', 'image/svg+xml')
+
+    for (const name of REBRANDING_ASSET_NAMES) {
+      expect(getAssetFileError(name, svg)).toBeNull()
+    }
+  })
+})
+
+describe('getRebrandingUploadError', () => {
+  it('reads the over-size body', () => {
+    const error = axiosErrorWithData({
+      data: { field: 'logo_light_rect', max_size: 2 * MB, actual_size: 3 * MB },
+    })
+
+    expect(getRebrandingUploadError(error)).toEqual({
+      field: 'logo_light_rect',
+      kind: 'size',
+      maxSize: 2 * MB,
+      actualSize: 3 * MB,
+    })
+  })
+
+  it('reads the bad-type body', () => {
+    const error = axiosErrorWithData({ data: { field: 'favicon', content_type: 'image/gif' } })
+
+    expect(getRebrandingUploadError(error)).toEqual({
+      field: 'favicon',
+      kind: 'mime',
+      contentType: 'image/gif',
+    })
+  })
+
+  it('ignores anything that is not an asset rejection', () => {
+    expect(getRebrandingUploadError(axiosErrorWithData({ data: null }))).toBeNull()
+    expect(getRebrandingUploadError(axiosErrorWithData({ data: { field: 'nope' } }))).toBeNull()
+    expect(
+      getRebrandingUploadError(
+        axiosErrorWithData({
+          data: { type: 'validation_error', errors: [{ key: 'product_name', message: 'max' }] },
+        }),
+      ),
+    ).toBeNull()
+    expect(getRebrandingUploadError(axiosErrorWithData(null, 500))).toBeNull()
+    expect(getRebrandingUploadError(new AxiosError('network'))).toBeNull()
+  })
+})
diff --git a/frontend/src/lib/rebranding/rebrandingAssets.ts b/frontend/src/lib/rebranding/rebrandingAssets.ts
new file mode 100644
index 000000000..93f687a8c
--- /dev/null
+++ b/frontend/src/lib/rebranding/rebrandingAssets.ts
@@ -0,0 +1,157 @@
+//  Copyright (C) 2026 Nethesis S.r.l.
+//  SPDX-License-Identifier: GPL-3.0-or-later
+
+import axios, { type AxiosError } from 'axios'
+import { API_URL } from '../config'
+import { useLoginStore } from '@/stores/login'
+import {
+  REBRANDING_ASSET_CONSTRAINTS,
+  REBRANDING_ASSET_NAMES,
+  type RebrandingAssetInfo,
+  type RebrandingAssetName,
+  type RebrandingProductStatus,
+} from './rebranding'
+
+// One asset needs three facts, not one File: the server already holds a value,
+// so "leave it alone" (the upload merges) has to stay distinguishable from
+// "delete it" (which needs an explicit `clear`).
+export interface AssetSlot {
+  // What the server holds today, as reported by the status endpoint.
+  existing: RebrandingAssetInfo | null
+  // Picked in this session, not yet saved.
+  file: File | null
+  // The stored asset was removed by the user.
+  cleared: boolean
+}
+
+export type AssetSlots = Record
+
+export const createAssetSlots = (product?: RebrandingProductStatus): AssetSlots => {
+  const slots = {} as AssetSlots
+
+  for (const name of REBRANDING_ASSET_NAMES) {
+    slots[name] = {
+      existing: product?.assets.find((asset) => asset.name === name) ?? null,
+      file: null,
+      cleared: false,
+    }
+  }
+  return slots
+}
+
+export const isAssetSlotDirty = (slot: AssetSlot): boolean =>
+  slot.file !== null || (slot.cleared && slot.existing !== null)
+
+export const hasRebrandingChanges = (
+  brandName: string,
+  savedBrandName: string,
+  slots: AssetSlots,
+): boolean =>
+  brandName.trim() !== savedBrandName ||
+  REBRANDING_ASSET_NAMES.some((name) => isAssetSlotDirty(slots[name]))
+
+export interface AssetFileIssue {
+  key: string
+  params: Record
+}
+
+export const getAssetFileError = (name: RebrandingAssetName, file: File): AssetFileIssue | null => {
+  const constraint = REBRANDING_ASSET_CONSTRAINTS[name]
+
+  if (file.size > constraint.maxSize) {
+    return { key: 'rebranding.asset_too_large', params: { maxSize: constraint.maxSize } }
+  }
+
+  if (!constraint.mimeTypes.includes(file.type)) {
+    return { key: 'rebranding.asset_invalid_type', params: { type: file.type || file.name } }
+  }
+  return null
+}
+
+export const buildRebrandingFormData = (brandName: string, slots: AssetSlots): FormData => {
+  const formData = new FormData()
+
+  // Always sent, empty included: the backend tells "field absent" (leave the
+  // stored name alone) from "field present and empty" (clear it) by the key's
+  // presence, and an emptied input means the second.
+  formData.append('product_name', brandName.trim())
+
+  // Iterating the constant rather than Object.keys keeps the field order
+  // deterministic, which is what the tests assert on.
+  for (const name of REBRANDING_ASSET_NAMES) {
+    const slot = slots[name]
+
+    if (slot.file) {
+      // A file always wins over a pending clear: picking one after removing the
+      // stored asset is a replacement, and the upsert already overwrites.
+      formData.append(name, slot.file, slot.file.name)
+    } else if (slot.cleared && slot.existing) {
+      formData.append('clear', name)
+    }
+  }
+  return formData
+}
+
+export interface RebrandingUploadError {
+  field: RebrandingAssetName
+  kind: 'size' | 'mime'
+  maxSize?: number
+  actualSize?: number
+  contentType?: string
+}
+
+const isRebrandingAssetName = (value: unknown): value is RebrandingAssetName =>
+  typeof value === 'string' && (REBRANDING_ASSET_NAMES as readonly string[]).includes(value)
+
+// Asset rejections do not use the {type, errors[]} validation envelope that
+// getValidationIssues understands — they answer with a bare {field, ...} body,
+// so they need their own reader.
+export const getRebrandingUploadError = (error: AxiosError): RebrandingUploadError | null => {
+  const data = error.response?.data
+
+  if (typeof data !== 'object' || data === null) {
+    return null
+  }
+
+  const payload = (data as { data?: unknown }).data
+
+  if (typeof payload !== 'object' || payload === null) {
+    return null
+  }
+
+  const body = payload as Record
+
+  if (!isRebrandingAssetName(body.field)) {
+    return null
+  }
+
+  if (typeof body.max_size === 'number') {
+    return {
+      field: body.field,
+      kind: 'size',
+      maxSize: body.max_size,
+      actualSize: typeof body.actual_size === 'number' ? body.actual_size : undefined,
+    }
+  }
+
+  if (typeof body.content_type === 'string') {
+    return { field: body.field, kind: 'mime', contentType: body.content_type }
+  }
+  return null
+}
+
+// The per-product endpoint, not PUT /:org_id/config: config deletes every
+// product left out of its `products` list, which a single-product form would
+// do on every save.
+export const putRebrandingProduct = (
+  organizationId: string,
+  productId: string,
+  formData: FormData,
+) => {
+  const loginStore = useLoginStore()
+
+  // No Content-Type here on purpose: axios sets the multipart boundary itself.
+  return axios.put(`${API_URL}/rebranding/${organizationId}/products/${productId}`, formData, {
+    headers: { Authorization: `Bearer ${loginStore.jwtToken}` },
+  })
+}
diff --git a/frontend/src/lib/rebranding/rebrandingOrganizations.test.ts b/frontend/src/lib/rebranding/rebrandingOrganizations.test.ts
new file mode 100644
index 000000000..66dd5f9b0
--- /dev/null
+++ b/frontend/src/lib/rebranding/rebrandingOrganizations.test.ts
@@ -0,0 +1,116 @@
+//  Copyright (C) 2026 Nethesis S.r.l.
+//  SPDX-License-Identifier: GPL-3.0-or-later
+
+import { describe, expect, it } from 'vitest'
+import { getRebrandingOrganizationsQueryString } from './rebrandingOrganizations'
+import { getRebrandingProductBadgeClasses } from './rebranding'
+
+const build = (
+  overrides: Partial<{
+    pageNum: number
+    pageSize: number
+    textFilter: string | null
+    typeFilter: string[]
+    sortBy: string | null
+    sortDescending: boolean
+  }> = {},
+) => {
+  const args = {
+    pageNum: 1,
+    pageSize: 10,
+    textFilter: null,
+    typeFilter: [],
+    sortBy: 'name',
+    sortDescending: false,
+    ...overrides,
+  }
+
+  return new URLSearchParams(
+    getRebrandingOrganizationsQueryString(
+      args.pageNum,
+      args.pageSize,
+      args.textFilter,
+      args.typeFilter,
+      args.sortBy,
+      args.sortDescending,
+    ),
+  )
+}
+
+describe('getRebrandingOrganizationsQueryString', () => {
+  it('always sends pagination and sorting', () => {
+    const params = build({ pageNum: 3, pageSize: 25 })
+
+    expect(params.get('page')).toBe('3')
+    expect(params.get('page_size')).toBe('25')
+    expect(params.get('sort_by')).toBe('name')
+    expect(params.get('sort_direction')).toBe('asc')
+  })
+
+  it('maps sortDescending onto the direction the backend expects', () => {
+    expect(build({ sortDescending: true }).get('sort_direction')).toBe('desc')
+    expect(build({ sortDescending: false }).get('sort_direction')).toBe('asc')
+  })
+
+  // The backend reads these with c.QueryArray and never splits on commas, so a
+  // joined value would become one literal filter that matches nothing.
+  it('repeats the type parameter instead of joining the values', () => {
+    const params = build({ typeFilter: ['distributor', 'reseller'] })
+
+    expect(params.getAll('type')).toEqual(['distributor', 'reseller'])
+    expect(params.toString()).toContain('type=distributor&type=reseller')
+    expect(params.toString()).not.toContain('distributor%2Creseller')
+  })
+
+  // Only NethVoice is brandable today, so there is nothing to filter products
+  // by and the parameter is never sent.
+  it('never sends a product filter', () => {
+    expect(build().has('product')).toBe(false)
+    expect(build({ typeFilter: ['distributor'] }).has('product')).toBe(false)
+  })
+
+  it('omits an empty type filter entirely', () => {
+    expect(build().getAll('type')).toEqual([])
+  })
+
+  it('sends a search only when there is something to search for', () => {
+    expect(build({ textFilter: null }).has('search')).toBe(false)
+    expect(build({ textFilter: '' }).has('search')).toBe(false)
+    expect(build({ textFilter: '   ' }).has('search')).toBe(false)
+    expect(build({ textFilter: 'cloudpoint' }).get('search')).toBe('cloudpoint')
+  })
+
+  it('sends an empty sort_by rather than dropping it', () => {
+    expect(build({ sortBy: null }).get('sort_by')).toBe('')
+  })
+})
+
+describe('getRebrandingProductBadgeClasses', () => {
+  it('gives each seeded product its own colour', () => {
+    const classes = ['nethvoice', 'nsec', 'webtop', 'ns8'].map(getRebrandingProductBadgeClasses)
+
+    expect(new Set(classes).size).toBe(classes.length)
+  })
+
+  // The badge is read against both themes, so a palette that only names one is
+  // a badge that disappears in the other.
+  it('pairs every colour with a dark variant', () => {
+    for (const id of ['nethvoice', 'nsec', 'webtop', 'ns8', 'unknown-product']) {
+      const classes = getRebrandingProductBadgeClasses(id)
+
+      expect(classes, id).toMatch(/\bbg-[a-z]+-\d+\b/)
+      expect(classes, id).toMatch(/\bdark:bg-[a-z]+-\d+\b/)
+      expect(classes, id).toMatch(/\btext-[a-z]+-\d+\b/)
+      expect(classes, id).toMatch(/\bdark:text-[a-z]+-\d+\b/)
+    }
+  })
+
+  // A product the catalogue gains before the frontend learns its colour must
+  // not silently borrow another product's.
+  it('falls back to gray for an unknown product', () => {
+    const fallback = getRebrandingProductBadgeClasses('something-new')
+
+    expect(fallback).toContain('bg-gray-200')
+    expect(fallback).not.toBe(getRebrandingProductBadgeClasses('nethvoice'))
+  })
+})
diff --git a/frontend/src/lib/rebranding/rebrandingOrganizations.ts b/frontend/src/lib/rebranding/rebrandingOrganizations.ts
new file mode 100644
index 000000000..64cd76f7b
--- /dev/null
+++ b/frontend/src/lib/rebranding/rebrandingOrganizations.ts
@@ -0,0 +1,199 @@
+//  Copyright (C) 2026 Nethesis S.r.l.
+//  SPDX-License-Identifier: GPL-3.0-or-later
+
+import axios from 'axios'
+import * as v from 'valibot'
+import { API_URL } from '../config'
+import { OPTIONS_PAGE_SIZE, type Pagination } from '../common'
+import { useLoginStore } from '@/stores/login'
+
+export const REBRANDING_ORGANIZATION_TYPES = ['distributor', 'reseller', 'customer'] as const
+
+export type RebrandingOrganizationType = (typeof REBRANDING_ORGANIZATION_TYPES)[number]
+
+// The only columns the backend knows how to sort on; anything else silently
+// falls back to name, which would make a header look broken.
+export const REBRANDING_SORT_KEYS = [
+  'name',
+  'organization_type',
+  'enabled_at',
+  'updated_at',
+] as const
+
+export type RebrandingSortBy = (typeof REBRANDING_SORT_KEYS)[number]
+
+export const RebrandingOrganizationProductSchema = v.object({
+  product_id: v.string(),
+  product_display_name: v.string(),
+  product_name: v.nullable(v.string()),
+})
+
+export const RebrandingOrganizationSchema = v.object({
+  id: v.string(),
+  logto_id: v.string(),
+  name: v.string(),
+  organization_type: v.picklist(REBRANDING_ORGANIZATION_TYPES),
+  products: v.array(RebrandingOrganizationProductSchema),
+  enabled_at: v.string(),
+  updated_at: v.nullable(v.string()),
+})
+
+export const RebrandingSummarySchema = v.object({
+  total: v.number(),
+  distributors: v.number(),
+  resellers: v.number(),
+  customers: v.number(),
+})
+
+export const RebrandingAvailableOrganizationSchema = v.object({
+  id: v.string(),
+  logto_id: v.string(),
+  name: v.string(),
+  organization_type: v.picklist(REBRANDING_ORGANIZATION_TYPES),
+})
+
+export type RebrandingOrganizationProduct = v.InferOutput<
+  typeof RebrandingOrganizationProductSchema
+>
+export type RebrandingOrganization = v.InferOutput
+export type RebrandingSummary = v.InferOutput
+export type RebrandingAvailableOrganization = v.InferOutput<
+  typeof RebrandingAvailableOrganizationSchema
+>
+
+interface RebrandingOrganizationsResponse {
+  code: number
+  message: string
+  data: {
+    organizations: RebrandingOrganization[]
+    pagination: Pagination
+  }
+}
+
+interface RebrandingSummaryResponse {
+  code: number
+  message: string
+  data: RebrandingSummary
+}
+
+interface RebrandingAvailableOrganizationsResponse {
+  code: number
+  message: string
+  data: { organizations: RebrandingAvailableOrganization[] }
+}
+
+interface EnableRebrandingResponse {
+  code: number
+  message: string
+  data: { enabled: number }
+}
+
+// `type` is a repeated parameter on the wire — the backend reads it with
+// c.QueryArray and does not split on commas, so joining the values would send
+// one literal "a,b" filter that matches nothing.
+export const getRebrandingOrganizationsQueryString = (
+  pageNum: number,
+  pageSize: number,
+  textFilter: string | null,
+  typeFilter: string[],
+  sortBy: string | null,
+  sortDescending: boolean,
+): string => {
+  const searchParams = new URLSearchParams({
+    page: pageNum.toString(),
+    page_size: pageSize.toString(),
+    sort_by: sortBy || '',
+    sort_direction: sortDescending ? 'desc' : 'asc',
+  })
+
+  if (textFilter?.trim()) {
+    searchParams.append('search', textFilter)
+  }
+
+  for (const type of typeFilter) {
+    searchParams.append('type', type)
+  }
+
+  return searchParams.toString()
+}
+
+export const getRebrandingOrganizations = (
+  pageNum: number,
+  pageSize: number,
+  textFilter: string | null,
+  typeFilter: string[],
+  sortBy: string | null,
+  sortDescending: boolean,
+) => {
+  const loginStore = useLoginStore()
+  const params = getRebrandingOrganizationsQueryString(
+    pageNum,
+    pageSize,
+    textFilter,
+    typeFilter,
+    sortBy,
+    sortDescending,
+  )
+
+  return axios
+    .get(`${API_URL}/rebranding/organizations?${params}`, {
+      headers: { Authorization: `Bearer ${loginStore.jwtToken}` },
+    })
+    .then((res) => res.data.data)
+}
+
+export const getRebrandingSummary = (): Promise => {
+  const loginStore = useLoginStore()
+
+  return axios
+    .get(`${API_URL}/rebranding/summary`, {
+      headers: { Authorization: `Bearer ${loginStore.jwtToken}` },
+    })
+    .then((res) => res.data.data)
+}
+
+// A picker endpoint, not a list: no pagination, capped server-side at 200.
+// Already-enabled organizations and the owner organization are excluded there.
+export const getAvailableRebrandingOrganizations = (
+  search: string,
+  limit: number = OPTIONS_PAGE_SIZE,
+): Promise => {
+  const loginStore = useLoginStore()
+  const searchParams = new URLSearchParams({ limit: limit.toString() })
+
+  if (search.trim()) {
+    searchParams.append('search', search)
+  }
+
+  return axios
+    .get(
+      `${API_URL}/rebranding/organizations/available?${searchParams.toString()}`,
+      { headers: { Authorization: `Bearer ${loginStore.jwtToken}` } },
+    )
+    .then((res) => res.data.data.organizations)
+}
+
+// Organization ids are Logto ids: the backend resolves them against
+// unified_organizations.logto_id, and sending the numeric id answers
+// 400 "organization_ids unknown".
+export const postEnableRebranding = (organizationIds: string[]): Promise<{ enabled: number }> => {
+  const loginStore = useLoginStore()
+
+  return axios
+    .post(
+      `${API_URL}/rebranding/organizations`,
+      { organization_ids: organizationIds },
+      { headers: { Authorization: `Bearer ${loginStore.jwtToken}` } },
+    )
+    .then((res) => res.data.data)
+}
+
+export const patchDisableRebranding = (organizationLogtoId: string) => {
+  const loginStore = useLoginStore()
+
+  return axios.patch(
+    `${API_URL}/rebranding/${organizationLogtoId}/disable`,
+    {},
+    { headers: { Authorization: `Bearer ${loginStore.jwtToken}` } },
+  )
+}
diff --git a/frontend/src/queries/rebranding/myRebrandingStatus.ts b/frontend/src/queries/rebranding/myRebrandingStatus.ts
new file mode 100644
index 000000000..f6eea693d
--- /dev/null
+++ b/frontend/src/queries/rebranding/myRebrandingStatus.ts
@@ -0,0 +1,42 @@
+//  Copyright (C) 2026 Nethesis S.r.l.
+//  SPDX-License-Identifier: GPL-3.0-or-later
+
+import { canReadRebranding, isRebrandingAdmin } from '@/lib/permissions'
+import {
+  getRebrandingStatus,
+  NETHVOICE_PRODUCT_ID,
+  REBRANDING_STATUS_KEY,
+} from '@/lib/rebranding/rebranding'
+import { useLoginStore } from '@/stores/login'
+import { defineQuery, useQuery } from '@pinia/colada'
+import { computed } from 'vue'
+
+// The side menu asks this on every route, so it has to be cheap and safe:
+// defineQuery makes it a singleton (one request per session), and reading the
+// status of one's own organization is always permitted — the hierarchy check
+// short-circuits on the caller's own id, and a non-enabled organization comes
+// back as enabled: false rather than a 403.
+export const useMyRebrandingStatus = defineQuery(() => {
+  const loginStore = useLoginStore()
+  const organizationId = computed(() => loginStore.userInfo?.organization_id ?? '')
+
+  const { state, asyncStatus, ...rest } = useQuery({
+    key: () => [REBRANDING_STATUS_KEY, organizationId.value],
+    // An owner-level user administers the fleet rather than configuring one
+    // company, and lands on the list view, so its own status is never read.
+    enabled: () =>
+      !!loginStore.jwtToken &&
+      !!organizationId.value &&
+      canReadRebranding() &&
+      !isRebrandingAdmin(),
+    query: () => getRebrandingStatus(organizationId.value),
+  })
+
+  const isEnabled = computed(() => state.value.data?.enabled === true)
+
+  const nethvoiceStatus = computed(() =>
+    state.value.data?.products.find((product) => product.product_id === NETHVOICE_PRODUCT_ID),
+  )
+
+  return { ...rest, state, asyncStatus, organizationId, isEnabled, nethvoiceStatus }
+})
diff --git a/frontend/src/queries/rebranding/rebrandingOrganizations.ts b/frontend/src/queries/rebranding/rebrandingOrganizations.ts
new file mode 100644
index 000000000..fe924ef0c
--- /dev/null
+++ b/frontend/src/queries/rebranding/rebrandingOrganizations.ts
@@ -0,0 +1,118 @@
+//  Copyright (C) 2026 Nethesis S.r.l.
+//  SPDX-License-Identifier: GPL-3.0-or-later
+
+import { MIN_SEARCH_LENGTH } from '@/lib/common'
+import { canReadRebranding } from '@/lib/permissions'
+import { REBRANDING_ORGANIZATIONS_KEY, REBRANDING_TABLE_ID } from '@/lib/rebranding/rebranding'
+import {
+  getRebrandingOrganizations,
+  type RebrandingSortBy,
+} from '@/lib/rebranding/rebrandingOrganizations'
+import { DEFAULT_PAGE_SIZE, loadPageSizeFromStorage } from '@/lib/tablePageSize'
+import { useLoginStore } from '@/stores/login'
+import { defineQuery, useQuery } from '@pinia/colada'
+import { useDebounceFn } from '@vueuse/core'
+import { computed, ref, watch } from 'vue'
+import type { NeDropdownFilterV2Option } from '@nethesis/vue-components'
+
+export const useRebrandingOrganizations = defineQuery(() => {
+  const loginStore = useLoginStore()
+  const pageNum = ref(1)
+  const pageSize = ref(DEFAULT_PAGE_SIZE)
+  const textFilter = ref('')
+  const debouncedTextFilter = ref('')
+  const typeFilter = ref([])
+  const sortBy = ref('name')
+  const sortDescending = ref(false)
+
+  const { state, asyncStatus, ...rest } = useQuery({
+    key: () => [
+      REBRANDING_ORGANIZATIONS_KEY,
+      {
+        pageNum: pageNum.value,
+        pageSize: pageSize.value,
+        textFilter: debouncedTextFilter.value,
+        typeFilter: typeFilter.value.map((o) => o.id),
+        sortBy: sortBy.value,
+        sortDirection: sortDescending.value,
+      },
+    ],
+    // Without the permission check this 403s, and the axios interceptor turns
+    // any 403 into a redirect to /forbidden.
+    enabled: () => !!loginStore.jwtToken && canReadRebranding(),
+    query: () =>
+      getRebrandingOrganizations(
+        pageNum.value,
+        pageSize.value,
+        debouncedTextFilter.value,
+        typeFilter.value.map((o) => o.id),
+        sortBy.value,
+        sortDescending.value,
+      ),
+  })
+
+  const areDefaultFiltersApplied = computed(
+    () => !debouncedTextFilter.value && typeFilter.value.length === 0,
+  )
+
+  const resetFilters = () => {
+    textFilter.value = ''
+    typeFilter.value = []
+  }
+
+  // load table page size from storage
+  watch(
+    () => loginStore.userInfo?.email,
+    (email) => {
+      if (email) {
+        pageSize.value = loadPageSizeFromStorage(REBRANDING_TABLE_ID)
+      }
+    },
+    { immediate: true },
+  )
+
+  watch(
+    () => textFilter.value,
+    useDebounceFn(() => {
+      // debounce and ignore if text filter is too short
+      if (textFilter.value.length === 0 || textFilter.value.length >= MIN_SEARCH_LENGTH) {
+        debouncedTextFilter.value = textFilter.value
+
+        // reset to first page when filter changes
+        pageNum.value = 1
+      }
+    }, 500),
+  )
+
+  // reset to first page when page size changes
+  watch(
+    () => pageSize.value,
+    () => {
+      pageNum.value = 1
+    },
+  )
+
+  // reset to first page when type filter changes
+  watch(
+    () => typeFilter.value,
+    () => {
+      pageNum.value = 1
+    },
+    { deep: true },
+  )
+
+  return {
+    ...rest,
+    state,
+    asyncStatus,
+    pageNum,
+    pageSize,
+    textFilter,
+    debouncedTextFilter,
+    typeFilter,
+    sortBy,
+    sortDescending,
+    areDefaultFiltersApplied,
+    resetFilters,
+  }
+})
diff --git a/frontend/src/queries/rebranding/rebrandingProducts.ts b/frontend/src/queries/rebranding/rebrandingProducts.ts
new file mode 100644
index 000000000..4e442e60f
--- /dev/null
+++ b/frontend/src/queries/rebranding/rebrandingProducts.ts
@@ -0,0 +1,34 @@
+//  Copyright (C) 2026 Nethesis S.r.l.
+//  SPDX-License-Identifier: GPL-3.0-or-later
+
+import { canReadRebranding } from '@/lib/permissions'
+import {
+  getRebrandingProducts,
+  NETHVOICE_PRODUCT_ID,
+  REBRANDING_PRODUCTS_KEY,
+} from '@/lib/rebranding/rebranding'
+import { useLoginStore } from '@/stores/login'
+import { defineQuery, useQuery } from '@pinia/colada'
+import { computed } from 'vue'
+
+export const useRebrandingProducts = defineQuery(() => {
+  const loginStore = useLoginStore()
+
+  const { state, asyncStatus, ...rest } = useQuery({
+    key: () => [REBRANDING_PRODUCTS_KEY],
+    enabled: () => !!loginStore.jwtToken && canReadRebranding(),
+    query: getRebrandingProducts,
+  })
+
+  // The whole catalogue backs the owner's "Branded products" filter, so an
+  // option never disappears just because nobody has configured that product.
+  const products = computed(() => state.value.data ?? [])
+
+  // Only NethVoice has a preview design, so the configuration view offers it
+  // alone. The list keeps its shape so widening it later stays a one-line change.
+  const configurableProducts = computed(() =>
+    products.value.filter((product) => product.id === NETHVOICE_PRODUCT_ID),
+  )
+
+  return { ...rest, state, asyncStatus, products, configurableProducts }
+})
diff --git a/frontend/src/queries/rebranding/rebrandingSummary.ts b/frontend/src/queries/rebranding/rebrandingSummary.ts
new file mode 100644
index 000000000..8b66e0833
--- /dev/null
+++ b/frontend/src/queries/rebranding/rebrandingSummary.ts
@@ -0,0 +1,20 @@
+//  Copyright (C) 2026 Nethesis S.r.l.
+//  SPDX-License-Identifier: GPL-3.0-or-later
+
+import { canReadRebranding } from '@/lib/permissions'
+import { REBRANDING_SUMMARY_KEY } from '@/lib/rebranding/rebranding'
+import { getRebrandingSummary } from '@/lib/rebranding/rebrandingOrganizations'
+import { useLoginStore } from '@/stores/login'
+import { defineQuery, useQuery } from '@pinia/colada'
+
+export const useRebrandingSummary = defineQuery(() => {
+  const loginStore = useLoginStore()
+
+  const { state, asyncStatus, ...rest } = useQuery({
+    key: () => [REBRANDING_SUMMARY_KEY],
+    enabled: () => !!loginStore.jwtToken && canReadRebranding(),
+    query: getRebrandingSummary,
+  })
+
+  return { ...rest, state, asyncStatus }
+})
diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts
index 179c90e60..12677bbdf 100644
--- a/frontend/src/router/index.ts
+++ b/frontend/src/router/index.ts
@@ -90,6 +90,11 @@ const router = createRouter({
       name: 'addons',
       component: () => import('../views/AddonsView.vue'),
     },
+    {
+      path: '/rebranding',
+      name: 'rebranding',
+      component: () => import('../views/RebrandingView.vue'),
+    },
     {
       path: '/applications/:applicationId',
       name: 'application_detail',
diff --git a/frontend/src/views/RebrandingView.vue b/frontend/src/views/RebrandingView.vue
new file mode 100644
index 000000000..d7aadf8eb
--- /dev/null
+++ b/frontend/src/views/RebrandingView.vue
@@ -0,0 +1,71 @@
+
+
+
+
+

From 9c563b18b6ce38aa152fd57aacfb76a1f9ae87a7 Mon Sep 17 00:00:00 2001
From: Andrea Leardini 
Date: Tue, 1 Sep 2026 17:25:12 +0200
Subject: [PATCH 02/23] feat(rebranding): show the browser frame in both
 preview views

The browser chrome lived inside LoginScreenPreview, so the top bar view
was drawn as a bare page and the favicon had nowhere to show. Extract it
into BrowserChrome and mount it in the panel, above whichever view is
selected: the product is a web app either way, and the canvas now reads
as one screenshot in both. The canvas background and aria-hidden move up
to the panel with it, so each view no longer repeats them.

Co-Authored-By: Claude Opus 5 (1M context) 
---
 .../rebranding/preview/BrowserChrome.vue      | 45 +++++++++++
 .../rebranding/preview/LoginScreenPreview.vue | 81 ++++++-------------
 .../preview/RebrandingPreviewPanel.vue        | 47 +++++++----
 .../rebranding/preview/TopBarPreview.vue      |  4 +-
 4 files changed, 102 insertions(+), 75 deletions(-)
 create mode 100644 frontend/src/components/rebranding/preview/BrowserChrome.vue

diff --git a/frontend/src/components/rebranding/preview/BrowserChrome.vue b/frontend/src/components/rebranding/preview/BrowserChrome.vue
new file mode 100644
index 000000000..b4c8761ae
--- /dev/null
+++ b/frontend/src/components/rebranding/preview/BrowserChrome.vue
@@ -0,0 +1,45 @@
+
+
+
+
+
+
+
diff --git a/frontend/src/components/rebranding/preview/LoginScreenPreview.vue b/frontend/src/components/rebranding/preview/LoginScreenPreview.vue
index 7c5b3813d..ee89dfee5 100644
--- a/frontend/src/components/rebranding/preview/LoginScreenPreview.vue
+++ b/frontend/src/components/rebranding/preview/LoginScreenPreview.vue
@@ -8,14 +8,11 @@
   inert divs on purpose. Ne* form controls carry their own dark: variants and
   would follow the application theme instead of the previewed one, and real
   focusable controls that do nothing are worse for a keyboard user than an
-  image. The parent exposes the canvas as role="img"; everything here is
-  decorative.
+  image. The parent exposes the canvas as role="img" and hides this subtree
+  from assistive tech; everything here is decorative.
 -->
 
 
 
 
diff --git a/frontend/src/components/rebranding/preview/TopBarPreview.vue b/frontend/src/components/rebranding/preview/TopBarPreview.vue
index ca824d08e..02d3b2083 100644
--- a/frontend/src/components/rebranding/preview/TopBarPreview.vue
+++ b/frontend/src/components/rebranding/preview/TopBarPreview.vue
@@ -7,7 +7,7 @@
   Same rule as the login preview: everything here is a decorative div, so the
   canvas can follow the previewed theme rather than the application one, and no
   dead control ends up in the tab order. The parent exposes the canvas as
-  role="img".
+  role="img" and hides this subtree from assistive tech.
 -->