Add booking count permission checks and handle API response failures gracefully
This commit is contained in:
@@ -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();
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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())");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user