diff --git a/src/Frontend/src/App.vue b/src/Frontend/src/App.vue
index 20ce9d576..567f2973e 100644
--- a/src/Frontend/src/App.vue
+++ b/src/Frontend/src/App.vue
@@ -9,16 +9,18 @@ import BackendChecksNotifications from "@/components/BackendChecksNotifications.
import { storeToRefs } from "pinia";
import { useAuthStore } from "@/stores/AuthStore";
import { useUserPermissionsStore } from "@/stores/UserPermissionsStore";
+import { useEnvironmentAndVersionsStore } from "@/stores/EnvironmentAndVersionsStore";
const authStore = useAuthStore();
const route = useRoute();
const { isAuthenticated, authEnabled } = storeToRefs(authStore);
const permissionsStore = useUserPermissionsStore();
+const environmentStore = useEnvironmentAndVersionsStore();
watch(
- [authEnabled, isAuthenticated],
- ([enabled, authenticated]) => {
- if (enabled && authenticated) {
+ [authEnabled, isAuthenticated, () => environmentStore.environment.supportsUserPermissions],
+ ([enabled, authenticated, supported]) => {
+ if (enabled && authenticated && supported) {
permissionsStore.refresh();
}
},
diff --git a/src/Frontend/src/components/PageHeader.vue b/src/Frontend/src/components/PageHeader.vue
index 26aa84878..b5c810211 100644
--- a/src/Frontend/src/components/PageHeader.vue
+++ b/src/Frontend/src/components/PageHeader.vue
@@ -15,7 +15,7 @@ import AuditMenuItem from "./audit/AuditMenuItem.vue";
import monitoringClient from "@/components/monitoring/monitoringClient";
import UserProfileMenuItem from "@/components/UserProfileMenuItem.vue";
import { useAuthStore } from "@/stores/AuthStore";
-import { useUserPermissionsStore, type PermissionsSummary } from "@/stores/UserPermissionsStore";
+import usePermissionGate from "@/composables/usePermissionGate";
import { storeToRefs } from "pinia";
const isMonitoringEnabled = monitoringClient.isMonitoringEnabled;
@@ -23,14 +23,7 @@ const isMonitoringEnabled = monitoringClient.isMonitoringEnabled;
const authStore = useAuthStore();
const { authEnabled, isAuthenticated } = storeToRefs(authStore);
-const permissionsStore = useUserPermissionsStore();
-const { summary } = storeToRefs(permissionsStore);
-
-const shouldGate = computed(() => authEnabled.value && isAuthenticated.value && summary.value !== null);
-
-function has(flag: keyof PermissionsSummary): boolean {
- return !shouldGate.value || summary.value?.[flag] === true;
-}
+const { has } = usePermissionGate();
// prettier-ignore
const menuItems = computed(
@@ -42,7 +35,7 @@ const menuItems = computed(
...(has("failed_messages_read") ? [FailedMessagesMenuItem] : []),
...(has("failed_messages_read") ? [CustomChecksMenuItem] : []),
...(has("failed_messages_read") ? [EventsMenuItem] : []),
- ...(has("failed_messages_read") ? [ThroughputMenuItem] : []),
+ ...(has("admin_read") ? [ThroughputMenuItem] : []),
ConfigurationMenuItem,
FeedbackButton,
]);
diff --git a/src/Frontend/src/components/configuration/UserPermissions.vue b/src/Frontend/src/components/configuration/UserPermissions.vue
index 11b542a71..973b7b1cc 100644
--- a/src/Frontend/src/components/configuration/UserPermissions.vue
+++ b/src/Frontend/src/components/configuration/UserPermissions.vue
@@ -33,35 +33,35 @@ onMounted(async () => {
| Failed Messages |
-
+
|
-
+
|
| Auditing |
-
+
|
— |
| Monitoring |
-
+
|
-
+
|
| Admin |
-
+
|
-
+
|
@@ -92,14 +92,6 @@ onMounted(async () => {
padding: 10px 16px;
}
-.icon-granted {
- color: #28a745;
-}
-
-.icon-denied {
- color: #dc3545;
-}
-
.user-label {
color: #666;
margin-bottom: 12px;
diff --git a/src/Frontend/src/components/serviceControlClient.ts b/src/Frontend/src/components/serviceControlClient.ts
index 40ad24c53..9db92f3f7 100644
--- a/src/Frontend/src/components/serviceControlClient.ts
+++ b/src/Frontend/src/components/serviceControlClient.ts
@@ -29,8 +29,13 @@ class ServiceControlClient {
}
}
- public async fetchTypedFromServiceControl(suffix: string, signal?: AbortSignal): Promise<[Response, T]> {
- const response = await authFetch(`${this.url}${suffix}`, { signal });
+ public fetchTypedFromServiceControl(suffix: string, signal?: AbortSignal): Promise<[Response, T]> {
+ return this.fetchTypedFromUrl(`${this.url}${suffix}`, signal);
+ }
+
+ // Fetch from an absolute URL, e.g. one discovered from the ServiceControl root document.
+ public async fetchTypedFromUrl(url: string, signal?: AbortSignal): Promise<[Response, T]> {
+ const response = await authFetch(url, { signal });
if (!response.ok) throw new HttpError(response.status, response.statusText ?? "No response");
const data = await response.json();
diff --git a/src/Frontend/src/composables/usePermissionGate.spec.ts b/src/Frontend/src/composables/usePermissionGate.spec.ts
new file mode 100644
index 000000000..a76ed96c6
--- /dev/null
+++ b/src/Frontend/src/composables/usePermissionGate.spec.ts
@@ -0,0 +1,75 @@
+import { describe, test, expect, beforeEach } from "vitest";
+import { createTestingPinia } from "@pinia/testing";
+import { setActivePinia } from "pinia";
+import usePermissionGate from "@/composables/usePermissionGate";
+import { useAuthStore } from "@/stores/AuthStore";
+import { useUserPermissionsStore, type PermissionsSummary } from "@/stores/UserPermissionsStore";
+
+const summaryWith = (overrides: Partial = {}): PermissionsSummary => ({
+ failed_messages_read: false,
+ failed_messages_write: false,
+ auditing_read: false,
+ monitoring_read: false,
+ monitoring_write: false,
+ admin_read: false,
+ admin_write: false,
+ ...overrides,
+});
+
+describe("usePermissionGate", () => {
+ beforeEach(() => {
+ setActivePinia(createTestingPinia({ stubActions: false }));
+ });
+
+ function withState(opts: { authEnabled: boolean; isAuthenticated: boolean; summary: PermissionsSummary | null }) {
+ const auth = useAuthStore();
+ auth.authEnabled = opts.authEnabled;
+ auth.isAuthenticated = opts.isAuthenticated;
+ useUserPermissionsStore().summary = opts.summary;
+ return usePermissionGate();
+ }
+
+ test("fails open (shows everything) when authorization is disabled", () => {
+ const { has, shouldGate } = withState({ authEnabled: false, isAuthenticated: true, summary: summaryWith() });
+
+ expect(shouldGate.value).toBe(false);
+ expect(has("admin_read")).toBe(true);
+ expect(has("failed_messages_read")).toBe(true);
+ });
+
+ test("fails open when the user is not authenticated", () => {
+ const { has, shouldGate } = withState({ authEnabled: true, isAuthenticated: false, summary: summaryWith() });
+
+ expect(shouldGate.value).toBe(false);
+ expect(has("admin_read")).toBe(true);
+ });
+
+ test("fails open while the permission summary has not loaded yet", () => {
+ const { has, shouldGate } = withState({ authEnabled: true, isAuthenticated: true, summary: null });
+
+ expect(shouldGate.value).toBe(false);
+ expect(has("admin_read")).toBe(true);
+ });
+
+ test("gates per flag once enabled, authenticated and loaded", () => {
+ const { has, shouldGate } = withState({ authEnabled: true, isAuthenticated: true, summary: summaryWith({ admin_read: true }) });
+
+ expect(shouldGate.value).toBe(true);
+ expect(has("admin_read")).toBe(true);
+ expect(has("failed_messages_read")).toBe(false);
+ });
+
+ test("reacts to the summary being updated", () => {
+ const auth = useAuthStore();
+ auth.authEnabled = true;
+ auth.isAuthenticated = true;
+ const permissions = useUserPermissionsStore();
+ permissions.summary = summaryWith();
+
+ const { has } = usePermissionGate();
+ expect(has("monitoring_read")).toBe(false);
+
+ permissions.summary = summaryWith({ monitoring_read: true });
+ expect(has("monitoring_read")).toBe(true);
+ });
+});
diff --git a/src/Frontend/src/composables/usePermissionGate.ts b/src/Frontend/src/composables/usePermissionGate.ts
new file mode 100644
index 000000000..516a3abee
--- /dev/null
+++ b/src/Frontend/src/composables/usePermissionGate.ts
@@ -0,0 +1,22 @@
+import { computed } from "vue";
+import { storeToRefs } from "pinia";
+import { useAuthStore } from "@/stores/AuthStore";
+import { useUserPermissionsStore, type PermissionsSummary } from "@/stores/UserPermissionsStore";
+
+// Centralises the "should this UI element be shown for the current user" decision.
+// Gating only kicks in once authorization is enabled, the user is authenticated and
+// the permission summary has loaded; otherwise everything is shown (fail-open) so the
+// UI is unchanged for unauthenticated/older-ServiceControl setups. Server-side checks
+// remain the real enforcement — this only hides UI the user cannot use anyway.
+export default function usePermissionGate() {
+ const { authEnabled, isAuthenticated } = storeToRefs(useAuthStore());
+ const { summary } = storeToRefs(useUserPermissionsStore());
+
+ const shouldGate = computed(() => authEnabled.value && isAuthenticated.value && summary.value !== null);
+
+ function has(flag: keyof PermissionsSummary): boolean {
+ return !shouldGate.value || summary.value?.[flag] === true;
+ }
+
+ return { has, shouldGate };
+}
diff --git a/src/Frontend/src/stores/EnvironmentAndVersionsStore.ts b/src/Frontend/src/stores/EnvironmentAndVersionsStore.ts
index 3e797116c..1ce6b4f09 100644
--- a/src/Frontend/src/stores/EnvironmentAndVersionsStore.ts
+++ b/src/Frontend/src/stores/EnvironmentAndVersionsStore.ts
@@ -18,6 +18,8 @@ export const useEnvironmentAndVersionsStore = defineStore("EnvironmentAndVersion
sp_version: window.defaultConfig && window.defaultConfig.version ? window.defaultConfig.version : "1.2.0",
supportsArchiveGroups: false,
supportsUserPermissions: false,
+ mypermissions_all_url: "",
+ mypermissions_summary_url: "",
endpoints_error_url: "",
known_endpoints_url: "",
endpoints_message_search_url: "",
@@ -58,6 +60,8 @@ export const useEnvironmentAndVersionsStore = defineStore("EnvironmentAndVersion
if (scVer) {
environment.supportsArchiveGroups = !!scVer.archived_groups_url;
environment.supportsUserPermissions = !!scVer.mypermissions_all && !!scVer.mypermissions_summary;
+ environment.mypermissions_all_url = scVer.mypermissions_all ?? "";
+ environment.mypermissions_summary_url = scVer.mypermissions_summary ?? "";
environment.is_compatible_with_sc = isSupported(environment.sc_version, environment.minimum_supported_sc_version);
environment.endpoints_error_url = scVer && scVer.endpoints_error_url;
environment.known_endpoints_url = scVer && scVer.known_endpoints_url;
diff --git a/src/Frontend/src/stores/UserPermissionsStore.spec.ts b/src/Frontend/src/stores/UserPermissionsStore.spec.ts
new file mode 100644
index 000000000..e73270c92
--- /dev/null
+++ b/src/Frontend/src/stores/UserPermissionsStore.spec.ts
@@ -0,0 +1,89 @@
+import { describe, test, expect, beforeEach, vi } from "vitest";
+import { createTestingPinia } from "@pinia/testing";
+import { setActivePinia } from "pinia";
+
+vi.mock("@/components/serviceControlClient", () => ({
+ default: { fetchTypedFromUrl: vi.fn() },
+}));
+
+import serviceControlClient from "@/components/serviceControlClient";
+import logger from "@/logger";
+import { useUserPermissionsStore } from "@/stores/UserPermissionsStore";
+import { useEnvironmentAndVersionsStore } from "@/stores/EnvironmentAndVersionsStore";
+
+const summaryUrl = "http://localhost/my/permissions";
+const allUrl = "http://localhost/my/permissions/all";
+
+const fetchTypedFromUrl = vi.mocked(serviceControlClient.fetchTypedFromUrl);
+
+const summaryData = { failed_messages_read: true, failed_messages_write: false, auditing_read: true, monitoring_read: false, monitoring_write: false, admin_read: false, admin_write: false };
+const descriptorData = { user: "alice", permissions: ["error:messages:view", "audit:view"] };
+
+function ok(data: T): [Response, T] {
+ return [{} as Response, data];
+}
+
+function setup() {
+ setActivePinia(createTestingPinia({ stubActions: false }));
+ const environment = useEnvironmentAndVersionsStore().environment;
+ environment.mypermissions_summary_url = summaryUrl;
+ environment.mypermissions_all_url = allUrl;
+ return useUserPermissionsStore();
+}
+
+describe("UserPermissionsStore", () => {
+ beforeEach(() => {
+ fetchTypedFromUrl.mockReset();
+ });
+
+ test("refresh fetches the discovered URLs and populates summary and descriptor", async () => {
+ const store = setup();
+ fetchTypedFromUrl.mockImplementation((url: string) => Promise.resolve(url === summaryUrl ? ok(summaryData) : ok(descriptorData)));
+
+ await store.refresh();
+
+ expect(fetchTypedFromUrl).toHaveBeenCalledWith(summaryUrl);
+ expect(fetchTypedFromUrl).toHaveBeenCalledWith(allUrl);
+ expect(store.summary).toEqual(summaryData);
+ expect(store.descriptor).toEqual(descriptorData);
+ expect(store.error).toBeNull();
+ expect(store.loading).toBe(false);
+ });
+
+ test("refresh records an error and logs when a request fails", async () => {
+ const store = setup();
+ const loggerError = vi.spyOn(logger, "error").mockImplementation(() => {});
+ const failure = new Error("boom");
+ fetchTypedFromUrl.mockRejectedValue(failure);
+
+ await store.refresh();
+
+ expect(store.summary).toBeNull();
+ expect(store.error).toBe("Failed to load user permissions");
+ expect(store.loading).toBe(false);
+ expect(loggerError).toHaveBeenCalledWith("Failed to load user permissions", failure);
+ loggerError.mockRestore();
+ });
+
+ test("concurrent refreshes share a single in-flight request", async () => {
+ const store = setup();
+ let resolveSummary: (value: [Response, typeof summaryData]) => void = () => {};
+ const pendingSummary = new Promise<[Response, typeof summaryData]>((resolve) => (resolveSummary = resolve));
+ fetchTypedFromUrl.mockImplementation((url: string) => (url === summaryUrl ? pendingSummary : Promise.resolve(ok(descriptorData))));
+
+ const first = store.refresh();
+ const second = store.refresh();
+
+ // One load in flight => two endpoint calls (summary + descriptor), not four.
+ expect(fetchTypedFromUrl).toHaveBeenCalledTimes(2);
+
+ resolveSummary(ok(summaryData));
+ await Promise.all([first, second]);
+
+ expect(store.summary).toEqual(summaryData);
+
+ // Once settled the in-flight slot is released, so a later refresh fetches again.
+ await store.refresh();
+ expect(fetchTypedFromUrl).toHaveBeenCalledTimes(4);
+ });
+});
diff --git a/src/Frontend/src/stores/UserPermissionsStore.ts b/src/Frontend/src/stores/UserPermissionsStore.ts
index fd3c1f41e..434adaa32 100644
--- a/src/Frontend/src/stores/UserPermissionsStore.ts
+++ b/src/Frontend/src/stores/UserPermissionsStore.ts
@@ -1,6 +1,8 @@
import { acceptHMRUpdate, defineStore } from "pinia";
import { ref } from "vue";
import serviceControlClient from "@/components/serviceControlClient";
+import { useEnvironmentAndVersionsStore } from "@/stores/EnvironmentAndVersionsStore";
+import logger from "@/logger";
interface PermissionsSummary {
failed_messages_read: boolean;
@@ -23,17 +25,28 @@ export const useUserPermissionsStore = defineStore("UserPermissionsStore", () =>
const loading = ref(false);
const error = ref(null);
- async function refresh() {
+ // Multiple callers can request a refresh in the same tick (the App-level watch
+ // and the User Permissions view's onMounted). Share a single in-flight request
+ // so they don't trigger duplicate fetches.
+ let inFlight: Promise | null = null;
+
+ function refresh() {
+ return (inFlight ??= load().finally(() => (inFlight = null)));
+ }
+
+ async function load() {
loading.value = true;
error.value = null;
+ const { environment } = useEnvironmentAndVersionsStore();
try {
const [summaryResult, descriptorResult] = await Promise.all([
- serviceControlClient.fetchTypedFromServiceControl("my/permissions"),
- serviceControlClient.fetchTypedFromServiceControl("my/permissions/all"),
+ serviceControlClient.fetchTypedFromUrl(environment.mypermissions_summary_url),
+ serviceControlClient.fetchTypedFromUrl(environment.mypermissions_all_url),
]);
summary.value = summaryResult[1];
descriptor.value = descriptorResult[1];
- } catch {
+ } catch (err) {
+ logger.error("Failed to load user permissions", err);
error.value = "Failed to load user permissions";
} finally {
loading.value = false;
diff --git a/src/Frontend/src/views/ConfigurationView.vue b/src/Frontend/src/views/ConfigurationView.vue
index 78dba05aa..0dd4f7ea6 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 { useUserPermissionsStore } from "@/stores/UserPermissionsStore";
+import usePermissionGate from "@/composables/usePermissionGate";
+import { useEnvironmentAndVersionsStore } from "@/stores/EnvironmentAndVersionsStore";
const { store: throughputStore } = useThroughputStoreAutoRefresh();
const { hasErrors } = storeToRefs(throughputStore);
@@ -22,11 +23,10 @@ const redirectsStore = useRedirectsStore();
const licenseStore = useLicenseStore();
const { licenseStatus } = licenseStore;
const authStore = useAuthStore();
+const environmentStore = useEnvironmentAndVersionsStore();
-const permissionsStore = useUserPermissionsStore();
-const { summary: permSummary } = storeToRefs(permissionsStore);
-const shouldGateConfig = computed(() => authStore.authEnabled && permSummary.value !== null);
-const hasAdminRead = computed(() => !shouldGateConfig.value || permSummary.value?.admin_read === true);
+const { has } = usePermissionGate();
+const hasAdminRead = computed(() => has("admin_read"));
onMounted(async () => {
if (notConnected.value) {
@@ -151,7 +151,14 @@ function preventIfDisabled(e: Event) {
-
+
User Permissions