diff --git a/frontend/package-lock.json b/frontend/package-lock.json index c5e2f1862..34e0cef38 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1649,7 +1649,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 0536ced4b..86185425a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -82,5 +82,11 @@ "vite-plugin-vue-devtools": "^7.7.2", "vitest": "^3.2.4", "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/main.css b/frontend/src/assets/main.css index 76eb9be51..5d4abb9b9 100644 --- a/frontend/src/assets/main.css +++ b/frontend/src/assets/main.css @@ -160,7 +160,7 @@ --background-input: var(--color-white); --icon-enabled: var(--color-green-700); - --icon-disabled: var(--color-gray-700); + --icon-neutral: var(--color-gray-600); } /* Apply dark theme when .dark class is present (overrides media query) */ @@ -198,7 +198,7 @@ --background-input: var(--color-gray-950); --icon-enabled: var(--color-green-500); - --icon-disabled: var(--color-gray-400); + --icon-neutral: var(--color-gray-400); } /* Reference the CSS custom properties in @theme inline */ @@ -224,7 +224,7 @@ --color-placeholder: var(--placeholder); --color-border-secondary: var(--border-secondary); --color-icon-enabled: var(--icon-enabled); - --color-icon-disabled: var(--icon-disabled); + --color-icon-neutral: var(--icon-neutral); --border-ring-button: var(--ring-button); --border-ring-input: var(--ring-input); 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 000000000..004d5e362 Binary files /dev/null and b/frontend/src/assets/rebranding_previews/nethvoice-favicon.ico differ 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 000000000..b6a3a312b Binary files /dev/null and b/frontend/src/assets/rebranding_previews/nethvoice-login-background.webp differ 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/account/apiKeys/ApiKeysPanel.vue b/frontend/src/components/account/apiKeys/ApiKeysPanel.vue index ead059c9d..208d5fc02 100644 --- a/frontend/src/components/account/apiKeys/ApiKeysPanel.vue +++ b/frontend/src/components/account/apiKeys/ApiKeysPanel.vue @@ -85,10 +85,10 @@ function statusIcon(status: 'revoked' | 'expired' | 'active') { function statusIconClass(status: 'revoked' | 'expired' | 'active'): string { return status === 'active' - ? 'text-green-600 dark:text-green-400' + ? 'text-icon-enabled' : status === 'expired' ? 'text-amber-600 dark:text-amber-400' - : 'text-gray-500 dark:text-gray-400' + : 'text-icon-neutral' } const filteredKeys = computed(() => { diff --git a/frontend/src/components/account/impersonation/ImpersonationPanel.vue b/frontend/src/components/account/impersonation/ImpersonationPanel.vue index 5d40cfd73..7a874d96e 100644 --- a/frontend/src/components/account/impersonation/ImpersonationPanel.vue +++ b/frontend/src/components/account/impersonation/ImpersonationPanel.vue @@ -120,7 +120,7 @@ async function disableConsent() {
{{
diff --git a/frontend/src/components/customers/CustomersTable.vue b/frontend/src/components/customers/CustomersTable.vue
index 7bca88adf..eff439dde 100644
--- a/frontend/src/components/customers/CustomersTable.vue
+++ b/frontend/src/components/customers/CustomersTable.vue
@@ -333,10 +333,10 @@ const goToCustomerDetails = (customer: Customer) => {
:custom-action-label="t('ne_dropdown_filter.reset_selection')"
@custom-action="resetStatusFilter"
/>
-
+
@@ -453,7 +453,7 @@ const goToCustomerDetails = (customer: Customer) => {
>
{{ item.systems_count }}
@@ -461,7 +461,7 @@ const goToCustomerDetails = (customer: Customer) => {
{{ item.systems_count }}
@@ -502,7 +502,7 @@ const goToCustomerDetails = (customer: Customer) => {
@@ -512,7 +512,7 @@ const goToCustomerDetails = (customer: Customer) => {
@@ -522,7 +522,7 @@ const goToCustomerDetails = (customer: Customer) => {
diff --git a/frontend/src/components/distributors/DistributorInfoCard.vue b/frontend/src/components/distributors/DistributorInfoCard.vue
index ccb6c15ff..a38a90bde 100644
--- a/frontend/src/components/distributors/DistributorInfoCard.vue
+++ b/frontend/src/components/distributors/DistributorInfoCard.vue
@@ -18,8 +18,9 @@ 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 EnabledStatus from '../common/EnabledStatus.vue'
import { canManageDistributors } from '@/lib/permissions'
import {
faPenToSquare,
@@ -38,6 +39,8 @@ 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 isNotesModalShown = ref(false)
const isShownCreateOrEditDistributorDrawer = ref(false)
const isShownSuspendDistributorModal = ref(false)
@@ -112,7 +115,7 @@ function getKebabMenuItems() {
{{ $t('common.archived') }}
@@ -120,7 +123,7 @@ function getKebabMenuItems() {
{{ $t('common.suspended') }}
@@ -128,7 +131,7 @@ function getKebabMenuItems() {
{{ $t('common.enabled') }}
@@ -252,9 +255,53 @@ function getKebabMenuItems() {
+
+
+
+ {{ $t('organizations.rebranding') }}
+
+
+
+
+
+
+
+
+ {{ $t('systems.created_by') }}
+
+
+
+
+
+ {{ distributorDetail.data.created_by.name || '-' }}
+
+ {{
+ distributorDetail.data.created_by.on_behalf_of
+ ? $t('systems.on_behalf_of', {
+ organization: distributorDetail.data.created_by.organization_name,
+ })
+ : distributorDetail.data.created_by.organization_name
+ }}
+
+
+
+ -
+
+
-
+
{{ $t('common.notes') }}
{{
diff --git a/frontend/src/components/distributors/DistributorsTable.vue b/frontend/src/components/distributors/DistributorsTable.vue
index d0a272516..4f8be1827 100644
--- a/frontend/src/components/distributors/DistributorsTable.vue
+++ b/frontend/src/components/distributors/DistributorsTable.vue
@@ -442,19 +442,11 @@ const goToDistributorDetails = (distributor: Distributor) => {
class="flex items-center gap-2 hover:underline"
:aria-label="$t('distributors.show_distributor_resellers', { name: item.name })"
>
-
+
{{ item.resellers_count }}
-
+
{{ item.resellers_count }}
@@ -475,7 +467,7 @@ const goToDistributorDetails = (distributor: Distributor) => {
>
{{ item.customers_count }}
@@ -483,7 +475,7 @@ const goToDistributorDetails = (distributor: Distributor) => {
{{ item.customers_count }}
@@ -505,7 +497,7 @@ const goToDistributorDetails = (distributor: Distributor) => {
>
{{ item.systems_count }}
@@ -513,7 +505,7 @@ const goToDistributorDetails = (distributor: Distributor) => {
{{ item.systems_count }}
@@ -554,7 +546,7 @@ const goToDistributorDetails = (distributor: Distributor) => {
@@ -564,7 +556,7 @@ const goToDistributorDetails = (distributor: Distributor) => {
@@ -574,7 +566,7 @@ const goToDistributorDetails = (distributor: Distributor) => {
diff --git a/frontend/src/components/organizations/OrganizationCombobox.test.ts b/frontend/src/components/organizations/OrganizationCombobox.test.ts
index d08f240ed..a13e88664 100644
--- a/frontend/src/components/organizations/OrganizationCombobox.test.ts
+++ b/frontend/src/components/organizations/OrganizationCombobox.test.ts
@@ -1,9 +1,9 @@
// Copyright (C) 2026 Nethesis S.r.l.
// SPDX-License-Identifier: GPL-3.0-or-later
-import { describe, expect, it, vi } from 'vitest'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
-import { ref } from 'vue'
+import { ref, toValue, type MaybeRefOrGetter } from 'vue'
import { createI18n } from 'vue-i18n'
import OrganizationCombobox from './OrganizationCombobox.vue'
import type { OrganizationSearchResult } from '@/lib/organizations/searchOrganizations'
@@ -15,20 +15,35 @@ const servedPage: OrganizationSearchResult[] = [
{ logto_id: 'org-bbb', name: 'Beta Ltd', type: 'reseller' },
]
+// The types the component asked the search to scope to, captured so a test can
+// tell server-side scoping apart from filtering the served page.
+let requestedTypes: string[] | undefined
+
vi.mock('@/composables/useOrganizationFilter', () => ({
- useOrganizationFilter: () => ({
- organizations: ref(servedPage),
- loading: ref(false),
- onSearch: vi.fn(),
- currentSearch: ref(''),
- }),
+ useOrganizationFilter: (
+ _enabled?: MaybeRefOrGetter,
+ types?: MaybeRefOrGetter,
+ ) => {
+ requestedTypes = toValue(types)
+ return {
+ organizations: ref(servedPage),
+ loading: ref(false),
+ onSearch: vi.fn(),
+ currentSearch: ref(''),
+ }
+ },
}))
+beforeEach(() => {
+ requestedTypes = undefined
+})
+
const i18n = createI18n({ legacy: false, locale: 'en', missingWarn: false, fallbackWarn: false })
// The label is written to the input after mount, so flush before reading it.
async function mountAndReadDisplayedValue(props: {
modelValue: string
+ allowedTypes?: string[]
selectedOrganization?: { logto_id?: string; name: string; type: string }
}) {
const wrapper = mount(OrganizationCombobox, {
@@ -41,6 +56,29 @@ async function mountAndReadDisplayedValue(props: {
}
describe('OrganizationCombobox', () => {
+ // The served page is truncated, so filtering it by type here would drop the
+ // allowed companies that sort behind companies of other types — the scoping
+ // has to reach the search itself.
+ it('scopes the search to the allowed types', async () => {
+ await mountAndReadDisplayedValue({ modelValue: '', allowedTypes: ['distributor'] })
+
+ expect(requestedTypes).toEqual(['distributor'])
+ })
+
+ it('asks for every type when no type is allowed in particular', async () => {
+ await mountAndReadDisplayedValue({ modelValue: '' })
+
+ expect(requestedTypes).toBeUndefined()
+ })
+
+ // Corollary of the above: the page comes back already scoped, so the
+ // component trusts it instead of filtering it a second time.
+ it('keeps a served company whose type is not among the allowed ones', async () => {
+ expect(
+ await mountAndReadDisplayedValue({ modelValue: 'org-bbb', allowedTypes: ['distributor'] }),
+ ).toBe('Beta Ltd')
+ })
+
it('displays a company that falls outside the served page of options', async () => {
expect(
await mountAndReadDisplayedValue({
diff --git a/frontend/src/components/organizations/OrganizationCombobox.vue b/frontend/src/components/organizations/OrganizationCombobox.vue
index 1abdb0c04..fb1d7edb0 100644
--- a/frontend/src/components/organizations/OrganizationCombobox.vue
+++ b/frontend/src/components/organizations/OrganizationCombobox.vue
@@ -20,7 +20,11 @@ const props = withDefaults(
helperText?: string
placeholder?: string
optional?: boolean
- // Restrict the options to these organization types (e.g. ['distributor']).
+ // Scopes the server-side search to these organization types (e.g.
+ // ['distributor']). Filtering by type here rather than on the fetched page
+ // is what keeps the options meaningful: the page is truncated, so a
+ // client-side filter would drop to a handful of entries — or none — as soon
+ // as companies of other types sort ahead of them.
allowedTypes?: string[]
// Exclude these organization ids from the options (e.g. the caller's own org).
excludeOrganizationIds?: string[]
@@ -44,13 +48,14 @@ const { t } = useI18n()
const { organizations, loading, onSearch, currentSearch } = useOrganizationFilter(
() => props.isShown,
+ () => props.allowedTypes,
)
const organizationOptions = computed(() => {
let orgs = organizations.value
- if (props.allowedTypes && props.allowedTypes.length > 0) {
- orgs = orgs.filter((org) => props.allowedTypes!.includes(org.type))
- }
+ // The excluded ids stay a client-side filter: the API has no equivalent
+ // parameter, and unlike a type filter this one only ever removes ids the
+ // caller already knows about, so it cannot silently empty the list.
if (props.excludeOrganizationIds && props.excludeOrganizationIds.length > 0) {
orgs = orgs.filter((org) => !props.excludeOrganizationIds!.includes(org.logto_id))
}
diff --git a/frontend/src/components/organizations/OrganizationDropdownFilter.vue b/frontend/src/components/organizations/OrganizationDropdownFilter.vue
index 631620787..6675682c0 100644
--- a/frontend/src/components/organizations/OrganizationDropdownFilter.vue
+++ b/frontend/src/components/organizations/OrganizationDropdownFilter.vue
@@ -14,16 +14,16 @@ const {
modelValue,
label,
showNoCompanyOption = false,
- organizationType,
+ organizationTypes,
} = defineProps<{
modelValue: NeDropdownFilterV2Option[]
label?: string
showNoCompanyOption?: boolean
/**
- * Restricts the options to a single company type (distributor, reseller,
- * customer). Omit to offer every company the user can see.
+ * Restricts the options to these company types (distributor, reseller,
+ * customer), OR-ed. Omit to offer every company the user can see.
*/
- organizationType?: string
+ organizationTypes?: string[]
}>()
const emit = defineEmits<{
@@ -34,7 +34,7 @@ const { t } = useI18n()
const computedLabel = label ?? t('organizations.organization')
const { options, loading, onSearch, currentSearch } = useOrganizationFilter(
undefined,
- () => organizationType,
+ () => organizationTypes,
)
const noCompanyLabel = computed(() => t('organizations.no_company'))
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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ storedAssetLabel }}
+
+
+ {{ byteFormat1024(assetSlot.existing.size) }}
+
+
+
+
+
+
+ {{ $t('rebranding.remove_asset') }}
+
+
+
+
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 @@
+
+
+
+
+
+
+
+ {{ $t('rebranding.configuration_description') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('common.reset_filters') }}
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('common.reset_filters') }}
+
+
+
+
+ {{ $t('rebranding.company') }}
+
+ {{ $t('rebranding.branded_products') }}
+
+ {{ $t('rebranding.last_updated') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ product.product_display_name }}
+
+
+ -
+
+
+
+
+ {{ formatDateTimeNoSeconds(new Date(item.updated_at), locale) }}
+
+ -
+
+
+
+
+
+
+
+
+
+
+ {
+ pageNum = page
+ }
+ "
+ @select-page-size="
+ (size: number) => {
+ pageSize = size
+ savePageSizeToStorage(REBRANDING_TABLE_ID, size)
+ }
+ "
+ />
+
+
+
+
+
+
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 @@
+
+
+
+
+
+
+
+
+
+ {{ $t('rebranding.owner_description') }}
+
+
+
+
+
+
+ {{ $t('rebranding.add_companies') }}
+
+
+
+
+
+
+
+
+ {{ $t('rebranding.add_companies') }}
+
+
+
+
+
+
+
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/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 @@
+
+
+
+
+
+
+
+
+
+
+ {{ tabTitle }}
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/components/rebranding/preview/LoginScreenPreview.vue b/frontend/src/components/rebranding/preview/LoginScreenPreview.vue
new file mode 100644
index 000000000..ee89dfee5
--- /dev/null
+++ b/frontend/src/components/rebranding/preview/LoginScreenPreview.vue
@@ -0,0 +1,60 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ subtitle }}
+
+
+
+
+ {{ $t('rebranding.preview_sign_in') }}
+
+
+
+
+
diff --git a/frontend/src/components/rebranding/preview/RebrandingPreviewPanel.vue b/frontend/src/components/rebranding/preview/RebrandingPreviewPanel.vue
new file mode 100644
index 000000000..d9cfbbad9
--- /dev/null
+++ b/frontend/src/components/rebranding/preview/RebrandingPreviewPanel.vue
@@ -0,0 +1,172 @@
+
+
+
+
+
+
+ {{ $t('rebranding.preview') }}
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/components/rebranding/preview/TopBarPreview.vue b/frontend/src/components/rebranding/preview/TopBarPreview.vue
new file mode 100644
index 000000000..02d3b2083
--- /dev/null
+++ b/frontend/src/components/rebranding/preview/TopBarPreview.vue
@@ -0,0 +1,70 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('common.search') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/components/resellers/ResellerInfoCard.vue b/frontend/src/components/resellers/ResellerInfoCard.vue
index b607067ab..0687baf8f 100644
--- a/frontend/src/components/resellers/ResellerInfoCard.vue
+++ b/frontend/src/components/resellers/ResellerInfoCard.vue
@@ -16,8 +16,9 @@ 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 EnabledStatus from '../common/EnabledStatus.vue'
import { canManageResellers, canPromoteOrganizations } from '@/lib/permissions'
import {
faPenToSquare,
@@ -34,9 +35,12 @@ 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 isNotesModalShown = ref(false)
const isShownCreateOrEditResellerDrawer = ref(false)
const isShownSuspendResellerModal = ref(false)
@@ -124,7 +128,7 @@ function getKebabMenuItems() {
{{ $t('common.archived') }}
@@ -132,7 +136,7 @@ function getKebabMenuItems() {
{{ $t('common.suspended') }}
@@ -140,7 +144,7 @@ function getKebabMenuItems() {
{{ $t('common.enabled') }}
@@ -230,9 +234,50 @@ function getKebabMenuItems() {
}}
+
+
+
+ {{ $t('organizations.rebranding') }}
+
+
+
+
+
+
+
+
+ {{ $t('systems.created_by') }}
+
+
+
+
+
+ {{ resellerDetail.data.created_by.name || '-' }}
+
+ {{
+ resellerDetail.data.created_by.on_behalf_of
+ ? $t('systems.on_behalf_of', {
+ organization: resellerDetail.data.created_by.organization_name,
+ })
+ : resellerDetail.data.created_by.organization_name
+ }}
+
+
+
+ -
+
+
-
+
{{ $t('common.notes') }}
{{
diff --git a/frontend/src/components/resellers/ResellersTable.vue b/frontend/src/components/resellers/ResellersTable.vue
index 2aed81fa7..71ebf9d97 100644
--- a/frontend/src/components/resellers/ResellersTable.vue
+++ b/frontend/src/components/resellers/ResellersTable.vue
@@ -344,7 +344,7 @@ const goToResellerDetails = (reseller: Reseller) => {
@@ -466,7 +466,7 @@ const goToResellerDetails = (reseller: Reseller) => {
>
{{ item.customers_count }}
@@ -474,7 +474,7 @@ const goToResellerDetails = (reseller: Reseller) => {
{{ item.customers_count }}
@@ -496,7 +496,7 @@ const goToResellerDetails = (reseller: Reseller) => {
>
{{ item.systems_count }}
@@ -504,7 +504,7 @@ const goToResellerDetails = (reseller: Reseller) => {
{{ item.systems_count }}
@@ -545,7 +545,7 @@ const goToResellerDetails = (reseller: Reseller) => {
@@ -555,7 +555,7 @@ const goToResellerDetails = (reseller: Reseller) => {
@@ -565,7 +565,7 @@ const goToResellerDetails = (reseller: Reseller) => {
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/AdditionalServicesCard.vue b/frontend/src/components/systems/AdditionalServicesCard.vue
index 4df8df778..caa412403 100644
--- a/frontend/src/components/systems/AdditionalServicesCard.vue
+++ b/frontend/src/components/systems/AdditionalServicesCard.vue
@@ -12,7 +12,8 @@ import {
NeSkeleton,
} from '@nethesis/vue-components'
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
-import { faCircleCheck, faCircleXmark, faFolderPlus } from '@fortawesome/free-solid-svg-icons'
+import { faFolderPlus } from '@fortawesome/free-solid-svg-icons'
+import EnabledStatus from '@/components/common/EnabledStatus.vue'
import { useLatestInventory } from '@/queries/systems/latestInventory'
import { computed } from 'vue'
import type { NsecFacts, NsecFeatures } from '@/lib/systems/inventory'
@@ -92,23 +93,10 @@ const sortedServices = computed(() =>
{{ service.label }}
-
-
-
- {{ service.enabled ? $t('common.enabled') : $t('common.disabled') }}
-
-
+
-
+
{{ $t('common.notes') }}
{{
diff --git a/frontend/src/components/systems/SystemNetworkCard.vue b/frontend/src/components/systems/SystemNetworkCard.vue
index 921e3703d..012584c87 100644
--- a/frontend/src/components/systems/SystemNetworkCard.vue
+++ b/frontend/src/components/systems/SystemNetworkCard.vue
@@ -30,7 +30,13 @@ const dnsServers = computed(() => {
const networkInterfaces = computed(() => {
const facts = latestInventory.value.data?.data?.facts as NsecFacts | undefined
const networkConfig = facts?.features?.network?.configuration
- return networkConfig ? Object.values(networkConfig) : []
+ if (!networkConfig) {
+ return []
+ }
+ // numeric collation so eth2 comes before eth10
+ return Object.values(networkConfig).sort((a, b) =>
+ a.name.localeCompare(b.name, undefined, { numeric: true }),
+ )
})
const getIpAddressWithCidr = (iface: InventoryNetworkInterface) => {
@@ -125,7 +131,7 @@ const getNetworkRoleForegroundStyle = (role: string | undefined) => {
>
{
{{ $t('system_detail.no_active_alerts') }}
@@ -286,7 +286,7 @@ const timezone = computed(() => {
{{ $t('system_detail.n_backups_stored', { n: backupsCount }, backupsCount) }}
diff --git a/frontend/src/components/systems/SystemStatusIcon.vue b/frontend/src/components/systems/SystemStatusIcon.vue
index c21cec1ae..8ccf4932f 100644
--- a/frontend/src/components/systems/SystemStatusIcon.vue
+++ b/frontend/src/components/systems/SystemStatusIcon.vue
@@ -24,7 +24,7 @@ defineProps<{
{
@@ -589,7 +589,7 @@ const goToAccount = () => {
@@ -599,7 +599,7 @@ const goToAccount = () => {
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/useOrganizationFilter.ts b/frontend/src/composables/useOrganizationFilter.ts
index 747cb5fd7..15532c9bc 100644
--- a/frontend/src/composables/useOrganizationFilter.ts
+++ b/frontend/src/composables/useOrganizationFilter.ts
@@ -17,12 +17,13 @@ import { useI18n } from 'vue-i18n'
*
* @param enabled gates the underlying request; pass the field's visibility so
* the query doesn't fire while the host drawer is closed. Defaults to always on.
- * @param type restricts the options to a single company type. It is part of the
- * query key, so a scoped caller never reads an unscoped result from the cache.
+ * @param types restricts the options to these company types, OR-ed. They are
+ * part of the query key, so a scoped caller never reads an unscoped result
+ * from the cache.
*/
export function useOrganizationFilter(
enabled?: MaybeRefOrGetter,
- type?: MaybeRefOrGetter,
+ types?: MaybeRefOrGetter,
) {
const { t } = useI18n()
const loginStore = useLoginStore()
@@ -37,9 +38,11 @@ export function useOrganizationFilter(
)
const { state, asyncStatus } = useQuery({
- key: () => [ORGANIZATIONS_SEARCH_KEY, debouncedSearch.value, toValue(type) ?? ''],
+ // the types are joined for keying only: the key must be a stable value, not
+ // the array reference the caller re-creates on every render
+ key: () => [ORGANIZATIONS_SEARCH_KEY, debouncedSearch.value, (toValue(types) ?? []).join(',')],
enabled: () => !!loginStore.jwtToken && toValue(enabled ?? true),
- query: () => searchOrganizations(debouncedSearch.value, toValue(type)),
+ query: () => searchOrganizations(debouncedSearch.value, toValue(types)),
})
const organizations = computed(() => state.value.data ?? [])
@@ -68,6 +71,10 @@ export function useOrganizationFilter(
* instead of the combobox hiding itself. Never searches, so the result reflects
* the full eligible set and stays stable while the user types in the combobox.
*
+ * `allowedTypes` scopes the request itself, so the answer is not decided by
+ * whichever companies happen to sort into the first page — and passing the same
+ * types the field uses keeps both on one query key, hence one request.
+ *
* @param enabled gates the request (e.g. drawer open AND caller may attribute).
*/
export function useHasAttributableOrganizations(
@@ -75,13 +82,9 @@ export function useHasAttributableOrganizations(
enabled?: MaybeRefOrGetter,
) {
const loginStore = useLoginStore()
- const { organizations } = useOrganizationFilter(enabled)
+ const { organizations } = useOrganizationFilter(enabled, allowedTypes)
return computed(() =>
- organizations.value.some(
- (org) =>
- toValue(allowedTypes).includes(org.type) &&
- org.logto_id !== loginStore.userInfo?.organization_id,
- ),
+ organizations.value.some((org) => org.logto_id !== loginStore.userInfo?.organization_id),
)
}
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 fc7445d7d..4dc0c024e 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",
@@ -976,6 +977,71 @@
"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_theme": "Theme",
+ "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/addons/systemAddons.ts b/frontend/src/lib/addons/systemAddons.ts
index 9b93061fa..bd84fd238 100644
--- a/frontend/src/lib/addons/systemAddons.ts
+++ b/frontend/src/lib/addons/systemAddons.ts
@@ -192,7 +192,7 @@ export const ADDON_STATUS_STYLE: Record<
},
suspended: {
icon: faCirclePause,
- text: 'text-gray-700 dark:text-gray-400',
+ text: 'text-icon-neutral',
bar: 'bg-gray-700 dark:bg-gray-400',
},
revoked: {
@@ -208,7 +208,7 @@ export const ADDON_STATUS_STYLE: Record<
// never granted here: nothing has gone wrong, so nothing is coloured
not_purchased: {
icon: faCircleMinus,
- text: 'text-icon-disabled',
+ text: 'text-icon-neutral',
bar: 'bg-gray-300 dark:bg-gray-600',
},
}
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/organizations/searchOrganizations.test.ts b/frontend/src/lib/organizations/searchOrganizations.test.ts
new file mode 100644
index 000000000..e8e19baa0
--- /dev/null
+++ b/frontend/src/lib/organizations/searchOrganizations.test.ts
@@ -0,0 +1,38 @@
+// Copyright (C) 2026 Nethesis S.r.l.
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+import { describe, expect, it } from 'vitest'
+import { getOrganizationsSearchQueryString } from './searchOrganizations'
+import { OPTIONS_PAGE_SIZE } from '../common'
+
+const build = (search = '', types?: string[]) =>
+ new URLSearchParams(getOrganizationsSearchQueryString(search, types))
+
+describe('getOrganizationsSearchQueryString', () => {
+ it('always asks for the first page of options', () => {
+ const params = build()
+
+ expect(params.get('page')).toBe('1')
+ expect(params.get('page_size')).toBe(OPTIONS_PAGE_SIZE.toString())
+ })
+
+ it('sends the search term only when it is not blank', () => {
+ expect(build('acme').get('search')).toBe('acme')
+ expect(build(' ').get('search')).toBeNull()
+ expect(build('').get('search')).toBeNull()
+ })
+
+ // 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('', ['distributor', 'reseller'])
+
+ expect(params.getAll('type')).toEqual(['distributor', 'reseller'])
+ expect(params.toString()).toContain('type=distributor&type=reseller')
+ })
+
+ it('omits the type parameter when no type is requested', () => {
+ expect(build('', []).getAll('type')).toEqual([])
+ expect(build('acme').getAll('type')).toEqual([])
+ })
+})
diff --git a/frontend/src/lib/organizations/searchOrganizations.ts b/frontend/src/lib/organizations/searchOrganizations.ts
index 957442487..f3b1dd42b 100644
--- a/frontend/src/lib/organizations/searchOrganizations.ts
+++ b/frontend/src/lib/organizations/searchOrganizations.ts
@@ -24,13 +24,12 @@ interface OrganizationsSearchResponse {
}
/**
- * @param type restricts the results to a single company type (distributor,
- * reseller, customer). Scoping server-side keeps the paginated option list
- * meaningful: filtering by type on the client would drop entries from an
- * already-truncated page.
+ * @param types restricts the results to these company types (distributor,
+ * reseller, customer); the values are OR-ed. Scoping server-side keeps the
+ * paginated option list meaningful: filtering by type on the client would drop
+ * entries from an already-truncated page.
*/
-export const searchOrganizations = (search: string, type?: string) => {
- const loginStore = useLoginStore()
+export const getOrganizationsSearchQueryString = (search: string, types?: string[]): string => {
const params = new URLSearchParams({
page: '1',
page_size: OPTIONS_PAGE_SIZE.toString(),
@@ -40,10 +39,20 @@ export const searchOrganizations = (search: string, type?: string) => {
params.append('search', search)
}
- if (type) {
+ // `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.
+ for (const type of types ?? []) {
params.append('type', type)
}
+ return params.toString()
+}
+
+export const searchOrganizations = (search: string, types?: string[]) => {
+ const loginStore = useLoginStore()
+ const params = getOrganizationsSearchQueryString(search, types)
+
return axios
.get(`${API_URL}/organizations?${params}`, {
headers: { Authorization: `Bearer ${loginStore.jwtToken}` },
diff --git a/frontend/src/lib/permissions.ts b/frontend/src/lib/permissions.ts
index fcc88c99c..f816b6757 100644
--- a/frontend/src/lib/permissions.ts
+++ b/frontend/src/lib/permissions.ts
@@ -26,6 +26,8 @@ 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'
const SUPER_ADMIN_ROLE = 'Super Admin'
// "Owner-level authority": the Owner organization, or a Super Admin user
@@ -167,3 +169,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 @@
+
+
+
+
+
+
+
+
+ {{ $t('rebranding.title') }}
+
+
+
+
+
+
+
+
+
+
+
+
+