From 091041335f72d963e681d5b84053f22c7fce1148 Mon Sep 17 00:00:00 2001 From: Andrea Leardini Date: Fri, 7 Aug 2026 11:02:35 +0200 Subject: [PATCH 01/16] feat(ui): add add-ons catalog page --- .../components/addons/AddonsCatalogPanel.vue | 465 ++++++++++++++++++ .../components/addons/AddonsReportPanel.vue | 22 + .../addons/CreateOrEditAddonDrawer.vue | 394 +++++++++++++++ .../components/addons/DeleteAddonModal.vue | 79 +++ .../components/addons/ProductTypeSelector.vue | 80 +++ frontend/src/components/shell/SideMenu.vue | 16 +- frontend/src/i18n/en/translation.json | 41 ++ frontend/src/i18n/it/translation.json | 41 ++ frontend/src/lib/addons/addons.ts | 240 +++++++++ frontend/src/lib/permissions.ts | 18 + frontend/src/lib/validation.ts | 10 + frontend/src/queries/addons/addons.ts | 25 + frontend/src/router/index.ts | 5 + frontend/src/views/AddonsView.vue | 45 ++ 14 files changed, 1480 insertions(+), 1 deletion(-) create mode 100644 frontend/src/components/addons/AddonsCatalogPanel.vue create mode 100644 frontend/src/components/addons/AddonsReportPanel.vue create mode 100644 frontend/src/components/addons/CreateOrEditAddonDrawer.vue create mode 100644 frontend/src/components/addons/DeleteAddonModal.vue create mode 100644 frontend/src/components/addons/ProductTypeSelector.vue create mode 100644 frontend/src/lib/addons/addons.ts create mode 100644 frontend/src/queries/addons/addons.ts create mode 100644 frontend/src/views/AddonsView.vue diff --git a/frontend/src/components/addons/AddonsCatalogPanel.vue b/frontend/src/components/addons/AddonsCatalogPanel.vue new file mode 100644 index 000000000..4c3735812 --- /dev/null +++ b/frontend/src/components/addons/AddonsCatalogPanel.vue @@ -0,0 +1,465 @@ + + + + + diff --git a/frontend/src/components/addons/AddonsReportPanel.vue b/frontend/src/components/addons/AddonsReportPanel.vue new file mode 100644 index 000000000..96aea5599 --- /dev/null +++ b/frontend/src/components/addons/AddonsReportPanel.vue @@ -0,0 +1,22 @@ + + + + + diff --git a/frontend/src/components/addons/CreateOrEditAddonDrawer.vue b/frontend/src/components/addons/CreateOrEditAddonDrawer.vue new file mode 100644 index 000000000..c8391736d --- /dev/null +++ b/frontend/src/components/addons/CreateOrEditAddonDrawer.vue @@ -0,0 +1,394 @@ + + + + + diff --git a/frontend/src/components/addons/DeleteAddonModal.vue b/frontend/src/components/addons/DeleteAddonModal.vue new file mode 100644 index 000000000..93dc81cb0 --- /dev/null +++ b/frontend/src/components/addons/DeleteAddonModal.vue @@ -0,0 +1,79 @@ + + + + + diff --git a/frontend/src/components/addons/ProductTypeSelector.vue b/frontend/src/components/addons/ProductTypeSelector.vue new file mode 100644 index 000000000..21679642e --- /dev/null +++ b/frontend/src/components/addons/ProductTypeSelector.vue @@ -0,0 +1,80 @@ + + + + + diff --git a/frontend/src/components/shell/SideMenu.vue b/frontend/src/components/shell/SideMenu.vue index ccf71cacc..126717426 100644 --- a/frontend/src/components/shell/SideMenu.vue +++ b/frontend/src/components/shell/SideMenu.vue @@ -22,6 +22,7 @@ import { faUserGroup as fasUserGroup, faServer as fasServer, faTriangleExclamation, + faPuzzlePiece, } from '@fortawesome/free-solid-svg-icons' import { faGridOne as fasGridOne } from '@nethesis/nethesis-solid-svg-icons' import { @@ -41,6 +42,7 @@ import { canReadResellers, canReadSystems, canReadUsers, + isAddonAdmin, } from '@/lib/permissions' import { isUserCustomer } from '@/lib/organizations/organizations' @@ -67,7 +69,7 @@ const menuExpanded: Ref> = ref({ resellers: false, }) -const systemsManagementRoutes = ['alerts', 'systems', 'applications'] +const systemsManagementRoutes = ['alerts', 'systems', 'applications', 'addons'] const companiesAndUsersRoutes = ['distributors', 'resellers', 'customers', 'users'] const navigation = computed(() => { @@ -102,6 +104,18 @@ const navigation = computed(() => { }) } + // Managing the add-on catalog is a licensing back-office duty, so the entry + // answers to owner-level authority rather than to read:entitlements, which + // every company holds to see its own add-ons. + if (isAddonAdmin()) { + menuItems.push({ + name: 'addons.title', + to: 'addons', + solidIcon: faPuzzlePiece, + lightIcon: faPuzzlePiece, + }) + } + if (canReadDistributors()) { menuItems.push({ name: 'distributors.title', diff --git a/frontend/src/i18n/en/translation.json b/frontend/src/i18n/en/translation.json index 22b085f40..3b56ebf82 100644 --- a/frontend/src/i18n/en/translation.json +++ b/frontend/src/i18n/en/translation.json @@ -38,6 +38,7 @@ "object_saved_successfully": "{name} has been updated successfully", "object_destroyed_successfully": "{name} has been destroyed successfully", "object_archived_successfully": "{name} has been archived successfully", + "object_deleted_successfully": "{name} has been deleted successfully", "updating": "Updating…", "loading": "Loading…", "show": "Show", @@ -842,6 +843,46 @@ "hierarchy_filter_description": "Showing applications of {name} and of all companies in its hierarchy. Changing the Company filter will remove this hierarchy filtering.", "hierarchy_filter_exact": "Filter by this company only" }, + "addons": { + "title": "Add-ons", + "page_description": "List of add-ons that can be granted to systems and sold on NethShop. Each add-on belongs to a product and, for NethServer, to a specific application.", + "report_tab": "Report", + "catalog_tab": "Catalog", + "report_not_available": "Report not available yet", + "report_not_available_description": "Add-on statistics will appear here in a future release.", + "cannot_retrieve_addons": "Cannot retrieve add-ons", + "no_addons": "No add-ons yet", + "no_addons_description": "Create the first add-on to make it available for systems and applications.", + "no_addons_found": "No add-ons found", + "filter_addons": "Filter add-ons", + "addon_name": "Add-on name", + "product": "Product", + "application": "Application", + "description": "Description", + "product_type": "Product type", + "choose_application": "Choose application", + "display_name": "Display name", + "display_name_helper": "The name shown to users in My Nethesis and NethShop.", + "technical_name": "Technical name", + "technical_name_helper": "Used to generate the add-on ID. It cannot be changed later.", + "technical_name_helper_with_id": "Used to generate the add-on ID: {id}. It cannot be changed later.", + "create_addon": "Create add-on", + "edit_addon": "Edit add-on", + "save_addon": "Save add-on", + "delete_addon": "Delete add-on", + "delete_addon_confirmation": "Add-on {name} will no longer be available.", + "addon_created": "Add-on created", + "addon_saved": "Add-on saved", + "addon_deleted": "Add-on deleted", + "cannot_create_addon": "Cannot create add-on", + "cannot_save_addon": "Cannot save add-on", + "cannot_delete_addon": "Cannot delete add-on", + "product_type_required": "Choose a product type", + "application_cannot_be_empty": "Choose an application", + "display_name_cannot_be_empty": "Display name cannot be empty", + "technical_name_cannot_be_empty": "Technical name cannot be empty", + "technical_name_is_invalid": "Technical name can only contain lowercase letters, numbers and hyphens" + }, "forbidden": { "title": "Restricted page", "description": "It seems that you don't have the necessary permissions to view this page." diff --git a/frontend/src/i18n/it/translation.json b/frontend/src/i18n/it/translation.json index 5df12ed06..77a4df89f 100644 --- a/frontend/src/i18n/it/translation.json +++ b/frontend/src/i18n/it/translation.json @@ -37,6 +37,7 @@ "object_saved_successfully": "{name} è stato aggiornato con successo", "object_destroyed_successfully": "{name} è stato distrutto con successo", "object_archived_successfully": "{name} è stato archiviato con successo", + "object_deleted_successfully": "{name} è stato eliminato con successo", "updating": "Aggiornamento…", "loading": "Caricamento…", "show": "Mostra", @@ -769,6 +770,46 @@ "hierarchy_filter_description": "Sono mostrate le applicazioni di {name} e di tutte le aziende della sua gerarchia. Modificare il filtro Azienda rimuoverà il filtro per gerarchia.", "hierarchy_filter_exact": "Filtra solo per questa azienda" }, + "addons": { + "title": "Add-on", + "page_description": "Elenco degli add-on che possono essere assegnati ai sistemi e venduti su NethShop. Ogni add-on appartiene a un prodotto e, per NethServer, a una specifica applicazione.", + "report_tab": "Report", + "catalog_tab": "Catalogo", + "report_not_available": "Report non ancora disponibile", + "report_not_available_description": "Le statistiche sugli add-on appariranno qui in una prossima versione.", + "cannot_retrieve_addons": "Impossibile recuperare gli add-on", + "no_addons": "Nessun add-on", + "no_addons_description": "Crea il primo add-on per renderlo disponibile per sistemi e applicazioni.", + "no_addons_found": "Nessun add-on trovato", + "filter_addons": "Filtra add-on", + "addon_name": "Nome add-on", + "product": "Prodotto", + "application": "Applicazione", + "description": "Descrizione", + "product_type": "Tipo di prodotto", + "choose_application": "Scegli applicazione", + "display_name": "Nome visualizzato", + "display_name_helper": "Il nome mostrato agli utenti in My Nethesis e NethShop.", + "technical_name": "Nome tecnico", + "technical_name_helper": "Utilizzato per generare l'ID dell'add-on. Non può essere modificato in seguito.", + "technical_name_helper_with_id": "Utilizzato per generare l'ID dell'add-on: {id}. Non può essere modificato in seguito.", + "create_addon": "Crea add-on", + "edit_addon": "Modifica add-on", + "save_addon": "Salva add-on", + "delete_addon": "Elimina add-on", + "delete_addon_confirmation": "L'add-on {name} non sarà più disponibile.", + "addon_created": "Add-on creato", + "addon_saved": "Add-on salvato", + "addon_deleted": "Add-on eliminato", + "cannot_create_addon": "Impossibile creare l'add-on", + "cannot_save_addon": "Impossibile salvare l'add-on", + "cannot_delete_addon": "Impossibile eliminare l'add-on", + "product_type_required": "Scegli un tipo di prodotto", + "application_cannot_be_empty": "Scegli un'applicazione", + "display_name_cannot_be_empty": "Il nome visualizzato non può essere vuoto", + "technical_name_cannot_be_empty": "Il nome tecnico non può essere vuoto", + "technical_name_is_invalid": "Il nome tecnico può contenere solo lettere minuscole, numeri e trattini" + }, "forbidden": { "title": "Pagina riservata", "description": "Sembra che tu non abbia i permessi necessari per visualizzare questa pagina." diff --git a/frontend/src/lib/addons/addons.ts b/frontend/src/lib/addons/addons.ts new file mode 100644 index 000000000..41d1a5508 --- /dev/null +++ b/frontend/src/lib/addons/addons.ts @@ -0,0 +1,240 @@ +// 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 ADDONS_KEY = 'addons' +export const ADDONS_TABLE_ID = 'addonsTable' + +// The wire path and the permissions behind it are still named "entitlements": +// that is the backend contract. Everything on this side is an add-on. +const ADDONS_PATH = 'entitlements/catalog' + +// The product an add-on belongs to, matching the system types used everywhere +// else in the app: nsec = NethSecurity, ns8 = NethServer. +export const ADDON_PRODUCTS = ['nsec', 'ns8'] as const +export type AddonProduct = (typeof ADDON_PRODUCTS)[number] + +// Derived from the product, never chosen by the user: NethSecurity add-ons are +// firewall-wide services, NethServer add-ons are modules of a single +// application instance of a cluster (hence scoped). +const AddonKindSchema = v.picklist(['service', 'module']) +export type AddonKind = v.InferOutput + +// Same rule the backend enforces on the catalog id. +export const ADDON_ID_REGEX = /^[a-z0-9][a-z0-9-]{1,98}$/ + +const applicationLogoFiles = import.meta.glob('../../assets/application_logos/*.svg', { + eager: true, + import: 'default', +}) as Record + +// The applications a NethServer add-on can be attached to: the ids are the +// application logo assets shipped with the frontend, so the list stays in step +// with what we can actually render. GET /filters/applications is deliberately +// not used — it only returns the application types currently installed in the +// fleet, while the catalog must offer every supported application. +export const ADDON_APPLICATION_IDS = Object.keys(applicationLogoFiles) + .map((path) => path.split('/').pop()!.replace('.svg', '')) + .sort() + +// Casing the file stems cannot carry. Anything missing falls back to a +// capitalized id. +const APPLICATION_DISPLAY_NAMES: Record = { + crowdsec: 'CrowdSec', + dnsmaskq: 'Dnsmasq', + ejabberd: 'ejabberd', + grafana: 'Grafana', + imapsync: 'Imapsync', + mail: 'Mail', + matrix: 'Matrix', + mattermost: 'Mattermost', + netdata: 'Netdata', + 'nethsecurity-controller': 'NethSecurity Controller', + nethvoice: 'NethVoice', + 'nethvoice-proxy': 'NethVoice Proxy', + nextcloud: 'Nextcloud', + piler: 'Piler', + samba: 'Samba', + webtop: 'WebTop', +} + +export const getApplicationDisplayName = (applicationId: string) => { + return ( + APPLICATION_DISPLAY_NAMES[applicationId] ?? + applicationId.charAt(0).toUpperCase() + applicationId.slice(1) + ) +} + +export const AddonSchema = v.object({ + id: v.string(), + display_name: v.string(), + description: v.string(), + scoped: v.boolean(), + kind: AddonKindSchema, + system_type: v.optional(v.string()), + legacy_alias: v.optional(v.string()), + created_at: v.string(), + updated_at: v.string(), +}) + +export type Addon = v.InferOutput + +// What POST /entitlements/catalog accepts. +export interface CreateAddon { + id: string + display_name: string + description: string + kind: AddonKind + system_type: AddonProduct + scoped: boolean +} + +// What PUT /entitlements/catalog/:id accepts: the backend takes the display +// fields only, every other field is immutable once the add-on exists. +export interface EditAddon { + id: string + display_name: string + description: string +} + +interface AddonsResponse { + code: number + message: string + data: { + catalog: Addon[] + } +} + +interface AddonResponse { + code: number + message: string + data: Addon +} + +// The form the drawer edits. It does not mirror the wire shape: the user picks +// a product and (for NethServer) an application, and the id is composed from +// those plus the technical name. +export interface AddonForm { + product: AddonProduct | '' + application: string + technical_name: string + display_name: string + description: string +} + +// nsec- for NethSecurity, - for NethServer, matching the +// id convention the backend documents. +export const composeAddonId = ( + form: Pick, +) => { + const technicalName = form.technical_name.trim().toLowerCase() + + if (!technicalName) { + return '' + } + + switch (form.product) { + case 'nsec': + return `nsec-${technicalName.replace(/^nsec-/, '')}` + case 'ns8': + return form.application ? `${form.application}-${technicalName}` : '' + default: + return '' + } +} + +// The application is encoded in the id prefix; there is no application field on +// the add-on. Longest match wins, so nethvoice-proxy-* is not read as nethvoice. +const applicationIdsByLength = [...ADDON_APPLICATION_IDS].sort((a, b) => b.length - a.length) + +export const getAddonApplication = (addon: Addon) => { + if (addon.kind !== 'module') { + return '' + } + return applicationIdsByLength.find((appId) => addon.id.startsWith(`${appId}-`)) ?? '' +} + +// The technical name is the id without the product/application prefix — what +// the user typed when the add-on was created. +export const getAddonTechnicalName = (addon: Addon) => { + const application = getAddonApplication(addon) + + if (application) { + return addon.id.slice(application.length + 1) + } + return addon.id.replace(/^nsec-/, '') +} + +export const CreateAddonFormSchema = v.pipe( + v.object({ + product: v.picklist(ADDON_PRODUCTS, 'addons.product_type_required'), + application: v.string(), + technical_name: v.pipe(v.string(), v.nonEmpty('addons.technical_name_cannot_be_empty')), + display_name: v.pipe(v.string(), v.nonEmpty('addons.display_name_cannot_be_empty')), + description: v.string(), + }), + v.forward( + v.check( + (form) => form.product !== 'ns8' || !!form.application, + 'addons.application_cannot_be_empty', + ), + ['application'], + ), + v.forward( + v.check( + (form) => ADDON_ID_REGEX.test(composeAddonId(form)), + 'addons.technical_name_is_invalid', + ), + ['technical_name'], + ), +) + +// Editing only ever touches the two fields the backend lets us change. +export const EditAddonFormSchema = v.object({ + display_name: v.pipe(v.string(), v.nonEmpty('addons.display_name_cannot_be_empty')), + description: v.string(), +}) + +export const getAddons = () => { + const loginStore = useLoginStore() + + return axios + .get(`${API_URL}/${ADDONS_PATH}`, { + headers: { Authorization: `Bearer ${loginStore.jwtToken}` }, + }) + .then((res) => res.data.data.catalog) +} + +export const postAddon = (addon: CreateAddon) => { + const loginStore = useLoginStore() + + return axios + .post(`${API_URL}/${ADDONS_PATH}`, addon, { + headers: { Authorization: `Bearer ${loginStore.jwtToken}` }, + }) + .then((res) => res.data.data) +} + +export const putAddon = (addon: EditAddon) => { + const loginStore = useLoginStore() + + return axios + .put( + `${API_URL}/${ADDONS_PATH}/${addon.id}`, + { display_name: addon.display_name, description: addon.description }, + { headers: { Authorization: `Bearer ${loginStore.jwtToken}` } }, + ) + .then((res) => res.data.data) +} + +export const deleteAddon = (addon: Addon) => { + const loginStore = useLoginStore() + + return axios.delete(`${API_URL}/${ADDONS_PATH}/${addon.id}`, { + headers: { Authorization: `Bearer ${loginStore.jwtToken}` }, + }) +} diff --git a/frontend/src/lib/permissions.ts b/frontend/src/lib/permissions.ts index 11138f94f..1a4af6eb6 100644 --- a/frontend/src/lib/permissions.ts +++ b/frontend/src/lib/permissions.ts @@ -23,6 +23,9 @@ const DESTROY_USERS = 'destroy:users' const DESTROY_SYSTEMS = 'destroy:systems' const READ_ALERTS = 'read:alerts' 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 SUPER_ADMIN_ROLE = 'Super Admin' // "Owner-level authority": the Owner organization, or a Super Admin user @@ -144,3 +147,18 @@ export const canReadAlerts = () => { // the button: the backend gate on PATCH /resellers/:id/promote additionally // keeps a Super Admin outside the Owner org within its own hierarchy. export const canPromoteOrganizations = () => hasOwnerLevelAuthority() + +export const canReadAddons = () => { + const loginStore = useLoginStore() + return loginStore.permissions.includes(READ_ADDONS) +} + +export const canManageAddons = () => { + const loginStore = useLoginStore() + return loginStore.permissions.includes(MANAGE_ADDONS) +} + +// The add-on catalog is a licensing back-office duty, not something every +// distributor takes part in: owner organization or Super Admin only. Mirrors +// the backend isEntitlementAdmin gate on the catalog write endpoints. +export const isAddonAdmin = () => hasOwnerLevelAuthority() diff --git a/frontend/src/lib/validation.ts b/frontend/src/lib/validation.ts index 7bee63695..b20b12914 100644 --- a/frontend/src/lib/validation.ts +++ b/frontend/src/lib/validation.ts @@ -19,6 +19,16 @@ export const isValidationError = (error: Error | null): boolean => { return isValidationErrorCode((error as AxiosError)?.response?.status) } +//// +// The message the backend sent, falling back to the axios one. Axios leaves +// error.message as "Request failed with status code 409", which tells the user +// nothing: reach for response.data.message whenever the API bothered to explain +// itself (e.g. "catalog item is referenced by existing grants"). +export const getBackendErrorMessage = (error: Error | null): string => { + const axiosError = error as AxiosError<{ message?: string }> | null + return axiosError?.response?.data?.message || axiosError?.message || '' +} + export const isValidationErrorCode = (errorCode: number | undefined) => { if (!errorCode) { return false diff --git a/frontend/src/queries/addons/addons.ts b/frontend/src/queries/addons/addons.ts new file mode 100644 index 000000000..1f576bda3 --- /dev/null +++ b/frontend/src/queries/addons/addons.ts @@ -0,0 +1,25 @@ +// Copyright (C) 2026 Nethesis S.r.l. +// SPDX-License-Identifier: GPL-3.0-or-later + +import { ADDONS_KEY, getAddons } from '@/lib/addons/addons' +import { useLoginStore } from '@/stores/login' +import { defineQuery, useQuery } from '@pinia/colada' + +// The catalog endpoint returns the whole list in one unpaginated, unfiltered +// response, so this query takes no parameters: searching, filtering and paging +// all happen client-side in the catalog panel. +export const useAddons = defineQuery(() => { + const loginStore = useLoginStore() + + const { state, asyncStatus, ...rest } = useQuery({ + key: () => [ADDONS_KEY], + enabled: () => !!loginStore.jwtToken, + query: () => getAddons(), + }) + + return { + ...rest, + state, + asyncStatus, + } +}) diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 0364d2b71..179c90e60 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -85,6 +85,11 @@ const router = createRouter({ name: 'applications', component: () => import('../views/ApplicationsView.vue'), }, + { + path: '/addons', + name: 'addons', + component: () => import('../views/AddonsView.vue'), + }, { path: '/applications/:applicationId', name: 'application_detail', diff --git a/frontend/src/views/AddonsView.vue b/frontend/src/views/AddonsView.vue new file mode 100644 index 000000000..c95fb7ebd --- /dev/null +++ b/frontend/src/views/AddonsView.vue @@ -0,0 +1,45 @@ + + + + + From 8cec608d8ca08632697c2e3305332cef806a2e6a Mon Sep 17 00:00:00 2001 From: Andrea Leardini Date: Fri, 7 Aug 2026 18:16:33 +0200 Subject: [PATCH 02/16] feat(ui): add system add-ons page --- frontend/.env.example | 1 + .../components/systems/SystemAddonsPanel.vue | 129 +++++ .../systems/addons/AddonActionModal.vue | 139 +++++ .../systems/addons/AddonStatusIcon.vue | 79 +++ .../systems/addons/SystemAddonCard.vue | 74 +++ .../systems/addons/SystemAddonDetail.vue | 514 ++++++++++++++++++ .../systems/addons/SystemAddonsGrid.vue | 227 ++++++++ frontend/src/i18n/en/translation.json | 53 +- frontend/src/i18n/it/translation.json | 53 +- frontend/src/lib/addons/systemAddons.ts | 311 +++++++++++ frontend/src/lib/dateTime.test.ts | 27 +- frontend/src/lib/dateTime.ts | 12 + frontend/src/lib/permissions.ts | 5 + frontend/src/queries/addons/systemAddons.ts | 68 +++ frontend/src/views/SystemDetailView.vue | 37 +- 15 files changed, 1719 insertions(+), 10 deletions(-) create mode 100644 frontend/src/components/systems/SystemAddonsPanel.vue create mode 100644 frontend/src/components/systems/addons/AddonActionModal.vue create mode 100644 frontend/src/components/systems/addons/AddonStatusIcon.vue create mode 100644 frontend/src/components/systems/addons/SystemAddonCard.vue create mode 100644 frontend/src/components/systems/addons/SystemAddonDetail.vue create mode 100644 frontend/src/components/systems/addons/SystemAddonsGrid.vue create mode 100644 frontend/src/lib/addons/systemAddons.ts create mode 100644 frontend/src/queries/addons/systemAddons.ts diff --git a/frontend/.env.example b/frontend/.env.example index 720ea0f59..da8badd5a 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -5,3 +5,4 @@ VITE_SIGNOUT_REDIRECT_URI=your-signout-redirect-uri VITE_API_BASE_URL=your-api-base-url VITE_LOGTO_ENDPOINT=your-logto-endpoint VITE_LOGTO_APP_ID=your-logto-app-id +VITE_SHOP_BASE_URL=your-shop-base-url diff --git a/frontend/src/components/systems/SystemAddonsPanel.vue b/frontend/src/components/systems/SystemAddonsPanel.vue new file mode 100644 index 000000000..5e93a08d0 --- /dev/null +++ b/frontend/src/components/systems/SystemAddonsPanel.vue @@ -0,0 +1,129 @@ + + + + + diff --git a/frontend/src/components/systems/addons/AddonActionModal.vue b/frontend/src/components/systems/addons/AddonActionModal.vue new file mode 100644 index 000000000..85bc696cc --- /dev/null +++ b/frontend/src/components/systems/addons/AddonActionModal.vue @@ -0,0 +1,139 @@ + + + + + diff --git a/frontend/src/components/systems/addons/AddonStatusIcon.vue b/frontend/src/components/systems/addons/AddonStatusIcon.vue new file mode 100644 index 000000000..173b7877f --- /dev/null +++ b/frontend/src/components/systems/addons/AddonStatusIcon.vue @@ -0,0 +1,79 @@ + + + + + diff --git a/frontend/src/components/systems/addons/SystemAddonCard.vue b/frontend/src/components/systems/addons/SystemAddonCard.vue new file mode 100644 index 000000000..54a992048 --- /dev/null +++ b/frontend/src/components/systems/addons/SystemAddonCard.vue @@ -0,0 +1,74 @@ + + + + + diff --git a/frontend/src/components/systems/addons/SystemAddonDetail.vue b/frontend/src/components/systems/addons/SystemAddonDetail.vue new file mode 100644 index 000000000..6c4675152 --- /dev/null +++ b/frontend/src/components/systems/addons/SystemAddonDetail.vue @@ -0,0 +1,514 @@ + + + + + diff --git a/frontend/src/components/systems/addons/SystemAddonsGrid.vue b/frontend/src/components/systems/addons/SystemAddonsGrid.vue new file mode 100644 index 000000000..2c90fb902 --- /dev/null +++ b/frontend/src/components/systems/addons/SystemAddonsGrid.vue @@ -0,0 +1,227 @@ + + + + + diff --git a/frontend/src/i18n/en/translation.json b/frontend/src/i18n/en/translation.json index 3b56ebf82..2a66d3109 100644 --- a/frontend/src/i18n/en/translation.json +++ b/frontend/src/i18n/en/translation.json @@ -881,7 +881,58 @@ "application_cannot_be_empty": "Choose an application", "display_name_cannot_be_empty": "Display name cannot be empty", "technical_name_cannot_be_empty": "Technical name cannot be empty", - "technical_name_is_invalid": "Technical name can only contain lowercase letters, numbers and hyphens" + "technical_name_is_invalid": "Technical name can only contain lowercase letters, numbers and hyphens", + "system_addons_description": "Add-ons available for this system. Purchases made on NethShop appear here automatically. Open an add-on to see its status, validity and originating order.", + "cannot_retrieve_system_addons": "Cannot retrieve the add-ons of this system", + "cannot_retrieve_available_addons": "Cannot retrieve available add-ons", + "cannot_retrieve_applications": "Cannot retrieve the applications of this system", + "no_system_addons": "No add-ons available", + "no_system_addons_description": "There are no add-ons available for this system.", + "no_applications_found": "No applications found", + "filter_applications": "Filter applications", + "application_type": "Application type", + "status": "Status", + "order": "Order", + "purchased_by": "Purchased by", + "validity": "Validity", + "details": "Details", + "back_to_addons": "Back to add-ons", + "buy": "Buy", + "manually_created": "Manually created", + "created_by": "Created by", + "never_expires": "Never expires", + "purchaser_not_visible": "Not visible", + "status_active": "Active", + "status_pending": "Pending payment", + "status_suspended": "Suspended", + "status_revoked": "Revoked", + "status_expired": "Expired", + "status_not_purchased": "Not purchased", + "active_on_n_applications": "Active on {n} application | Active on {n} applications", + "pending_on_n_applications": "Pending payment on {n} application | Pending payment on {n} applications", + "suspended_on_n_applications": "Suspended on {n} application | Suspended on {n} applications", + "revoked_on_n_applications": "Revoked on {n} application | Revoked on {n} applications", + "expired_on_n_applications": "Expired on {n} application | Expired on {n} applications", + "not_purchased_on_n_applications": "Not purchased on {n} application | Not purchased on {n} applications", + "activate": "Activate", + "revoke": "Revoke", + "reactivate": "Reactivate", + "activate_addon": "Activate add-on", + "revoke_addon": "Revoke add-on", + "reactivate_addon": "Reactivate add-on", + "activate_addon_confirmation": "{name} will be activated for {application}, without a NethShop purchase.", + "revoke_addon_confirmation": "{application} will lose access to {name}.", + "reactivate_addon_confirmation": "{name} will be reactivated for {application}.", + "addon_activated": "Add-on activated", + "addon_revoked": "Add-on revoked", + "addon_reactivated": "Add-on reactivated", + "addon_activated_successfully": "Add-on {name} has been activated for {application}", + "addon_revoked_successfully": "Add-on {name} has been revoked for {application}", + "addon_reactivated_successfully": "Add-on {name} has been reactivated for {application}", + "cannot_activate_addon": "Cannot activate add-on", + "cannot_revoke_addon": "Cannot revoke add-on", + "cannot_reactivate_addon": "Cannot reactivate add-on", + "addon_already_granted": "This add-on has already been granted here. Reload the page to see the current state." }, "forbidden": { "title": "Restricted page", diff --git a/frontend/src/i18n/it/translation.json b/frontend/src/i18n/it/translation.json index 77a4df89f..f97f542be 100644 --- a/frontend/src/i18n/it/translation.json +++ b/frontend/src/i18n/it/translation.json @@ -808,7 +808,58 @@ "application_cannot_be_empty": "Scegli un'applicazione", "display_name_cannot_be_empty": "Il nome visualizzato non può essere vuoto", "technical_name_cannot_be_empty": "Il nome tecnico non può essere vuoto", - "technical_name_is_invalid": "Il nome tecnico può contenere solo lettere minuscole, numeri e trattini" + "technical_name_is_invalid": "Il nome tecnico può contenere solo lettere minuscole, numeri e trattini", + "system_addons_description": "Add-on disponibili per questo sistema. Gli acquisti effettuati su NethShop compaiono qui automaticamente. Apri un add-on per vederne stato, validità e ordine di origine.", + "cannot_retrieve_system_addons": "Impossibile recuperare gli add-on di questo sistema", + "cannot_retrieve_available_addons": "Impossibile recuperare gli add-on disponibili", + "cannot_retrieve_applications": "Impossibile recuperare le applicazioni di questo sistema", + "no_system_addons": "Nessun add-on disponibile", + "no_system_addons_description": "Non ci sono add-on disponibili per questo sistema.", + "no_applications_found": "Nessuna applicazione trovata", + "filter_applications": "Filtra applicazioni", + "application_type": "Tipo di applicazione", + "status": "Stato", + "order": "Ordine", + "purchased_by": "Acquistato da", + "validity": "Validità", + "details": "Dettagli", + "back_to_addons": "Torna agli add-on", + "buy": "Acquista", + "manually_created": "Creato manualmente", + "created_by": "Creato da", + "never_expires": "Non scade mai", + "purchaser_not_visible": "Non visibile", + "status_active": "Attivo", + "status_pending": "In attesa di pagamento", + "status_suspended": "Sospeso", + "status_revoked": "Revocato", + "status_expired": "Scaduto", + "status_not_purchased": "Non acquistato", + "active_on_n_applications": "Attivo su {n} applicazione | Attivo su {n} applicazioni", + "pending_on_n_applications": "In attesa di pagamento su {n} applicazione | In attesa di pagamento su {n} applicazioni", + "suspended_on_n_applications": "Sospeso su {n} applicazione | Sospeso su {n} applicazioni", + "revoked_on_n_applications": "Revocato su {n} applicazione | Revocato su {n} applicazioni", + "expired_on_n_applications": "Scaduto su {n} applicazione | Scaduto su {n} applicazioni", + "not_purchased_on_n_applications": "Non acquistato su {n} applicazione | Non acquistato su {n} applicazioni", + "activate": "Attiva", + "revoke": "Revoca", + "reactivate": "Riattiva", + "activate_addon": "Attiva add-on", + "revoke_addon": "Revoca add-on", + "reactivate_addon": "Riattiva add-on", + "activate_addon_confirmation": "{name} sarà attivato per {application}, senza un acquisto su NethShop.", + "revoke_addon_confirmation": "{application} perderà l'accesso a {name}.", + "reactivate_addon_confirmation": "{name} sarà riattivato per {application}.", + "addon_activated": "Add-on attivato", + "addon_revoked": "Add-on revocato", + "addon_reactivated": "Add-on riattivato", + "addon_activated_successfully": "L'add-on {name} è stato attivato per {application}", + "addon_revoked_successfully": "L'add-on {name} è stato revocato per {application}", + "addon_reactivated_successfully": "L'add-on {name} è stato riattivato per {application}", + "cannot_activate_addon": "Impossibile attivare l'add-on", + "cannot_revoke_addon": "Impossibile revocare l'add-on", + "cannot_reactivate_addon": "Impossibile riattivare l'add-on", + "addon_already_granted": "Questo add-on è già assegnato qui. Ricarica la pagina per vedere lo stato attuale." }, "forbidden": { "title": "Pagina riservata", diff --git a/frontend/src/lib/addons/systemAddons.ts b/frontend/src/lib/addons/systemAddons.ts new file mode 100644 index 000000000..e20cb4fc6 --- /dev/null +++ b/frontend/src/lib/addons/systemAddons.ts @@ -0,0 +1,311 @@ +// Copyright (C) 2026 Nethesis S.r.l. +// SPDX-License-Identifier: GPL-3.0-or-later + +import axios from 'axios' +import { API_URL, SHOP_BASE_URL } from '../config' +import { useLoginStore } from '@/stores/login' +import { getDisplayName, type Application } from '../applications/applications' +import type { Addon } from './addons' + +export const SYSTEM_ADDONS_KEY = 'systemAddons' +export const AVAILABLE_ADDONS_KEY = 'availableAddons' +export const SYSTEM_ADDONS_TABLE_ID = 'systemAddonsTable' + +// The wire paths are still named "entitlements": that is the backend contract. +// Everything on this side is an add-on. +const systemAddonsPath = (systemId: string) => `systems/${systemId}/entitlements` +const AVAILABLE_ADDONS_PATH = 'entitlements/available' + +// Server-computed lifecycle of a grant, in the backend's own precedence order: +// suspended > active > pending > revoked > expired. `suspended` is not a state +// of the grant itself — it means the owning system (or its organization) is +// suspended or deleted and cannot use anything it holds. +export const ADDON_STATUSES = ['active', 'pending', 'suspended', 'revoked', 'expired'] as const +export type AddonStatus = (typeof ADDON_STATUSES)[number] + +// Audit snapshot of the user who bought the grant on NethShop, frozen at +// purchase time. Absent for manual grants; `{ email }` only when the shop +// address matches no My Nethesis user; `{ out_of_scope: true }` when the buyer +// sits outside the viewer's hierarchy (redacted server-side). +export interface AddonPurchaser { + logto_id?: string + name?: string + email?: string + organization_id?: string + organization_name?: string + out_of_scope?: boolean +} + +export interface AddonCreator { + user_id?: string + user_name?: string + organization_id?: string + organization_name?: string + channel?: string +} + +// One add-on granted to one system, optionally narrowed to a single +// application instance through `scope` ('' = the whole system). +export interface AddonGrant { + id: string + system_id: string + // the catalog id of the add-on this grant is for + entitlement: string + scope?: string + source: string + source_ref?: string + valid_from: string + valid_until?: string + revoked_at?: string + // who revoked: 'shop' (subscription cancelled or payment failed — the user + // may buy again) or 'manual' (deliberate revocation — restore only) + revoked_source?: 'manual' | 'shop' + // a shop order placed at checkout whose payment has not landed yet + pending_ref?: string + pending_since?: string + active: boolean + status: AddonStatus + created_by?: AddonCreator + purchased_by?: AddonPurchaser + // shop tier of the purchased product line, display only (e.g. "16-30 device") + variant?: { id?: number; sku?: string; label?: string } + renewal_count?: number +} + +interface Envelope { + code: number + message: string + data: T +} + +const authHeaders = () => { + const loginStore = useLoginStore() + return { headers: { Authorization: `Bearer ${loginStore.jwtToken}` } } +} + +export const getSystemAddons = (systemId: string) => + axios + .get< + Envelope<{ entitlements: AddonGrant[] }> + >(`${API_URL}/${systemAddonsPath(systemId)}`, authHeaders()) + .then((res) => res.data.data.entitlements) + +// What the caller's organization is allowed to have, after the availability +// rules the owner configured. Not the full catalog: an add-on missing from +// here cannot be bought, though an existing grant for it keeps working. +export const getAvailableAddons = () => + axios + .get>(`${API_URL}/${AVAILABLE_ADDONS_PATH}`, authHeaders()) + .then((res) => res.data.data.available) + +// Grant an add-on outright, skipping NethShop. Owner organization or Super +// Admin only, enforced again by the backend. No expiry means perpetual. +export const grantSystemAddon = (systemId: string, addonId: string, scope: string) => + axios + .post< + Envelope + >(`${API_URL}/${systemAddonsPath(systemId)}`, { entitlement: addonId, scope: scope || undefined }, authHeaders()) + .then((res) => res.data.data) + +// Soft revoke: the row stays and records who revoked it, so a later purchase +// can pick it back up. +export const revokeSystemAddon = (systemId: string, addonId: string, scope: string) => + axios + .delete< + Envelope + >(`${API_URL}/${systemAddonsPath(systemId)}/${addonId}?scope=${encodeURIComponent(scope)}`, authHeaders()) + .then((res) => res.data.data) + +// Clears revoked_at/revoked_source. Note this does not touch valid_until, so +// restoring a grant that had also expired leaves it expired — which is why the +// UI only offers it for status 'revoked'. +export const restoreSystemAddon = (systemId: string, addonId: string, scope: string) => + axios + .put< + Envelope + >(`${API_URL}/${systemAddonsPath(systemId)}/${addonId}?scope=${encodeURIComponent(scope)}`, { revoked: false }, authHeaders()) + .then((res) => res.data.data) + +// ----- rows ----- + +// One line of the UI: an add-on, the place it applies to, and the grant that +// covers it if there is one. Services produce a single row for the whole +// system; modules produce one row per application instance. +export interface SystemAddonRow { + addon: Addon + grant?: AddonGrant + // '' for a system-wide service, the application instance's module_id otherwise + scope: string + // the application type (instance_of), '' for a system-wide service + applicationId: string + // what the Application column shows: the instance label, or the system name + applicationLabel: string +} + +// What a place can be in, from the reader's point of view: the five states the +// backend computes, plus the one it has no record of. Ordered worst-to-best +// news, which is the order the card lists them in. +export const ADDON_ROW_STATUSES = [...ADDON_STATUSES, 'not_purchased'] as const +export type AddonRowStatus = (typeof ADDON_ROW_STATUSES)[number] + +// One vocabulary for the whole feature: the card counts these and the detail +// table names them one row at a time. Summarising several of them as +// "inactive" would have the card contradict the table it opens onto. +export const getRowStatus = (row: SystemAddonRow): AddonRowStatus => + row.grant?.status ?? 'not_purchased' + +interface ComposeRowsArgs { + availableAddons: Addon[] + grants: AddonGrant[] + applications: Application[] + systemType: string + systemName: string +} + +// The grant list carries no catalog information and the application instances +// live in a different endpoint again, so the three are joined here. The +// backend model is flat — one row per (system, add-on, scope) — and the +// two-level shape the UI shows is built entirely from that. +export const composeSystemAddonRows = ({ + availableAddons, + grants, + applications, + systemType, + systemName, +}: ComposeRowsArgs): SystemAddonRow[] => { + const findGrant = (addonId: string, scope: string) => + grants.find((grant) => grant.entitlement === addonId && (grant.scope ?? '') === scope) + + const rows: SystemAddonRow[] = [] + + // System-wide services, limited to the ones that apply to this product. + const services = availableAddons.filter( + (addon) => + addon.kind === 'service' && + (!addon.system_type || !systemType || addon.system_type === systemType), + ) + + for (const addon of services) { + rows.push({ + addon, + grant: findGrant(addon.id, ''), + scope: '', + applicationId: '', + applicationLabel: systemName, + }) + } + + // Modules only exist on a NethServer cluster, one row per application + // instance: a module belongs to an application when its catalog id is + // prefixed with that application's type. Longest prefix wins, so a + // "nethvoice-proxy-*" module is not read as belonging to "nethvoice". + if (systemType === 'ns8') { + const modules = availableAddons.filter((addon) => addon.kind === 'module') + const instances = [...applications].sort((a, b) => a.module_id.localeCompare(b.module_id)) + const applicationTypes = [...new Set(instances.map((app) => app.instance_of))].sort( + (a, b) => b.length - a.length, + ) + const ownerOf = new Map( + modules.map((module) => [ + module.id, + applicationTypes.find((type) => module.id.startsWith(`${type}-`)) ?? '', + ]), + ) + + for (const application of instances) { + for (const addon of modules.filter( + (module) => ownerOf.get(module.id) === application.instance_of, + )) { + rows.push({ + addon, + grant: findGrant(addon.id, application.module_id), + scope: application.module_id, + applicationId: application.instance_of, + applicationLabel: getDisplayName(application), + }) + } + } + } + + // Grants nothing above accounts for — legacy imports, add-ons an + // availability rule has since restricted away — still deserve a row rather + // than vanishing from a page that claims to list what the system holds. + const covered = new Set(rows.map((row) => `${row.addon.id} ${row.scope}`)) + + for (const grant of grants) { + const scope = grant.scope ?? '' + + if (covered.has(`${grant.entitlement} ${scope}`)) { + continue + } + + const application = applications.find((app) => app.module_id === scope) + // Usually the add-on is perfectly well known and was only skipped above + // because it does not apply to this product — an nsec service granted to a + // cluster, say. Falling straight through to a synthetic entry would show + // the user a raw id when a display name was right there. + const known = availableAddons.find((addon) => addon.id === grant.entitlement) + + rows.push({ + addon: known ?? { + // nothing to read a name from, so the id has to stand in + id: grant.entitlement, + display_name: grant.entitlement, + description: '', + scoped: !!scope, + kind: scope ? 'module' : 'service', + created_at: '', + updated_at: '', + }, + grant, + scope, + applicationId: application?.instance_of ?? '', + applicationLabel: application ? getDisplayName(application) : scope || systemName, + }) + } + + return rows +} + +// ----- NethShop links ----- + +// The shop's SSO entry point: it signs the user in, then honours redirect_to +// or completes the purchase. +const SHOP_ACTIVATE_URL = `${SHOP_BASE_URL}/wp-admin/admin-ajax.php?action=activate` + +// The reference shown in the Order column: the order awaiting payment while +// pending, the originating order otherwise. +export const getGrantRef = (grant: AddonGrant) => + grant.status === 'pending' ? grant.pending_ref : grant.source_ref + +export const getOrderNumber = (grant: AddonGrant) => + getGrantRef(grant)?.match(/^wc-order-(\d+)$/)?.[1] ?? '' + +// Buyers open their own order in the customer area; add-on admins are shop +// Administrators and open the backoffice editor, because the customer page +// rejects orders that are not theirs. +export const getOrderUrl = (grant: AddonGrant, isAdmin: boolean) => { + const orderNumber = getOrderNumber(grant) + + if (!orderNumber) { + return '' + } + + const target = isAdmin + ? `${SHOP_BASE_URL}/wp-admin/post.php?post=${orderNumber}&action=edit` + : `${SHOP_BASE_URL}/mio-account/view-order/${orderNumber}/` + + return `${SHOP_ACTIVATE_URL}&redirect_to=${encodeURIComponent(target)}` +} + +export const getBuyUrl = (systemKey: string, row: SystemAddonRow) => { + let url = `${SHOP_ACTIVATE_URL}&system_key=${encodeURIComponent(systemKey)}&entitlement=${encodeURIComponent(row.addon.id)}` + + if (row.scope) { + url += `&scope=${encodeURIComponent(row.scope)}` + } + // Explicit return page for the shop's "back to My Nethesis" link: the + // Referer header cannot carry it, since strict-origin-when-cross-origin + // strips the path on cross-origin navigations. + return `${url}&return_url=${encodeURIComponent(window.location.href)}` +} diff --git a/frontend/src/lib/dateTime.test.ts b/frontend/src/lib/dateTime.test.ts index 4a5fbaa2f..7fcad8959 100644 --- a/frontend/src/lib/dateTime.test.ts +++ b/frontend/src/lib/dateTime.test.ts @@ -1,7 +1,13 @@ // Copyright (C) 2025 Nethesis S.r.l. // SPDX-License-Identifier: GPL-3.0-or-later -import { formatMinutes, formatSeconds, formatTimeNoSeconds, formatUptime } from './dateTime' +import { + formatDateNoTime, + formatMinutes, + formatSeconds, + formatTimeNoSeconds, + formatUptime, +} from './dateTime' import { expect, it, describe, vi, beforeEach } from 'vitest' // Create a simple mock function for translation @@ -24,6 +30,25 @@ const mockT = vi.fn((key: string, count?: number) => { return key }) +describe('formatDateNoTime', () => { + it('should format the date without any time', () => { + const date = new Date('2025-10-03T09:30:45Z') + const result = formatDateNoTime(date, 'en-US', 'UTC') + + expect(result).toContain('2025') + expect(result).toContain('Oct') + expect(result).toContain('03') + expect(result).not.toContain('09') + expect(result).not.toContain('30') + }) + + it('should localize the month name', () => { + const date = new Date('2025-08-06T12:00:00Z') + + expect(formatDateNoTime(date, 'it-IT', 'UTC')).toContain('ago') + }) +}) + describe('formatTimeNoSeconds', () => { it('should format time without seconds', () => { const date = new Date('2025-10-03T09:30:45Z') diff --git a/frontend/src/lib/dateTime.ts b/frontend/src/lib/dateTime.ts index 22b5c6257..dd9173b9d 100644 --- a/frontend/src/lib/dateTime.ts +++ b/frontend/src/lib/dateTime.ts @@ -25,6 +25,18 @@ function getTimeZoneOptions(timeZone?: string): Intl.DateTimeFormatOptions { } } +// The calendar day on its own, matching the date half of +// formatDateTimeNoSeconds: "Aug 06, 2026" in English, "06 ago 2026" in +// Italian. For values where the time of day carries no meaning. +export function formatDateNoTime(dateTime: Date, locale: string, timeZone?: string): string { + return dateTime.toLocaleDateString(locale, { + year: 'numeric', + month: 'short', + day: '2-digit', + ...getTimeZoneOptions(timeZone), + }) +} + export function formatTimeNoSeconds(dateTime: Date, locale: string, timeZone?: string): string { return dateTime.toLocaleTimeString(locale, { hour: '2-digit', diff --git a/frontend/src/lib/permissions.ts b/frontend/src/lib/permissions.ts index 1a4af6eb6..fcc88c99c 100644 --- a/frontend/src/lib/permissions.ts +++ b/frontend/src/lib/permissions.ts @@ -162,3 +162,8 @@ export const canManageAddons = () => { // distributor takes part in: owner organization or Super Admin only. Mirrors // the backend isEntitlementAdmin gate on the catalog write endpoints. export const isAddonAdmin = () => hasOwnerLevelAuthority() + +// Buying an add-on on NethShop is what a distributor, reseller or customer +// 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() diff --git a/frontend/src/queries/addons/systemAddons.ts b/frontend/src/queries/addons/systemAddons.ts new file mode 100644 index 000000000..84e4ea5ba --- /dev/null +++ b/frontend/src/queries/addons/systemAddons.ts @@ -0,0 +1,68 @@ +// Copyright (C) 2026 Nethesis S.r.l. +// SPDX-License-Identifier: GPL-3.0-or-later + +import { defineQuery, useQuery } from '@pinia/colada' +import { useRoute } from 'vue-router' +import { + AVAILABLE_ADDONS_KEY, + SYSTEM_ADDONS_KEY, + getAvailableAddons, + getSystemAddons, +} from '@/lib/addons/systemAddons' +import { APPLICATIONS_KEY, getApplications } from '@/lib/applications/applications' +import { useLoginStore } from '@/stores/login' + +// Every application instance of the system in one request: the add-ons panel +// needs the whole set to build its rows, and a cluster never has enough +// instances to page through. +const SYSTEM_APPLICATIONS_PAGE_SIZE = 200 + +export const useSystemAddons = defineQuery(() => { + const loginStore = useLoginStore() + const route = useRoute() + + const { state, asyncStatus, ...rest } = useQuery({ + key: () => [SYSTEM_ADDONS_KEY, route.params.systemId as string], + enabled: () => !!loginStore.jwtToken && !!route.params.systemId, + query: () => getSystemAddons(route.params.systemId as string), + }) + + return { ...rest, state, asyncStatus } +}) + +export const useAvailableAddons = defineQuery(() => { + const loginStore = useLoginStore() + + const { state, asyncStatus, ...rest } = useQuery({ + key: () => [AVAILABLE_ADDONS_KEY], + enabled: () => !!loginStore.jwtToken, + query: () => getAvailableAddons(), + }) + + return { ...rest, state, asyncStatus } +}) + +export const useSystemApplications = defineQuery(() => { + const loginStore = useLoginStore() + const route = useRoute() + + const { state, asyncStatus, ...rest } = useQuery({ + key: () => [APPLICATIONS_KEY, 'system', route.params.systemId as string], + enabled: () => !!loginStore.jwtToken && !!route.params.systemId, + query: () => + getApplications( + 1, + SYSTEM_APPLICATIONS_PAGE_SIZE, + '', + [], + [], + [route.params.systemId as string], + [], + false, + 'module_id', + false, + ), + }) + + return { ...rest, state, asyncStatus } +}) diff --git a/frontend/src/views/SystemDetailView.vue b/frontend/src/views/SystemDetailView.vue index 9f3524f6f..3ba04a2ab 100644 --- a/frontend/src/views/SystemDetailView.vue +++ b/frontend/src/views/SystemDetailView.vue @@ -8,26 +8,43 @@ import { NeHeading, NeInlineNotification, NeSkeleton, NeTabs } from '@nethesis/v /*//// import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome' import { faArrowUpRightFromSquare } from '@fortawesome/free-solid-svg-icons' */ import PageBreadcrumb from '@/components/common/PageBreadcrumb.vue' -import { canReadSystems } from '@/lib/permissions' +import { canReadAddons, canReadSystems } from '@/lib/permissions' import { useSystemDetail } from '@/queries/systems/systemDetail' import { useTabs } from '@/composables/useTabs' +import { computed } from 'vue' import { useI18n } from 'vue-i18n' +import type { Tab } from '@nethesis/vue-components' import SystemOverviewPanel from '@/components/systems/SystemOverviewPanel.vue' import SystemChangeHistoryPanel from '@/components/systems/SystemChangeHistoryPanel.vue' import SystemBackupsPanel from '@/components/systems/SystemBackupsPanel.vue' import SystemAlertsPanel from '@/components/systems/SystemAlertsPanel.vue' +import SystemAddonsPanel from '@/components/systems/SystemAddonsPanel.vue' import { useLatestInventory } from '@/queries/systems/latestInventory' +import { useLoginStore } from '@/stores/login' const { t } = useI18n() +const loginStore = useLoginStore() const { state: systemDetail } = useSystemDetail() const { state: latestInventory } = useLatestInventory() // const { state: reachabilityState, asyncStatus: reachabilityAsyncStatus } = useSystemReachability() //// -const { tabs, selectedTab } = useTabs([ - { name: 'overview', label: t('system_detail.overview') }, - { name: 'change_history', label: t('system_detail.change_history') }, - { name: 'alert_history', label: t('alerts.title') }, - { name: 'backups', label: t('backups.title') }, -]) + +// Add-ons are only listed to companies allowed to see what they hold; every +// other tab is available to anyone who can read the system. +const tabsConfig = computed((): Tab[] => { + const tabs: Tab[] = [ + { name: 'overview', label: t('system_detail.overview') }, + { name: 'change_history', label: t('system_detail.change_history') }, + { name: 'alert_history', label: t('alerts.title') }, + { name: 'backups', label: t('backups.title') }, + ] + + if (canReadAddons()) { + tabs.push({ name: 'addons', label: t('addons.title') }) + } + return tabs +}) + +const { tabs, selectedTab } = useTabs(tabsConfig) //// // const isSystemReachable = computed(() => !!reachabilityState.value.data?.reachable) @@ -113,7 +130,12 @@ const { tabs, selectedTab } = useTabs([ :description="$t('system_detail.no_inventory_available_description')" class="mb-4" /> + + + From cdd8d7db0d8952212fd377c423f91b74a430903d Mon Sep 17 00:00:00 2001 From: Andrea Leardini Date: Tue, 25 Aug 2026 12:35:28 +0200 Subject: [PATCH 03/16] feat(ui): add addons dashboard --- .../components/addons/AddonsReportPanel.vue | 160 ++++++++++++++- .../addons/report/AddonActivationsCard.vue | 80 ++++++++ .../addons/report/AddonRenewalsCard.vue | 56 +++++ .../addons/report/AddonsByAddonCard.vue | 102 +++++++++ .../report/AddonsByOrganizationCard.vue | 117 +++++++++++ .../addons/report/AddonsByTierCard.vue | 110 ++++++++++ .../components/addons/report/ReportCard.vue | 32 +++ .../systems/addons/AddonStatusIcon.vue | 59 +----- frontend/src/i18n/en/translation.json | 35 +++- frontend/src/i18n/it/translation.json | 35 +++- frontend/src/lib/addons/addonsReport.ts | 194 ++++++++++++++++++ frontend/src/lib/addons/systemAddons.ts | 47 +++++ frontend/src/queries/addons/addonsReport.ts | 141 +++++++++++++ frontend/src/views/AddonsView.vue | 4 +- 14 files changed, 1104 insertions(+), 68 deletions(-) create mode 100644 frontend/src/components/addons/report/AddonActivationsCard.vue create mode 100644 frontend/src/components/addons/report/AddonRenewalsCard.vue create mode 100644 frontend/src/components/addons/report/AddonsByAddonCard.vue create mode 100644 frontend/src/components/addons/report/AddonsByOrganizationCard.vue create mode 100644 frontend/src/components/addons/report/AddonsByTierCard.vue create mode 100644 frontend/src/components/addons/report/ReportCard.vue create mode 100644 frontend/src/lib/addons/addonsReport.ts create mode 100644 frontend/src/queries/addons/addonsReport.ts diff --git a/frontend/src/components/addons/AddonsReportPanel.vue b/frontend/src/components/addons/AddonsReportPanel.vue index 96aea5599..c6e2617a4 100644 --- a/frontend/src/components/addons/AddonsReportPanel.vue +++ b/frontend/src/components/addons/AddonsReportPanel.vue @@ -2,21 +2,159 @@ Copyright (C) 2026 Nethesis S.r.l. SPDX-License-Identifier: GPL-3.0-or-later - Placeholder for the add-on report. The backend already exposes the aggregates - (/entitlements/report, /report/organizations, /report/tiers); the panel that - renders them is not part of this change. + The add-on report: what the fleet holds, where it sits and how it is + trending. Every figure is scoped server-side to the caller's hierarchy — the + owner organization and Super Admins see everything, a reseller only its own + companies — so nothing here needs a permission check of its own. + + The totals, the trend, the renewal split and the per-add-on breakdown arrive + in one response; the two tables page and search on their own. --> diff --git a/frontend/src/components/addons/report/AddonActivationsCard.vue b/frontend/src/components/addons/report/AddonActivationsCard.vue new file mode 100644 index 000000000..c777f9bd3 --- /dev/null +++ b/frontend/src/components/addons/report/AddonActivationsCard.vue @@ -0,0 +1,80 @@ + + + + + diff --git a/frontend/src/components/addons/report/AddonRenewalsCard.vue b/frontend/src/components/addons/report/AddonRenewalsCard.vue new file mode 100644 index 000000000..fc83d39b5 --- /dev/null +++ b/frontend/src/components/addons/report/AddonRenewalsCard.vue @@ -0,0 +1,56 @@ + + + + + diff --git a/frontend/src/components/addons/report/AddonsByAddonCard.vue b/frontend/src/components/addons/report/AddonsByAddonCard.vue new file mode 100644 index 000000000..483c52c6f --- /dev/null +++ b/frontend/src/components/addons/report/AddonsByAddonCard.vue @@ -0,0 +1,102 @@ + + + + + diff --git a/frontend/src/components/addons/report/AddonsByOrganizationCard.vue b/frontend/src/components/addons/report/AddonsByOrganizationCard.vue new file mode 100644 index 000000000..9c36681cc --- /dev/null +++ b/frontend/src/components/addons/report/AddonsByOrganizationCard.vue @@ -0,0 +1,117 @@ + + + + + diff --git a/frontend/src/components/addons/report/AddonsByTierCard.vue b/frontend/src/components/addons/report/AddonsByTierCard.vue new file mode 100644 index 000000000..e54b56f6b --- /dev/null +++ b/frontend/src/components/addons/report/AddonsByTierCard.vue @@ -0,0 +1,110 @@ + + + + + diff --git a/frontend/src/components/addons/report/ReportCard.vue b/frontend/src/components/addons/report/ReportCard.vue new file mode 100644 index 000000000..2314aae39 --- /dev/null +++ b/frontend/src/components/addons/report/ReportCard.vue @@ -0,0 +1,32 @@ + + + + + diff --git a/frontend/src/components/systems/addons/AddonStatusIcon.vue b/frontend/src/components/systems/addons/AddonStatusIcon.vue index 173b7877f..5cfe4c203 100644 --- a/frontend/src/components/systems/addons/AddonStatusIcon.vue +++ b/frontend/src/components/systems/addons/AddonStatusIcon.vue @@ -2,26 +2,18 @@ Copyright (C) 2026 Nethesis S.r.l. SPDX-License-Identifier: GPL-3.0-or-later - Where an add-on stands in one place. Shaped after SystemStatusIcon: a v-if - chain rather than a lookup map, so the icon, the colour and the label of a - state all stay on one line. + Where an add-on stands in one place. The icon and its colour come from + ADDON_STATUS_STYLE, the one table the report's stacked bars read as well, so + a state cannot be called or coloured one way in the summary and another in + the table it opens onto. - Both the card and the detail table render this, so a place cannot be called - one thing in the summary and another in the table it opens onto. Pass `count` - on the card to name the state and count the applications in it at once. + Pass `count` on a card to name the state and count the applications in it at + once. -->