From 4ef6840b42e6e0a3f20c3924ee95176cbd983f90 Mon Sep 17 00:00:00 2001 From: Ramon Smits Date: Thu, 25 Jun 2026 15:32:00 +0200 Subject: [PATCH 01/15] =?UTF-8?q?=E2=9C=A8=20Add=20API=20route=20registry?= =?UTF-8?q?=20for=20UI=20gating?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/Frontend/src/composables/apiRoutes.ts | 37 +++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 src/Frontend/src/composables/apiRoutes.ts diff --git a/src/Frontend/src/composables/apiRoutes.ts b/src/Frontend/src/composables/apiRoutes.ts new file mode 100644 index 000000000..2897bd179 --- /dev/null +++ b/src/Frontend/src/composables/apiRoutes.ts @@ -0,0 +1,37 @@ +// Maps each gated UI capability to the ServiceControl HTTP route it represents. +// This is the ONLY place coupling ServicePulse to ServiceControl's route surface: +// the UI gates on routes it already calls, never on the internal permission grammar. +// Paths use {} for each route parameter; matching is param-name-insensitive (see routeMatching.ts). +// Each entry tracks a route in ServiceControl's APIApprovals.HttpApiRoutes (Primary or Monitoring instance). +export type RouteRef = { method: string; path: string }; + +export const ApiRoutes = { + // ---- nav / verb-level (GET routes gated by the matching :view permission) ---- + viewHeartbeats: { method: "GET", path: "/api/heartbeats/stats" }, // error:heartbeats:view (EndpointsMonitoringController) + viewMonitoredEndpoints: { method: "GET", path: "/api/monitored-endpoints" }, // monitoring:endpoint:view (DiagramApiController, Monitoring instance) + viewAuditMessages: { method: "GET", path: "/api/messages2" }, // error:messages:view (GetMessages2Controller) + viewFailedMessages: { method: "GET", path: "/api/errors" }, // error:messages:view (GetAllErrorsController) + viewCustomChecks: { method: "GET", path: "/api/customchecks" }, // error:customchecks:view (CustomCheckController) + viewEventLog: { method: "GET", path: "/api/eventlogitems" }, // error:eventlog:view (EventLogApiController) + viewThroughput: { method: "GET", path: "/api/licensing/report/available" }, // error:throughput:view (LicensingController) + viewLicense: { method: "GET", path: "/api/license" }, // error:licensing:view (LicenseController) + viewConnections: { method: "GET", path: "/api/connection" }, // error:connections:view (ConnectionController) + viewNotifications: { method: "GET", path: "/api/notifications/email" }, // error:notifications:view (NotificationsController) + viewRedirects: { method: "GET", path: "/api/redirects" }, // error:redirects:view (MessageRedirectsController) + viewEndpoints: { method: "GET", path: "/api/endpoints" }, // error:endpoints:view (EndpointsMonitoringController) + manageThroughput: { method: "POST", path: "/api/licensing/settings/masks/update" }, // error:throughput:manage (LicensingController) + // ---- actions ---- + manageNotifications: { method: "POST", path: "/api/notifications/email" }, + testNotifications: { method: "POST", path: "/api/notifications/email/test" }, + manageRedirects: { method: "POST", path: "/api/redirects" }, + dismissCustomCheck: { method: "DELETE", path: "/api/customchecks/{}" }, + retryMessage: { method: "POST", path: "/api/errors/retry" }, + editMessage: { method: "POST", path: "/api/edit/{}" }, + deleteMessage: { method: "PATCH", path: "/api/errors/archive" }, + restoreMessage: { method: "PATCH", path: "/api/errors/unarchive" }, + retryGroup: { method: "POST", path: "/api/recoverability/groups/{}/errors/retry" }, + deleteGroup: { method: "POST", path: "/api/recoverability/groups/{}/errors/archive" }, + restoreGroup: { method: "POST", path: "/api/recoverability/groups/{}/errors/unarchive" }, + deleteEndpointInstance: { method: "DELETE", path: "/api/endpoints/{}" }, + deleteMonitoredEndpoint: { method: "DELETE", path: "/api/monitored-instance/{}/{}" }, +} as const satisfies Record; From 6ef129fd9f3068591f722101dc02c5954bffdea8 Mon Sep 17 00:00:00 2001 From: Ramon Smits Date: Thu, 25 Jun 2026 15:36:41 +0200 Subject: [PATCH 02/15] =?UTF-8?q?=E2=9C=A8=20Add=20structural=20route-key?= =?UTF-8?q?=20matcher?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/Frontend/src/composables/routeMatching.spec.ts | 14 ++++++++++++++ src/Frontend/src/composables/routeMatching.ts | 9 +++++++++ 2 files changed, 23 insertions(+) create mode 100644 src/Frontend/src/composables/routeMatching.spec.ts create mode 100644 src/Frontend/src/composables/routeMatching.ts diff --git a/src/Frontend/src/composables/routeMatching.spec.ts b/src/Frontend/src/composables/routeMatching.spec.ts new file mode 100644 index 000000000..6ebbfd012 --- /dev/null +++ b/src/Frontend/src/composables/routeMatching.spec.ts @@ -0,0 +1,14 @@ +import { describe, it, expect } from "vitest"; +import { normalizeRouteKey } from "@/composables/routeMatching"; + +describe("normalizeRouteKey", () => { + it.each([ + ["POST", "/api/errors/{id}/retry", "POST /api/errors/{}/retry"], + ["post", "/api/errors/{failedMessageId}/retry", "POST /api/errors/{}/retry"], // param-name-insensitive + verb case + ["GET", "/api/errors", "GET /api/errors"], + ["DELETE", "/api/monitored-instance/{name}/{instanceId}", "DELETE /api/monitored-instance/{}/{}"], + ["GET", "api/messages2", "GET /api/messages2"], // adds leading slash + ])("normalizes %s %s", (method, path, expected) => { + expect(normalizeRouteKey(method, path)).toBe(expected); + }); +}); diff --git a/src/Frontend/src/composables/routeMatching.ts b/src/Frontend/src/composables/routeMatching.ts new file mode 100644 index 000000000..9c1ee8d9d --- /dev/null +++ b/src/Frontend/src/composables/routeMatching.ts @@ -0,0 +1,9 @@ +// Normalizes a (method, path) into a comparison key for allowed-route matching. +// Param names are collapsed to {} so matching couples only to method + path STRUCTURE +// (the stable public contract), surviving server route-parameter renames. +export function normalizeRouteKey(method: string, path: string): string { + const normalizedPath = path + .replace(/\{[^}]*\}/g, "{}") + .replace(/^\/?/, "/"); + return `${method.toUpperCase()} ${normalizedPath}`; +} From 0fad7d2e08b63a7195ddb803f02dd55422fe8a7f Mon Sep 17 00:00:00 2001 From: Ramon Smits Date: Thu, 25 Jun 2026 15:39:52 +0200 Subject: [PATCH 03/15] =?UTF-8?q?=E2=9C=A8=20Add=20AllowedRoutesStore=20fe?= =?UTF-8?q?tching=20my/routes=20from=20Primary=20+=20Monitoring?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/monitoring/monitoringClient.ts | 7 ++ .../src/stores/AllowedRoutesStore.spec.ts | 36 ++++++++++ src/Frontend/src/stores/AllowedRoutesStore.ts | 69 +++++++++++++++++++ 3 files changed, 112 insertions(+) create mode 100644 src/Frontend/src/stores/AllowedRoutesStore.spec.ts create mode 100644 src/Frontend/src/stores/AllowedRoutesStore.ts diff --git a/src/Frontend/src/components/monitoring/monitoringClient.ts b/src/Frontend/src/components/monitoring/monitoringClient.ts index 5793d5f09..92c77aa18 100644 --- a/src/Frontend/src/components/monitoring/monitoringClient.ts +++ b/src/Frontend/src/components/monitoring/monitoringClient.ts @@ -91,6 +91,13 @@ class MonitoringClient { return false; } + public async fetchAllowedRoutes() { + if (this.isMonitoringDisabled) { + return undefined; + } + return await authFetch(`${this.url}my/routes`); + } + public get isMonitoringEnabled() { return this.url && this.url !== "!" ? true : false; } diff --git a/src/Frontend/src/stores/AllowedRoutesStore.spec.ts b/src/Frontend/src/stores/AllowedRoutesStore.spec.ts new file mode 100644 index 000000000..834d599b7 --- /dev/null +++ b/src/Frontend/src/stores/AllowedRoutesStore.spec.ts @@ -0,0 +1,36 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { setActivePinia, createPinia } from "pinia"; + +const scFetch = vi.fn(); +const monFetch = vi.fn(); +vi.mock("@/components/serviceControlClient", () => ({ default: { fetchFromServiceControl: (s: string) => scFetch(s) } })); +vi.mock("@/components/monitoring/monitoringClient", () => ({ + default: { get isMonitoringEnabled() { return true; }, fetchAllowedRoutes: () => monFetch() }, +})); + +import { useAllowedRoutesStore } from "@/stores/AllowedRoutesStore"; + +const ok = (body: unknown) => ({ ok: true, status: 200, json: async () => body }); + +describe("AllowedRoutesStore", () => { + beforeEach(() => { setActivePinia(createPinia()); scFetch.mockReset(); monFetch.mockReset(); }); + + it("merges Primary and Monitoring manifests into normalized keys", async () => { + scFetch.mockResolvedValue(ok([{ method: "POST", urlTemplate: "/api/errors/{id}/retry" }])); + monFetch.mockResolvedValue(ok([{ method: "DELETE", urlTemplate: "/api/monitored-instance/{n}/{i}" }])); + const store = useAllowedRoutesStore(); + await store.refresh(); + expect(store.routes.has("POST /api/errors/{}/retry")).toBe(true); + expect(store.routes.has("DELETE /api/monitored-instance/{}/{}")).toBe(true); + expect(store.loaded).toBe(true); + }); + + it("fails open per instance: a 404 from one instance contributes nothing but does not throw", async () => { + scFetch.mockResolvedValue(ok([{ method: "GET", urlTemplate: "/api/errors" }])); + monFetch.mockResolvedValue({ ok: false, status: 404, json: async () => ({}) }); + const store = useAllowedRoutesStore(); + await store.refresh(); + expect(store.routes.has("GET /api/errors")).toBe(true); + expect(store.loadAttempted).toBe(true); + }); +}); diff --git a/src/Frontend/src/stores/AllowedRoutesStore.ts b/src/Frontend/src/stores/AllowedRoutesStore.ts new file mode 100644 index 000000000..e337a3e9b --- /dev/null +++ b/src/Frontend/src/stores/AllowedRoutesStore.ts @@ -0,0 +1,69 @@ +import { acceptHMRUpdate, defineStore } from "pinia"; +import { ref } from "vue"; +import serviceControlClient from "@/components/serviceControlClient"; +import monitoringClient from "@/components/monitoring/monitoringClient"; +import { normalizeRouteKey } from "@/composables/routeMatching"; +import logger from "@/logger"; + +export interface ManifestEntry { method: string; urlTemplate: string; [k: string]: unknown } + +// Holds the allowed-route manifest the current token may call, merged from the instances +// ServicePulse calls directly (Primary + Monitoring). A Map (not a Set) preserves each entry so a +// future per-route `scope` field survives (resource-level checks). Keys are normalizeRouteKey(). +export const useAllowedRoutesStore = defineStore("AllowedRoutesStore", () => { + const routes = ref>(new Map()); + const loaded = ref(false); + const loadAttempted = ref(false); + + // Per-store in-flight guard so concurrent callers share one request. Kept on the store + // (not module scope) so each Pinia instance — e.g. a re-mounted app — has its own, and a + // stale request can never resolve into a different store instance. + let inFlight: Promise | null = null; + + async function fetchInstance(get: () => Promise): Promise { + try { + const response = await get(); + if (!response || !response.ok) return null; // per-instance fail-open + return (await response.json()) as ManifestEntry[]; + } catch (error) { + logger.warn("Failed to fetch allowed routes", error); + return null; + } + } + + async function load() { + try { + const [primary, monitoring] = await Promise.all([ + fetchInstance(() => serviceControlClient.fetchFromServiceControl("my/routes")), + fetchInstance(() => monitoringClient.isMonitoringEnabled ? monitoringClient.fetchAllowedRoutes() : Promise.resolve(undefined)), + ]); + const merged = new Map(); + for (const list of [primary, monitoring]) { + for (const entry of list ?? []) { + merged.set(normalizeRouteKey(entry.method, entry.urlTemplate), entry); + } + } + routes.value = merged; + loaded.value = primary !== null || monitoring !== null; + } finally { + loadAttempted.value = true; + } + } + + // Fetch (or join an in-flight fetch of) the current user's allowed routes. + function refresh() { + return (inFlight ??= load().finally(() => (inFlight = null))); + } + + function clear() { + routes.value = new Map(); + loaded.value = false; + loadAttempted.value = false; + } + + return { routes, loaded, loadAttempted, refresh, clear }; +}); + +if (import.meta.hot) { + import.meta.hot.accept(acceptHMRUpdate(useAllowedRoutesStore, import.meta.hot)); +} From bee86e494ab08a1827801658449068798f4395b6 Mon Sep 17 00:00:00 2001 From: Ramon Smits Date: Thu, 25 Jun 2026 15:44:21 +0200 Subject: [PATCH 04/15] =?UTF-8?q?=E2=9C=A8=20Add=20useAllowedRoutes=20comp?= =?UTF-8?q?osable=20(canCall/canAnyCall)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/composables/useAllowedRoutes.spec.ts | 47 +++++++++++++++++++ .../src/composables/useAllowedRoutes.ts | 32 +++++++++++++ 2 files changed, 79 insertions(+) create mode 100644 src/Frontend/src/composables/useAllowedRoutes.spec.ts create mode 100644 src/Frontend/src/composables/useAllowedRoutes.ts diff --git a/src/Frontend/src/composables/useAllowedRoutes.spec.ts b/src/Frontend/src/composables/useAllowedRoutes.spec.ts new file mode 100644 index 000000000..3224b5aa5 --- /dev/null +++ b/src/Frontend/src/composables/useAllowedRoutes.spec.ts @@ -0,0 +1,47 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { setActivePinia, createPinia } from "pinia"; +import { useAllowedRoutesStore } from "@/stores/AllowedRoutesStore"; +import { useAuthStore } from "@/stores/AuthStore"; +import { useAllowedRoutes } from "@/composables/useAllowedRoutes"; +import { ApiRoutes } from "@/composables/apiRoutes"; + +vi.mock("@/components/serviceControlClient", () => ({ + default: { fetchFromServiceControl: vi.fn(), fetchTypedFromServiceControl: vi.fn() }, +})); +vi.mock("@/components/monitoring/monitoringClient", () => ({ + default: { isMonitoringEnabled: false, fetchAllowedRoutes: vi.fn() }, +})); + +describe("useAllowedRoutes", () => { + beforeEach(() => setActivePinia(createPinia())); + + function arrange(enabled: boolean, authed: boolean, keys: string[]) { + const auth = useAuthStore(); + auth.authEnabled = enabled; + auth.isAuthenticated = authed; + const store = useAllowedRoutesStore(); + store.routes = new Map(keys.map((k) => [k, { method: "", urlTemplate: "" }])); + store.loaded = keys.length > 0; + } + + it("allows a granted route", () => { + arrange(true, true, ["POST /api/errors/retry"]); + expect(useAllowedRoutes().canCall(ApiRoutes.retryMessage)).toBe(true); + }); + it("denies an ungranted route when gating", () => { + arrange(true, true, ["GET /api/errors"]); + expect(useAllowedRoutes().canCall(ApiRoutes.retryMessage)).toBe(false); + }); + it("fails open when auth disabled", () => { + arrange(false, false, []); + expect(useAllowedRoutes().canCall(ApiRoutes.retryMessage)).toBe(true); + }); + it("canCall accepts and ignores a resource argument", () => { + arrange(true, true, ["POST /api/errors/retry"]); + expect(useAllowedRoutes().canCall(ApiRoutes.retryMessage, { queue: "billing" })).toBe(true); + }); + it("canAnyCall is true if any entry is granted", () => { + arrange(true, true, ["GET /api/errors"]); + expect(useAllowedRoutes().canAnyCall([ApiRoutes.retryMessage, ApiRoutes.viewFailedMessages])).toBe(true); + }); +}); diff --git a/src/Frontend/src/composables/useAllowedRoutes.ts b/src/Frontend/src/composables/useAllowedRoutes.ts new file mode 100644 index 000000000..496af6dbc --- /dev/null +++ b/src/Frontend/src/composables/useAllowedRoutes.ts @@ -0,0 +1,32 @@ +import { computed } from "vue"; +import { storeToRefs } from "pinia"; +import { useAllowedRoutesStore } from "@/stores/AllowedRoutesStore"; +import { useAuthStore } from "@/stores/AuthStore"; +import { normalizeRouteKey } from "@/composables/routeMatching"; +import type { RouteRef } from "@/composables/apiRoutes"; + +// Route-aware gating composable (the primitive). The resource-owning view-model calls canCall and +// exposes the result; templates bind. Gating is fail-open: only applies once authorization is enabled, +// the user is authenticated and the manifest has loaded — otherwise everything is shown. +export function useAllowedRoutes() { + const store = useAllowedRoutesStore(); + const { authEnabled, isAuthenticated } = storeToRefs(useAuthStore()); + + const shouldGate = computed(() => authEnabled.value && isAuthenticated.value && store.loaded); + const ready = computed(() => !(authEnabled.value && isAuthenticated.value) || store.loadAttempted); + + function fetchManifest(): Promise { + return store.refresh(); + } + + // resource: reserved for future resource-level scope (ignored today). See design Future-proofing. + function canCall(entry: RouteRef, _resource?: object): boolean { + return !shouldGate.value || store.routes.has(normalizeRouteKey(entry.method, entry.path)); + } + + function canAnyCall(entries: RouteRef[]): boolean { + return entries.some((e) => canCall(e)); + } + + return { fetchManifest, canCall, canAnyCall, shouldGate, ready }; +} From f6dc1d0492cd230077971d535e1b36912850c34a Mon Sep 17 00:00:00 2001 From: Ramon Smits Date: Thu, 25 Jun 2026 15:50:35 +0200 Subject: [PATCH 05/15] =?UTF-8?q?=F0=9F=90=9B=20Guard=20AllowedRoutesStore?= =?UTF-8?q?=20against=20non-array=20fetch=20response;=20add=20fail-open=20?= =?UTF-8?q?tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - fetchInstance() now validates Array.isArray(json) before using the parsed body; a non-array 200 response (error envelope, null, {}) returns null like a network failure instead of propagating into load() and rejecting refresh() - Add test: both instances return non-array 200 → loaded=false - Add test: both instances throw network error → loaded=false (global fail-open) - Fix arrange() in useAllowedRoutes.spec.ts to set loadAttempted=true so future ready-computed tests work correctly by default --- .../src/composables/useAllowedRoutes.spec.ts | 1 + .../src/stores/AllowedRoutesStore.spec.ts | 18 ++++++++++++++++++ src/Frontend/src/stores/AllowedRoutesStore.ts | 4 +++- 3 files changed, 22 insertions(+), 1 deletion(-) diff --git a/src/Frontend/src/composables/useAllowedRoutes.spec.ts b/src/Frontend/src/composables/useAllowedRoutes.spec.ts index 3224b5aa5..9111a9dc7 100644 --- a/src/Frontend/src/composables/useAllowedRoutes.spec.ts +++ b/src/Frontend/src/composables/useAllowedRoutes.spec.ts @@ -22,6 +22,7 @@ describe("useAllowedRoutes", () => { const store = useAllowedRoutesStore(); store.routes = new Map(keys.map((k) => [k, { method: "", urlTemplate: "" }])); store.loaded = keys.length > 0; + store.loadAttempted = true; } it("allows a granted route", () => { diff --git a/src/Frontend/src/stores/AllowedRoutesStore.spec.ts b/src/Frontend/src/stores/AllowedRoutesStore.spec.ts index 834d599b7..55bee5b24 100644 --- a/src/Frontend/src/stores/AllowedRoutesStore.spec.ts +++ b/src/Frontend/src/stores/AllowedRoutesStore.spec.ts @@ -33,4 +33,22 @@ describe("AllowedRoutesStore", () => { expect(store.routes.has("GET /api/errors")).toBe(true); expect(store.loadAttempted).toBe(true); }); + + it("treats a non-array 200 response body as a failed instance: loaded stays false when both return non-array", async () => { + scFetch.mockResolvedValue(ok({})); + monFetch.mockResolvedValue(ok(null)); + const store = useAllowedRoutesStore(); + await store.refresh(); + expect(store.loaded).toBe(false); + expect(store.loadAttempted).toBe(true); + }); + + it("fails open globally: loaded is false when both instances fail with network errors", async () => { + scFetch.mockRejectedValue(new Error("network error")); + monFetch.mockRejectedValue(new Error("network error")); + const store = useAllowedRoutesStore(); + await store.refresh(); + expect(store.loaded).toBe(false); + expect(store.loadAttempted).toBe(true); + }); }); diff --git a/src/Frontend/src/stores/AllowedRoutesStore.ts b/src/Frontend/src/stores/AllowedRoutesStore.ts index e337a3e9b..4e52f1805 100644 --- a/src/Frontend/src/stores/AllowedRoutesStore.ts +++ b/src/Frontend/src/stores/AllowedRoutesStore.ts @@ -24,7 +24,9 @@ export const useAllowedRoutesStore = defineStore("AllowedRoutesStore", () => { try { const response = await get(); if (!response || !response.ok) return null; // per-instance fail-open - return (await response.json()) as ManifestEntry[]; + const json = await response.json(); + if (!Array.isArray(json)) return null; // guard against non-array bodies (error envelopes, etc.) + return json as ManifestEntry[]; } catch (error) { logger.warn("Failed to fetch allowed routes", error); return null; From 0c4f53dbe4a350a183a85433cb131075c420df94 Mon Sep 17 00:00:00 2001 From: Ramon Smits Date: Thu, 25 Jun 2026 15:57:10 +0200 Subject: [PATCH 06/15] =?UTF-8?q?=E2=9C=A8=20Fetch=20my/routes=20at=20star?= =?UTF-8?q?tup=20and=20surface=20403=20denial=20reason?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/Frontend/src/App.vue | 12 ++++----- .../src/composables/useAuthenticatedFetch.ts | 25 +++++++++++++++++-- 2 files changed, 29 insertions(+), 8 deletions(-) diff --git a/src/Frontend/src/App.vue b/src/Frontend/src/App.vue index 5f0f6d49a..ccaa60e86 100644 --- a/src/Frontend/src/App.vue +++ b/src/Frontend/src/App.vue @@ -8,21 +8,21 @@ import LicenseNotifications from "@/components/LicenseNotifications.vue"; import BackendChecksNotifications from "@/components/BackendChecksNotifications.vue"; import { storeToRefs } from "pinia"; import { useAuthStore } from "@/stores/AuthStore"; -import { usePermissions } from "@/composables/usePermissions"; +import { useAllowedRoutes } from "@/composables/useAllowedRoutes"; const authStore = useAuthStore(); const route = useRoute(); const { isAuthenticated, authEnabled } = storeToRefs(authStore); -// Load the user's effective permissions (my/permissions/all) once authenticated, so the -// nav and other UI can gate on them. Fail-safe: a missing/old endpoint just leaves -// permissions unloaded and the UI fails open. -const { fetchDescriptor } = usePermissions(); +// Load the allowed-route manifest (my/routes) once authenticated, so the nav and other +// UI can gate on it. Fail-safe: a missing/old endpoint just leaves the manifest unloaded +// and the UI fails open. +const { fetchManifest } = useAllowedRoutes(); watch( [authEnabled, isAuthenticated], ([enabled, authenticated]) => { if (enabled && authenticated) { - fetchDescriptor(); + fetchManifest(); } }, { immediate: true } diff --git a/src/Frontend/src/composables/useAuthenticatedFetch.ts b/src/Frontend/src/composables/useAuthenticatedFetch.ts index 2fa2d0cfa..220ea4482 100644 --- a/src/Frontend/src/composables/useAuthenticatedFetch.ts +++ b/src/Frontend/src/composables/useAuthenticatedFetch.ts @@ -1,4 +1,6 @@ import { useAuthStore } from "@/stores/AuthStore"; +import { useShowToast } from "@/composables/toast"; +import { TYPE } from "vue-toastification"; const UNAUTHENTICATED_ENDPOINTS = ["/api/authentication/configuration"]; @@ -6,11 +8,24 @@ function isUnauthenticatedEndpoint(url: string): boolean { return UNAUTHENTICATED_ENDPOINTS.some((endpoint) => url.includes(endpoint)); } +async function handle403Toast(response: Response): Promise { + let message = "You do not have permission to perform this action."; + try { + const body = await response.clone().json(); + if (body?.message) message = body.message; + } catch { + // keep default message + } + useShowToast(TYPE.ERROR, "Forbidden", message); +} + /** * Authenticated fetch wrapper that automatically includes JWT token * in the Authorization header when authentication is enabled. + * When a 403 response is received the denial reason from ServiceControl's + * structured body ({ error, message }) is surfaced as an error toast. */ -export function authFetch(input: RequestInfo, init?: RequestInit): Promise { +export async function authFetch(input: RequestInfo, init?: RequestInit): Promise { const authStore = useAuthStore(); const url = typeof input === "string" ? input : input.url; @@ -34,5 +49,11 @@ export function authFetch(input: RequestInfo, init?: RequestInit): Promise Date: Thu, 25 Jun 2026 16:02:37 +0200 Subject: [PATCH 07/15] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Gate=20nav=20and=20c?= =?UTF-8?q?onfiguration=20tabs=20on=20allowed=20routes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/Frontend/src/components/PageHeader.vue | 23 ++++++++++---------- src/Frontend/src/views/ConfigurationView.vue | 23 ++++++++++---------- 2 files changed, 24 insertions(+), 22 deletions(-) diff --git a/src/Frontend/src/components/PageHeader.vue b/src/Frontend/src/components/PageHeader.vue index fdb03e618..f465a267c 100644 --- a/src/Frontend/src/components/PageHeader.vue +++ b/src/Frontend/src/components/PageHeader.vue @@ -15,7 +15,8 @@ import AuditMenuItem from "./audit/AuditMenuItem.vue"; import monitoringClient from "@/components/monitoring/monitoringClient"; import UserProfileMenuItem from "@/components/UserProfileMenuItem.vue"; import { useAuthStore } from "@/stores/AuthStore"; -import { usePermissions } from "@/composables/usePermissions"; +import { useAllowedRoutes } from "@/composables/useAllowedRoutes"; +import { ApiRoutes } from "@/composables/apiRoutes"; import { storeToRefs } from "pinia"; const isMonitoringEnabled = monitoringClient.isMonitoringEnabled; @@ -23,23 +24,23 @@ const isMonitoringEnabled = monitoringClient.isMonitoringEnabled; const authStore = useAuthStore(); const { authEnabled, isAuthenticated } = storeToRefs(authStore); -const { can, ready } = usePermissions(); +const { canCall, ready } = useAllowedRoutes(); -// Each item gates on the specific permission ServiceControl enforces for that area. +// Each item gates on the specific route ServiceControl enforces for that area. // Gate-on-ready: render nothing until permissions are known, so items never appear and -// then disappear. can() fails open, so auth-disabled / failed-load shows everything. +// then disappear. canCall() fails open, so auth-disabled / failed-load shows everything. // prettier-ignore const menuItems = computed(() => { if (!ready.value) return []; return [ DashboardMenuItem, - ...(can("error:heartbeats:view") ? [HeartbeatsMenuItem] : []), - ...(isMonitoringEnabled && can("monitoring:endpoint:view") ? [MonitoringMenuItem] : []), - ...(can("audit:message:view") ? [AuditMenuItem] : []), - ...(can("error:messages:view") ? [FailedMessagesMenuItem] : []), - ...(can("error:customchecks:view") ? [CustomChecksMenuItem] : []), - ...(can("error:eventlog:view") ? [EventsMenuItem] : []), - ...(can("error:throughput:view") ? [ThroughputMenuItem] : []), + ...(canCall(ApiRoutes.viewHeartbeats) ? [HeartbeatsMenuItem] : []), + ...(isMonitoringEnabled && canCall(ApiRoutes.viewMonitoredEndpoints) ? [MonitoringMenuItem] : []), + ...(canCall(ApiRoutes.viewAuditMessages) ? [AuditMenuItem] : []), + ...(canCall(ApiRoutes.viewFailedMessages) ? [FailedMessagesMenuItem] : []), + ...(canCall(ApiRoutes.viewCustomChecks) ? [CustomChecksMenuItem] : []), + ...(canCall(ApiRoutes.viewEventLog) ? [EventsMenuItem] : []), + ...(canCall(ApiRoutes.viewThroughput) ? [ThroughputMenuItem] : []), ConfigurationMenuItem, FeedbackButton, ]; diff --git a/src/Frontend/src/views/ConfigurationView.vue b/src/Frontend/src/views/ConfigurationView.vue index 8525afbfe..f823e23a1 100644 --- a/src/Frontend/src/views/ConfigurationView.vue +++ b/src/Frontend/src/views/ConfigurationView.vue @@ -12,7 +12,8 @@ import useConnectionsAndStatsAutoRefresh from "@/composables/useConnectionsAndSt import { useRedirectsStore } from "@/stores/RedirectsStore"; import { useLicenseStore } from "@/stores/LicenseStore"; import { useAuthStore } from "@/stores/AuthStore"; -import { usePermissions } from "@/composables/usePermissions"; +import { useAllowedRoutes } from "@/composables/useAllowedRoutes"; +import { ApiRoutes } from "@/composables/apiRoutes"; const { store: throughputStore } = useThroughputStoreAutoRefresh(); const { hasErrors } = storeToRefs(throughputStore); @@ -23,9 +24,9 @@ const licenseStore = useLicenseStore(); const { licenseStatus } = licenseStore; const authStore = useAuthStore(); -// Each tab gates on the specific permission ServiceControl enforces for it. Gate-on-ready: +// Each tab gates on the specific route ServiceControl enforces for it. Gate-on-ready: // the tab row is held until permissions are known, so tabs don't appear and then disappear. -const { can, ready } = usePermissions(); +const { canCall, ready } = useAllowedRoutes(); onMounted(async () => { if (notConnected.value) { @@ -61,12 +62,12 @@ function preventIfDisabled(e: Event) {