From 9591afb9a20fa9a984732a3b8e1bfabc82c2244b Mon Sep 17 00:00:00 2001 From: Jeppe Bundgaard Date: Mon, 8 Jun 2026 13:41:57 +0200 Subject: [PATCH] Add booking count permission checks and handle API response failures gracefully --- .../items/NavigationMenuItemsAdmin.vue | 16 +++++++ .../items/NavigationMenuItemsSuperUser.vue | 21 ++++++---- .../navigation/items/adminBookingCount.js | 21 +++++----- .../navigation/items/bookingCountGuards.js | 24 +++++++++++ .../navigation/items/superUserBookingCount.js | 15 ++++--- src/i18n/locales/da.json | 3 +- src/i18n/locales/de.json | 3 +- src/i18n/locales/en.json | 3 +- src/i18n/locales/no.json | 3 +- src/i18n/locales/sv.json | 3 +- .../configuration/ConfigurationSlack.vue | 19 ++++++++- tests/unit/admin-booking-count.spec.js | 26 ++++++++++++ tests/unit/booking-count-guards.spec.js | 42 +++++++++++++++++++ ...vigation-menu-items-admin-contract.spec.js | 17 ++++++++ tests/unit/slack-module.spec.js | 6 +++ tests/unit/superuser-booking-count.spec.js | 22 ++++++++++ 16 files changed, 214 insertions(+), 30 deletions(-) create mode 100644 src/components/models/navigation/items/bookingCountGuards.js create mode 100644 tests/unit/booking-count-guards.spec.js diff --git a/src/components/models/navigation/items/NavigationMenuItemsAdmin.vue b/src/components/models/navigation/items/NavigationMenuItemsAdmin.vue index 82545246..b9af6322 100644 --- a/src/components/models/navigation/items/NavigationMenuItemsAdmin.vue +++ b/src/components/models/navigation/items/NavigationMenuItemsAdmin.vue @@ -13,6 +13,7 @@ import { } from "@/composables/useDraftTransactionCustomer.js"; import { fetchDepartmentOrderBookingCounts } from "@/components/models/navigation/items/adminBookingCount.js"; import { fetchDepartmentDraftCount } from "@/components/models/navigation/items/adminDraftCount.js"; +import { hasExplicitBookingCountPermission } from "@/components/models/navigation/items/bookingCountGuards.js"; import { NAVIGATION_COUNT_REFRESH_EVENT } from "@/components/models/navigation/items/navigationCountEvents.js"; const t = (key: string) => i18n.global.t(key); @@ -70,6 +71,8 @@ let queuedBookingLoadingIndicator = false; let queuedDraftLoadingIndicator = false; const getDepartmentIdNumber = () => Number.parseInt(String(department_id.value), 10); const canUseAdminNavigationCounts = () => SessionUser.canAccessAdmin(); +const canUseAdminBookingNavigationCounts = () => + canUseAdminNavigationCounts() && hasExplicitBookingCountPermission(SessionUser); const getDepartmentById = (id: number) => { return departments_cache.value?.find((department: any) => Number(department?.id) === Number(id)) || null; }; @@ -148,6 +151,19 @@ const getDepartmentDraftBadge = () => { }; const fetchDepartmentBookingCount = async ({ showLoadingIndicator = false } = {}) => { + if (!canUseAdminBookingNavigationCounts()) { + department_booking_counts.value = { + past: 0, + current: 0, + future: 0, + }; + department_booking_counts_loading.value = false; + bookingCountFetchInFlight = false; + bookingCountRefreshQueued = false; + queuedBookingLoadingIndicator = false; + return; + } + if (!hasValidDepartmentId.value) { department_booking_counts.value = { past: 0, diff --git a/src/components/models/navigation/items/NavigationMenuItemsSuperUser.vue b/src/components/models/navigation/items/NavigationMenuItemsSuperUser.vue index 4956bc03..2cc7a714 100644 --- a/src/components/models/navigation/items/NavigationMenuItemsSuperUser.vue +++ b/src/components/models/navigation/items/NavigationMenuItemsSuperUser.vue @@ -9,6 +9,7 @@ import { } from "@/composables/useDraftTransactionCustomer.js"; import { fetchSuperUserDraftCount } from "@/components/models/navigation/items/superUserDraftCount.js"; import { fetchSuperUserBookingCounts } from "@/components/models/navigation/items/superUserBookingCount.js"; +import { hasExplicitBookingCountPermission } from "@/components/models/navigation/items/bookingCountGuards.js"; import { NAVIGATION_COUNT_REFRESH_EVENT } from "@/components/models/navigation/items/navigationCountEvents.js"; const firstToUpperCase = (str: string) => str.charAt(0).toUpperCase() + str.slice(1); @@ -37,23 +38,29 @@ let queuedSuperUserBookingLoadingIndicator = false; let queuedSuperUserDraftLoadingIndicator = false; const canUseSuperUserNavigationCounts = () => SessionUser.canAccessSuperUser(); +const canUseSuperUserBookingNavigationCounts = () => + canUseSuperUserNavigationCounts() && hasExplicitBookingCountPermission(SessionUser); -const resetSuperUserNavigationCounts = () => { +const resetSuperUserBookingCounts = () => { bookingCountRequestId += 1; - draftCountRequestId += 1; superuser_booking_counts.value = { past: 0, current: 0, future: 0, }; superuser_booking_count_loading.value = false; + superUserBookingCountFetchInFlight = false; + superUserBookingCountRefreshQueued = false; + queuedSuperUserBookingLoadingIndicator = false; +}; + +const resetSuperUserNavigationCounts = () => { + resetSuperUserBookingCounts(); + draftCountRequestId += 1; superuser_draft_count.value = 0; superuser_draft_count_loading.value = false; - superUserBookingCountFetchInFlight = false; superUserDraftCountFetchInFlight = false; - superUserBookingCountRefreshQueued = false; superUserDraftCountRefreshQueued = false; - queuedSuperUserBookingLoadingIndicator = false; queuedSuperUserDraftLoadingIndicator = false; }; @@ -125,8 +132,8 @@ const getSuperUserDraftsBadge = () => { }; const fetchCurrentSuperUserBookingCount = async ({ showLoadingIndicator = false } = {}) => { - if (!canUseSuperUserNavigationCounts()) { - resetSuperUserNavigationCounts(); + if (!canUseSuperUserBookingNavigationCounts()) { + resetSuperUserBookingCounts(); return; } diff --git a/src/components/models/navigation/items/adminBookingCount.js b/src/components/models/navigation/items/adminBookingCount.js index 715fecab..b55ee89c 100644 --- a/src/components/models/navigation/items/adminBookingCount.js +++ b/src/components/models/navigation/items/adminBookingCount.js @@ -1,4 +1,8 @@ import { authenticatedRequest } from "@/components/session/authenticatedRequest.vue"; +import { + emptyBookingCounts, + isExpectedBookingCountRequestFailure, +} from "@/components/models/navigation/items/bookingCountGuards.js"; const normalizePositiveInteger = (value) => { const parsedValue = Number.parseInt(String(value ?? ""), 10); @@ -24,11 +28,7 @@ export const fetchDepartmentOrderBookingCounts = async ({ departmentId }) => { const normalizedDepartmentId = normalizePositiveInteger(departmentId); if (!normalizedDepartmentId) { - return { - past: 0, - current: 0, - future: 0, - }; + return emptyBookingCounts(); } try { @@ -38,12 +38,11 @@ export const fetchDepartmentOrderBookingCounts = async ({ departmentId }) => { return normalizeBookingCounts(response); } catch (error) { - console.error("Error fetching department order-booking counts:", error); - return { - past: 0, - current: 0, - future: 0, - }; + if (!isExpectedBookingCountRequestFailure(error)) { + console.error("Error fetching department order-booking counts:", error); + } + + return emptyBookingCounts(); } }; diff --git a/src/components/models/navigation/items/bookingCountGuards.js b/src/components/models/navigation/items/bookingCountGuards.js new file mode 100644 index 00000000..a5cce918 --- /dev/null +++ b/src/components/models/navigation/items/bookingCountGuards.js @@ -0,0 +1,24 @@ +export const BOOKING_COUNT_PERMISSIONS = ["list_bookings", "list_own_bookings"]; + +export const emptyBookingCounts = () => ({ + past: 0, + current: 0, + future: 0, +}); + +export const hasExplicitBookingCountPermission = (sessionUser) => { + const permissions = sessionUser?.permissions?.value; + + if (!Array.isArray(permissions)) { + return false; + } + + return BOOKING_COUNT_PERMISSIONS.some((permission) => permissions.includes(permission)); +}; + +export const getRequestErrorStatus = (error) => + Number.parseInt(String(error?.response?.status ?? error?.status ?? ""), 10); + +export const isExpectedBookingCountRequestFailure = (error) => { + return [401, 403, 404].includes(getRequestErrorStatus(error)); +}; diff --git a/src/components/models/navigation/items/superUserBookingCount.js b/src/components/models/navigation/items/superUserBookingCount.js index 5fe9c4cf..00c29661 100644 --- a/src/components/models/navigation/items/superUserBookingCount.js +++ b/src/components/models/navigation/items/superUserBookingCount.js @@ -1,4 +1,8 @@ import { authenticatedRequest } from "@/components/session/authenticatedRequest.vue"; +import { + emptyBookingCounts, + isExpectedBookingCountRequestFailure, +} from "@/components/models/navigation/items/bookingCountGuards.js"; const toNonNegativeInteger = (value) => { const parsedValue = Number.parseInt(String(value ?? ""), 10); @@ -20,12 +24,11 @@ export const fetchSuperUserBookingCounts = async () => { const response = await authenticatedRequest("/order-bookings/counts", "GET", {}); return normalizeBookingCounts(response); } catch (error) { - console.error("Error fetching superuser booking counts:", error); - return { - past: 0, - current: 0, - future: 0, - }; + if (!isExpectedBookingCountRequestFailure(error)) { + console.error("Error fetching superuser booking counts:", error); + } + + return emptyBookingCounts(); } }; diff --git a/src/i18n/locales/da.json b/src/i18n/locales/da.json index 45709926..67c9112b 100644 --- a/src/i18n/locales/da.json +++ b/src/i18n/locales/da.json @@ -1354,7 +1354,8 @@ "notification_settings": "Notifikationer", "notification_settings_desc": "Slack-webhooks til systemhændelser.", "subtitle": "Konfiguration af Slack-notifikationer", - "title": "Slack-konfiguration" + "title": "Slack-konfiguration", + "unavailable": "Slack-konfigurationen er ikke tilgængelig i denne API-udgivelse." }, "shelly": { "api_key": "API-Nøgle", diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index f29f5cb4..3c8de01a 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -1354,7 +1354,8 @@ "notification_settings": "Benachrichtigungseinstellungen", "notification_settings_desc": "Slack-Webhooks fuer Systemereignisse.", "subtitle": "Konfiguration von Slack-Benachrichtigungen", - "title": "Slack-Konfiguration" + "title": "Slack-Konfiguration", + "unavailable": "Die Slack-Konfiguration ist in dieser API-Version nicht verfuegbar." }, "shelly": { "api_key": "API-Schl?ssel", diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index d4a9971c..fc33587e 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -1354,7 +1354,8 @@ "notification_settings": "Notification settings", "notification_settings_desc": "Slack webhooks for system events.", "subtitle": "Configuration of Slack notifications", - "title": "Slack configuration" + "title": "Slack configuration", + "unavailable": "Slack configuration is not available on this API release." }, "shelly": { "api_key": "API-nyckel", diff --git a/src/i18n/locales/no.json b/src/i18n/locales/no.json index fe6d8430..c955f685 100644 --- a/src/i18n/locales/no.json +++ b/src/i18n/locales/no.json @@ -1354,7 +1354,8 @@ "notification_settings": "Varslingsinnstillinger", "notification_settings_desc": "Slack-webhooks for systemhendelser.", "subtitle": "Konfigurasjon av Slack-varsler", - "title": "Slack-konfigurasjon" + "title": "Slack-konfigurasjon", + "unavailable": "Slack-konfigurasjonen er ikke tilgjengelig i denne API-utgivelsen." }, "shelly": { "api_key": "API-nøkkel", diff --git a/src/i18n/locales/sv.json b/src/i18n/locales/sv.json index 97ae6897..1cd66a7e 100644 --- a/src/i18n/locales/sv.json +++ b/src/i18n/locales/sv.json @@ -1354,7 +1354,8 @@ "notification_settings": "Aviseringsinställningar", "notification_settings_desc": "Slack-webhooks för systemhändelser.", "subtitle": "Konfiguration av Slack-aviseringar", - "title": "Slack-konfiguration" + "title": "Slack-konfiguration", + "unavailable": "Slack-konfigurationen är inte tillgänglig i den här API-versionen." }, "shelly": { "api_key": "API-nyckel", diff --git a/src/views/dashboards/superUserDashboard/configuration/ConfigurationSlack.vue b/src/views/dashboards/superUserDashboard/configuration/ConfigurationSlack.vue index 2015fc99..185f764d 100644 --- a/src/views/dashboards/superUserDashboard/configuration/ConfigurationSlack.vue +++ b/src/views/dashboards/superUserDashboard/configuration/ConfigurationSlack.vue @@ -8,8 +8,12 @@ import { SessionUser } from "@/components/session/token/SessionUser.vue"; import ConfigurationSubPageWrapper from "@/views/dashboards/superUserDashboard/configuration/ConfigurationSubPageWrapper.vue"; const module_config = ref([]); +const module_config_unavailable = ref(false); + +const getRequestStatus = (error) => Number.parseInt(String(error?.response?.status ?? error?.status ?? ""), 10); const getModuleConfig = async () => { + module_config_unavailable.value = false; await SessionUser.superUser.modules.slack.config .get_all() .then((response) => { @@ -22,7 +26,15 @@ const getModuleConfig = async () => { })); }) .catch((error) => { - console.log(error); + const status = getRequestStatus(error); + + if ([403, 404].includes(status)) { + module_config_unavailable.value = true; + module_config.value = []; + return; + } + + console.error("Error fetching Slack config:", error); }); }; @@ -59,6 +71,11 @@ getModuleConfig(); /> + diff --git a/tests/unit/admin-booking-count.spec.js b/tests/unit/admin-booking-count.spec.js index bae4acdd..da439682 100644 --- a/tests/unit/admin-booking-count.spec.js +++ b/tests/unit/admin-booking-count.spec.js @@ -133,6 +133,32 @@ describe("fetchDepartmentOrderBookingCounts", () => { expect(consoleErrorSpy).toHaveBeenCalled(); }); + it("quietly returns 0 counts when the order-bookings request is denied", async () => { + authenticatedRequestMock.mockRejectedValue({ + response: { + status: 403, + data: { + data: { + message: "Missing permission(s)", + permissions: ["list_own_bookings", "list_bookings"], + }, + }, + }, + }); + + await expect( + fetchDepartmentOrderBookingCounts({ + departmentId: 12, + }) + ).resolves.toEqual({ + past: 0, + current: 0, + future: 0, + }); + + expect(consoleErrorSpy).not.toHaveBeenCalled(); + }); + it("returns the current booking count from the grouped counts helper", async () => { authenticatedRequestMock.mockResolvedValue({ data: { diff --git a/tests/unit/booking-count-guards.spec.js b/tests/unit/booking-count-guards.spec.js new file mode 100644 index 00000000..b732955d --- /dev/null +++ b/tests/unit/booking-count-guards.spec.js @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; + +import { + hasExplicitBookingCountPermission, + isExpectedBookingCountRequestFailure, +} from "@/components/models/navigation/items/bookingCountGuards.js"; + +describe("booking count navigation guards", () => { + it("requires explicit booking permissions instead of superuser shell access", () => { + expect( + hasExplicitBookingCountPermission({ + permissions: { + value: ["superuser"], + }, + }) + ).toBe(false); + + expect( + hasExplicitBookingCountPermission({ + permissions: { + value: ["superuser", "list_bookings"], + }, + }) + ).toBe(true); + + expect( + hasExplicitBookingCountPermission({ + permissions: { + value: ["admin", "list_own_bookings"], + }, + }) + ).toBe(true); + }); + + it("treats expected unavailable or forbidden count responses as quiet fallbacks", () => { + expect(isExpectedBookingCountRequestFailure({ response: { status: 401 } })).toBe(true); + expect(isExpectedBookingCountRequestFailure({ response: { status: 403 } })).toBe(true); + expect(isExpectedBookingCountRequestFailure({ response: { status: 404 } })).toBe(true); + expect(isExpectedBookingCountRequestFailure({ response: { status: 500 } })).toBe(false); + expect(isExpectedBookingCountRequestFailure(new Error("Network Error"))).toBe(false); + }); +}); diff --git a/tests/unit/navigation-menu-items-admin-contract.spec.js b/tests/unit/navigation-menu-items-admin-contract.spec.js index 80a7c328..12994101 100644 --- a/tests/unit/navigation-menu-items-admin-contract.spec.js +++ b/tests/unit/navigation-menu-items-admin-contract.spec.js @@ -7,6 +7,10 @@ const source = readFileSync( join(root, "src/components/models/navigation/items/NavigationMenuItemsAdmin.vue"), "utf8" ).replace(/\r\n/g, "\n"); +const superuserSource = readFileSync( + join(root, "src/components/models/navigation/items/NavigationMenuItemsSuperUser.vue"), + "utf8" +).replace(/\r\n/g, "\n"); describe("NavigationMenuItemsAdmin contract", () => { it("derives department id from synced route context without router import", () => { @@ -17,4 +21,17 @@ describe("NavigationMenuItemsAdmin contract", () => { expect(source).toContain("currentRoute?.params?.departmentId"); expect(source).toContain("currentRoute?.path?.match(/^\\/admin\\/(\\d+)(?:\\/|$)/)"); }); + + it("uses explicit booking-count permissions before polling count badges", () => { + expect(source).toContain("hasExplicitBookingCountPermission(SessionUser)"); + expect(source).toContain("const canUseAdminBookingNavigationCounts = () =>"); + expect(source).toContain("canUseAdminNavigationCounts() && hasExplicitBookingCountPermission(SessionUser)"); + expect(source).toContain("if (!canUseAdminBookingNavigationCounts())"); + expect(superuserSource).toContain("hasExplicitBookingCountPermission(SessionUser)"); + expect(superuserSource).toContain("const canUseSuperUserBookingNavigationCounts = () =>"); + expect(superuserSource).toContain( + "canUseSuperUserNavigationCounts() && hasExplicitBookingCountPermission(SessionUser)" + ); + expect(superuserSource).toContain("if (!canUseSuperUserBookingNavigationCounts())"); + }); }); diff --git a/tests/unit/slack-module.spec.js b/tests/unit/slack-module.spec.js index 6beb2577..013ec018 100644 --- a/tests/unit/slack-module.spec.js +++ b/tests/unit/slack-module.spec.js @@ -52,4 +52,10 @@ describe("slack module contract", () => { ':on-save="SessionUser.superUser.modules.slack.config.keys.customer_registration_webhook_url.set"' ); }); + + it("shows an unavailable state when the API release does not expose Slack config", () => { + expect(slackPageSource).toContain("module_config_unavailable"); + expect(slackPageSource).toContain("[403, 404].includes(status)"); + expect(slackPageSource).toContain('$t("configuration.slack.unavailable")'); + }); }); diff --git a/tests/unit/superuser-booking-count.spec.js b/tests/unit/superuser-booking-count.spec.js index f9fca8a6..35f92fc2 100644 --- a/tests/unit/superuser-booking-count.spec.js +++ b/tests/unit/superuser-booking-count.spec.js @@ -90,6 +90,28 @@ describe("fetchSuperUserBookingCounts", () => { expect(consoleErrorSpy).toHaveBeenCalled(); }); + it("quietly returns 0 counts when the order-bookings request is denied", async () => { + authenticatedRequestMock.mockRejectedValue({ + response: { + status: 403, + data: { + data: { + message: "Missing permission(s)", + permissions: ["list_own_bookings", "list_bookings"], + }, + }, + }, + }); + + await expect(fetchSuperUserBookingCounts()).resolves.toEqual({ + past: 0, + current: 0, + future: 0, + }); + + expect(consoleErrorSpy).not.toHaveBeenCalled(); + }); + it("returns the current booking count from the grouped counts helper", async () => { authenticatedRequestMock.mockResolvedValue({ data: {