Add booking count permission checks and handle API response failures gracefully

This commit is contained in:
Jeppe Bundgaard
2026-06-08 13:41:57 +02:00
parent 0afde8ac8e
commit 9591afb9a2
16 changed files with 214 additions and 30 deletions
@@ -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,
@@ -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;
}
@@ -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();
}
};
@@ -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));
};
@@ -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();
}
};
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -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",
@@ -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();
/>
</ConfigurationCategory>
</template>
<template v-else-if="module_config_unavailable" #content>
<div class="notification is-warning is-light mt-2">
{{ $t("configuration.slack.unavailable") }}
</div>
</template>
</ConfigurationSubPageWrapper>
</RestrictedPageWrapper>
</template>
+26
View File
@@ -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: {
+42
View File
@@ -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);
});
});
@@ -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())");
});
});
+6
View File
@@ -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")');
});
});
@@ -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: {