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/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/components/configuration/HealthCheckNotifications.vue b/src/Frontend/src/components/configuration/HealthCheckNotifications.vue index 56b5274dc..a79564e7f 100644 --- a/src/Frontend/src/components/configuration/HealthCheckNotifications.vue +++ b/src/Frontend/src/components/configuration/HealthCheckNotifications.vue @@ -1,5 +1,5 @@ + diff --git a/src/Frontend/src/components/customchecks/CustomCheckView.vue b/src/Frontend/src/components/customchecks/CustomCheckView.vue index 14a3ac0b8..038576823 100644 --- a/src/Frontend/src/components/customchecks/CustomCheckView.vue +++ b/src/Frontend/src/components/customchecks/CustomCheckView.vue @@ -8,15 +8,16 @@ import { faCheck, faList, faServer } from "@fortawesome/free-solid-svg-icons"; import { faClock } from "@fortawesome/free-regular-svg-icons"; import { hexToCSSFilter } from "hex-to-css-filter"; import useCustomChecksStoreAutoRefresh from "@/composables/useCustomChecksStoreAutoRefresh"; -import { usePermissions } from "@/composables/usePermissions"; +import { useAllowedRoutes } from "@/composables/useAllowedRoutes"; +import { ApiRoutes } from "@/composables/apiRoutes"; -defineProps<{ customCheck: CustomCheck }>(); +const props = defineProps<{ customCheck: CustomCheck }>(); const { store } = useCustomChecksStoreAutoRefresh(); const endpointColor = hexToCSSFilter("#929E9E").filter; -const { can } = usePermissions(); -const canDismiss = computed(() => can("error:customchecks:delete")); +const { canCall } = useAllowedRoutes(); +const canDismiss = computed(() => canCall(ApiRoutes.dismissCustomCheck, props.customCheck)); const dismissDeniedTooltip = "You don't have permission to dismiss custom checks."; diff --git a/src/Frontend/src/components/failedmessages/DeletedMessageGroups.vue b/src/Frontend/src/components/failedmessages/DeletedMessageGroups.vue index d78da3709..4d0a31355 100644 --- a/src/Frontend/src/components/failedmessages/DeletedMessageGroups.vue +++ b/src/Frontend/src/components/failedmessages/DeletedMessageGroups.vue @@ -12,7 +12,8 @@ import { TYPE } from "vue-toastification"; import MetadataItem from "@/components/MetadataItem.vue"; import ActionButton from "@/components/ActionButton.vue"; import PermissionGate from "@/components/PermissionGate.vue"; -import { usePermissions } from "@/composables/usePermissions"; +import { useAllowedRoutes } from "@/composables/useAllowedRoutes"; +import { ApiRoutes } from "@/composables/apiRoutes"; import { faArrowRotateRight, faEnvelope } from "@fortawesome/free-solid-svg-icons"; import { faClock } from "@fortawesome/free-regular-svg-icons"; import { useDeletedMessageGroupsStore, statusesForRestoreOperation, type ExtendedFailureGroupView, type Status } from "@/stores/DeletedMessageGroupsStore"; @@ -28,10 +29,10 @@ const { store } = autoRefresh(); const { archiveGroups, classifiers, selectedClassifier } = storeToRefs(store); const router = useRouter(); -const { can } = usePermissions(); +const { canCall } = useAllowedRoutes(); // Restoring a deleted group is an unarchive; keep the button visible but disabled with a // tooltip when the user lacks the permission, instead of silently failing server-side. -const canRestoreGroups = computed(() => can("error:recoverabilitygroups:unarchive")); +const canRestoreGroups = computed(() => canCall(ApiRoutes.restoreGroup)); const restoreDeniedTooltip = "You don't have permission to restore message groups."; const showRestoreGroupModal = ref(false); diff --git a/src/Frontend/src/components/failedmessages/DeletedMessages.vue b/src/Frontend/src/components/failedmessages/DeletedMessages.vue index 59060ccdd..11bc5ae1b 100644 --- a/src/Frontend/src/components/failedmessages/DeletedMessages.vue +++ b/src/Frontend/src/components/failedmessages/DeletedMessages.vue @@ -11,7 +11,8 @@ import { FailedMessageStatus } from "@/resources/FailedMessage"; import { TYPE } from "vue-toastification"; import FAIcon from "@/components/FAIcon.vue"; import PermissionGate from "@/components/PermissionGate.vue"; -import { usePermissions } from "@/composables/usePermissions"; +import { useAllowedRoutes } from "@/composables/useAllowedRoutes"; +import { ApiRoutes } from "@/composables/apiRoutes"; import { faArrowRotateRight } from "@fortawesome/free-solid-svg-icons"; import { storeToRefs } from "pinia"; import { useStoreAutoRefresh } from "@/composables/useAutoRefresh"; @@ -29,10 +30,10 @@ const { messages, groupId, groupName, totalCount, pageNumber, selectedPeriod } = const showConfirmRestore = ref(false); const messageList = ref(); -const { can } = usePermissions(); +const { canCall } = useAllowedRoutes(); // Restoring messages is an unarchive; keep the button visible but disabled with a tooltip // when the user lacks the permission, instead of silently failing server-side. -const canRestoreMessages = computed(() => can("error:messages:unarchive")); +const canRestoreMessages = computed(() => canCall(ApiRoutes.restoreMessage)); const restoreDeniedTooltip = "You don't have permission to restore messages."; function numberSelected() { diff --git a/src/Frontend/src/components/failedmessages/FailedMessages.vue b/src/Frontend/src/components/failedmessages/FailedMessages.vue index 1a38bf666..1277f865b 100644 --- a/src/Frontend/src/components/failedmessages/FailedMessages.vue +++ b/src/Frontend/src/components/failedmessages/FailedMessages.vue @@ -17,7 +17,8 @@ import type GroupOperation from "@/resources/GroupOperation"; import { faArrowDownAZ, faArrowDownZA, faArrowDownShortWide, faArrowDownWideShort, faArrowRotateRight, faTrash, faDownload } from "@fortawesome/free-solid-svg-icons"; import ActionButton from "@/components/ActionButton.vue"; import PermissionGate from "@/components/PermissionGate.vue"; -import { usePermissions } from "@/composables/usePermissions"; +import { useAllowedRoutes } from "@/composables/useAllowedRoutes"; +import { ApiRoutes } from "@/composables/apiRoutes"; import { useMessageStore } from "@/stores/MessageStore"; import { useRecoverabilityStore } from "@/stores/RecoverabilityStore"; import { useStoreAutoRefresh } from "@/composables/useAutoRefresh"; @@ -34,13 +35,13 @@ const { autoRefresh, isRefreshing, updateInterval } = useStoreAutoRefresh("recov const { store } = autoRefresh(); const { messages, groupId, groupName, totalCount, pageNumber } = storeToRefs(store); -const { can } = usePermissions(); +const { canCall } = useAllowedRoutes(); // Keep the toolbar actions visible but disabled (with a tooltip) when the user lacks the // permission, so the capability stays discoverable and clicks don't silently fail server-side. -const canRetryMessages = computed(() => can("error:messages:retry")); -const canDeleteMessages = computed(() => can("error:messages:archive")); -const canRetryGroup = computed(() => can("error:recoverabilitygroups:retry")); -const canDeleteGroup = computed(() => can("error:recoverabilitygroups:archive")); +const canRetryMessages = computed(() => canCall(ApiRoutes.retryMessage)); +const canDeleteMessages = computed(() => canCall(ApiRoutes.deleteMessage)); +const canRetryGroup = computed(() => canCall(ApiRoutes.retryGroup)); +const canDeleteGroup = computed(() => canCall(ApiRoutes.deleteGroup)); const retryDeniedTooltip = "You don't have permission to retry messages."; const deleteDeniedTooltip = "You don't have permission to delete messages."; const retryAllDeniedTooltip = "You don't have permission to retry message groups."; diff --git a/src/Frontend/src/components/failedmessages/MessageGroupList.vue b/src/Frontend/src/components/failedmessages/MessageGroupList.vue index fcbc60572..977bbaf75 100644 --- a/src/Frontend/src/components/failedmessages/MessageGroupList.vue +++ b/src/Frontend/src/components/failedmessages/MessageGroupList.vue @@ -16,13 +16,14 @@ import { faClock } from "@fortawesome/free-regular-svg-icons"; import ProgressMessage from "./ProgressMessage.vue"; import ActionButton from "@/components/ActionButton.vue"; import PermissionGate from "@/components/PermissionGate.vue"; -import { usePermissions } from "@/composables/usePermissions"; +import { useAllowedRoutes } from "@/composables/useAllowedRoutes"; +import { ApiRoutes } from "@/composables/apiRoutes"; -const { can } = usePermissions(); +const { canCall } = useAllowedRoutes(); // Group-level actions are gated on the recoverabilitygroups permissions. When the user // lacks them the buttons stay visible but disabled, with a tooltip explaining why. -const canRetryGroups = computed(() => can("error:recoverabilitygroups:retry")); -const canDeleteGroups = computed(() => can("error:recoverabilitygroups:archive")); +const canRetryGroups = computed(() => canCall(ApiRoutes.retryGroup)); +const canDeleteGroups = computed(() => canCall(ApiRoutes.deleteGroup)); const retryDeniedTooltip = "You don't have permission to request a retry for message groups."; const deleteDeniedTooltip = "You don't have permission to delete message groups."; diff --git a/src/Frontend/src/components/failedmessages/messageGroupButtonPermissions.spec.ts b/src/Frontend/src/components/failedmessages/messageGroupButtonPermissions.spec.ts index 3f2741f30..55f02bcfa 100644 --- a/src/Frontend/src/components/failedmessages/messageGroupButtonPermissions.spec.ts +++ b/src/Frontend/src/components/failedmessages/messageGroupButtonPermissions.spec.ts @@ -5,12 +5,15 @@ import { defineComponent, h, nextTick, ref } from "vue"; import { createRouter, createMemoryHistory } from "vue-router"; import type GroupOperation from "@/resources/GroupOperation"; import MessageGroupList, { type IMessageGroupList } from "@/components/failedmessages/MessageGroupList.vue"; +import type { ManifestEntry } from "@/stores/AllowedRoutesStore"; +import { ApiRoutes } from "@/composables/apiRoutes"; +import { normalizeRouteKey } from "@/composables/routeMatching"; -// The group-level Retry/Delete actions are gated on the recoverabilitygroups permissions. +// The group-level Retry/Delete actions are gated on the recoverabilitygroups allowed routes. // Unlike the per-message buttons (which hide), these stay VISIBLE but DISABLED when the -// permission is missing, with a tooltip explaining why, so it's clear the action exists. -const RETRY_PERMISSION = "error:recoverabilitygroups:retry"; -const DELETE_PERMISSION = "error:recoverabilitygroups:archive"; +// route is missing, with a tooltip explaining why, so it's clear the action exists. +const RETRY_ROUTE_KEY = normalizeRouteKey(ApiRoutes.retryGroup.method, ApiRoutes.retryGroup.path); +const DELETE_ROUTE_KEY = normalizeRouteKey(ApiRoutes.deleteGroup.method, ApiRoutes.deleteGroup.path); const group: GroupOperation = { id: "group-1", @@ -30,7 +33,11 @@ vi.mock("@/components/failedmessages/messageGroupClient", () => ({ }), })); -async function renderGroupList(permissions: string[]) { +function makeRoutes(keys: string[]): Map { + return new Map(keys.map((k) => [k, { method: "", url_template: "" }])); +} + +async function renderGroupList(allowedRouteKeys: string[]) { const listRef = ref(); const Harness = defineComponent({ @@ -49,7 +56,7 @@ async function renderGroupList(permissions: string[]) { stubActions: true, initialState: { auth: { authEnabled: true, isAuthenticated: true }, - PermissionsStore: { permissions: new Set(permissions), loaded: true, loadAttempted: true }, + AllowedRoutesStore: { routes: makeRoutes(allowedRouteKeys), loaded: true, loadAttempted: true }, }, }), ], @@ -72,25 +79,25 @@ describe("failed-message group action button permissions", () => { document.body.innerHTML = '
'; }); - test("Request retry is enabled when its permission is granted", async () => { - await renderGroupList([RETRY_PERMISSION, DELETE_PERMISSION]); + test("Request retry is enabled when its route is allowed", async () => { + await renderGroupList([RETRY_ROUTE_KEY, DELETE_ROUTE_KEY]); expect(button(/Request retry/i)?.disabled).toBe(false); }); - test("Request retry stays visible but disabled when its permission is denied", async () => { - await renderGroupList([DELETE_PERMISSION]); + test("Request retry stays visible but disabled when its route is not allowed", async () => { + await renderGroupList([DELETE_ROUTE_KEY]); const retry = button(/Request retry/i); expect(retry).not.toBeNull(); expect(retry?.disabled).toBe(true); }); - test("Delete group is enabled when its permission is granted", async () => { - await renderGroupList([RETRY_PERMISSION, DELETE_PERMISSION]); + test("Delete group is enabled when its route is allowed", async () => { + await renderGroupList([RETRY_ROUTE_KEY, DELETE_ROUTE_KEY]); expect(button(/Delete group/i)?.disabled).toBe(false); }); - test("Delete group stays visible but disabled when its permission is denied", async () => { - await renderGroupList([RETRY_PERMISSION]); + test("Delete group stays visible but disabled when its route is not allowed", async () => { + await renderGroupList([RETRY_ROUTE_KEY]); const del = button(/Delete group/i); expect(del).not.toBeNull(); expect(del?.disabled).toBe(true); diff --git a/src/Frontend/src/components/heartbeats/EndpointInstances.vue b/src/Frontend/src/components/heartbeats/EndpointInstances.vue index 727efb004..421b64af1 100644 --- a/src/Frontend/src/components/heartbeats/EndpointInstances.vue +++ b/src/Frontend/src/components/heartbeats/EndpointInstances.vue @@ -22,7 +22,6 @@ import FAIcon from "@/components/FAIcon.vue"; import PermissionGate from "@/components/PermissionGate.vue"; import useHeartbeatInstancesStoreAutoRefresh from "@/composables/useHeartbeatInstancesStoreAutoRefresh"; import { useEndpointSettingsStore } from "@/stores/EndpointSettingsStore"; -import { usePermissions } from "@/composables/usePermissions"; enum Operation { Mute = "mute", @@ -32,12 +31,10 @@ enum Operation { const route = useRoute(); const router = useRouter(); -const { can } = usePermissions(); -const canDeleteEndpoints = computed(() => can("error:endpoints:delete")); const deleteDeniedTooltip = "You don't have permission to delete endpoints."; const endpointName = route.params.endpointName.toString(); const { store } = useHeartbeatInstancesStoreAutoRefresh(); -const { filteredInstances, sortedInstances, instanceFilterString, sortByInstances } = storeToRefs(store); +const { filteredInstances, sortedInstances, instanceFilterString, sortByInstances, canDeleteEndpointInstance } = storeToRefs(store); const endpointSettingsStore = useEndpointSettingsStore(); const endpointSettings = ref([endpointSettingsStore.defaultEndpointSettingsValue]); @@ -176,8 +173,8 @@ async function toggleAlerts(instance: EndpointsView) {
You may - - + + this endpoint
@@ -203,8 +200,8 @@ async function toggleAlerts(instance: EndpointsView) {
- - + +
diff --git a/src/Frontend/src/components/messages/DeleteMessageButton.vue b/src/Frontend/src/components/messages/DeleteMessageButton.vue index 287985b0a..1dc9830c1 100644 --- a/src/Frontend/src/components/messages/DeleteMessageButton.vue +++ b/src/Frontend/src/components/messages/DeleteMessageButton.vue @@ -9,16 +9,14 @@ import { MessageStatus } from "@/resources/Message"; import { storeToRefs } from "pinia"; import { FailedMessageStatus } from "@/resources/FailedMessage"; import { faTrash } from "@fortawesome/free-solid-svg-icons"; -import { usePermissions } from "@/composables/usePermissions"; const store = useMessageStore(); -const { state } = storeToRefs(store); -const { can } = usePermissions(); +const { state, canDelete } = storeToRefs(store); const isConfirmDialogVisible = ref(false); const failureStatus = computed(() => state.value.data.failure_status); const isDisabled = computed(() => failureStatus.value.retried || failureStatus.value.resolved); -const isVisible = computed(() => can("error:messages:archive") && !failureStatus.value.archived && state.value.data.status !== MessageStatus.Successful && state.value.data.status !== MessageStatus.ResolvedSuccessfully); +const isVisible = computed(() => canDelete.value && !failureStatus.value.archived && state.value.data.status !== MessageStatus.Successful && state.value.data.status !== MessageStatus.ResolvedSuccessfully); const handleConfirm = async () => { isConfirmDialogVisible.value = false; diff --git a/src/Frontend/src/components/messages/EditAndRetryButton.vue b/src/Frontend/src/components/messages/EditAndRetryButton.vue index add6c8ddc..54ac1c653 100644 --- a/src/Frontend/src/components/messages/EditAndRetryButton.vue +++ b/src/Frontend/src/components/messages/EditAndRetryButton.vue @@ -10,19 +10,16 @@ import { MessageStatus } from "@/resources/Message"; import { storeToRefs } from "pinia"; import { FailedMessageStatus } from "@/resources/FailedMessage"; import { faPencil } from "@fortawesome/free-solid-svg-icons"; -import { usePermissions } from "@/composables/usePermissions"; import PermissionGate from "@/components/PermissionGate.vue"; const store = useMessageStore(); -const { state, edit_and_retry_config, editRetryResponse } = storeToRefs(store); -const { can } = usePermissions(); +const { state, edit_and_retry_config, editRetryResponse, canEdit } = storeToRefs(store); const isConfirmDialogVisible = ref(false); const isEditIgnoredDialogVisible = ref(false); // Edit & retry is enabled by a ServiceControl setting (edit_and_retry_config). When it is on, // the button is shown to everyone but disabled (with a tooltip) for users without the edit // permission, rather than hidden. -const canEdit = computed(() => can("error:messages:edit")); const editDeniedTooltip = "You don't have permission to edit and retry messages."; const failureStatus = computed(() => state.value.data.failure_status); diff --git a/src/Frontend/src/components/messages/RestoreMessageButton.vue b/src/Frontend/src/components/messages/RestoreMessageButton.vue index 36969f724..407098b86 100644 --- a/src/Frontend/src/components/messages/RestoreMessageButton.vue +++ b/src/Frontend/src/components/messages/RestoreMessageButton.vue @@ -8,14 +8,12 @@ import { TYPE } from "vue-toastification"; import { storeToRefs } from "pinia"; import { FailedMessageStatus } from "@/resources/FailedMessage"; import { faUndo } from "@fortawesome/free-solid-svg-icons"; -import { usePermissions } from "@/composables/usePermissions"; const store = useMessageStore(); -const { state } = storeToRefs(store); -const { can } = usePermissions(); +const { state, canRestore } = storeToRefs(store); const isConfirmDialogVisible = ref(false); -const isVisible = computed(() => can("error:messages:unarchive") && state.value.data.failure_status.archived); +const isVisible = computed(() => canRestore.value && state.value.data.failure_status.archived); const handleConfirm = async () => { isConfirmDialogVisible.value = false; diff --git a/src/Frontend/src/components/messages/RetryMessageButton.vue b/src/Frontend/src/components/messages/RetryMessageButton.vue index dee67f722..534bf4515 100644 --- a/src/Frontend/src/components/messages/RetryMessageButton.vue +++ b/src/Frontend/src/components/messages/RetryMessageButton.vue @@ -9,16 +9,14 @@ import { MessageStatus } from "@/resources/Message"; import { storeToRefs } from "pinia"; import { FailedMessageStatus } from "@/resources/FailedMessage"; import { faRefresh } from "@fortawesome/free-solid-svg-icons"; -import { usePermissions } from "@/composables/usePermissions"; const store = useMessageStore(); -const { state } = storeToRefs(store); -const { can } = usePermissions(); +const { state, canRetry } = storeToRefs(store); const isConfirmDialogVisible = ref(false); const failureStatus = computed(() => state.value.data.failure_status); const isDisabled = computed(() => failureStatus.value.retried || failureStatus.value.archived || failureStatus.value.resolved); -const isVisible = computed(() => can("error:messages:retry") && state.value.data.status !== MessageStatus.Successful && state.value.data.status !== MessageStatus.ResolvedSuccessfully); +const isVisible = computed(() => canRetry.value && state.value.data.status !== MessageStatus.Successful && state.value.data.status !== MessageStatus.ResolvedSuccessfully); const handleConfirm = async () => { isConfirmDialogVisible.value = false; diff --git a/src/Frontend/src/components/messages/messageButtonPermissions.spec.ts b/src/Frontend/src/components/messages/messageButtonPermissions.spec.ts index 5ed2a2a7d..26c3fa017 100644 --- a/src/Frontend/src/components/messages/messageButtonPermissions.spec.ts +++ b/src/Frontend/src/components/messages/messageButtonPermissions.spec.ts @@ -7,28 +7,33 @@ import RetryMessageButton from "@/components/messages/RetryMessageButton.vue"; import EditAndRetryButton from "@/components/messages/EditAndRetryButton.vue"; import DeleteMessageButton from "@/components/messages/DeleteMessageButton.vue"; import RestoreMessageButton from "@/components/messages/RestoreMessageButton.vue"; +import type { ManifestEntry } from "@/stores/AllowedRoutesStore"; -// Each failed-message action button is gated on its own ServiceControl permission. These -// tests pin the per-action gating: with auth enabled + the descriptor loaded, the button -// shows only when the matching permission is granted. +// Each failed-message action button is gated on its own ServiceControl route. These +// tests pin the per-action gating: with auth enabled + the manifest loaded, the button +// shows only when the matching route is in the allowed set. interface ButtonCase { name: string; component: Component; - permission: string; + routeKey: string; // normalized route key in AllowedRoutesStore text: RegExp; archived: boolean; // Restore is only relevant on an archived message; the others on a live one } -// Retry / Delete / Restore are hidden when the permission is denied. Edit & retry is handled +// Retry / Delete / Restore are hidden when the route is not allowed. Edit & retry is handled // separately below: it is enabled by a setting and, when on, disabled (not hidden) without // the permission. const cases: ButtonCase[] = [ - { name: "Retry", component: RetryMessageButton, permission: "error:messages:retry", text: /Retry message/i, archived: false }, - { name: "Delete", component: DeleteMessageButton, permission: "error:messages:archive", text: /Delete message/i, archived: false }, - { name: "Restore", component: RestoreMessageButton, permission: "error:messages:unarchive", text: /Restore/i, archived: true }, + { name: "Retry", component: RetryMessageButton, routeKey: "POST /api/errors/retry", text: /Retry message/i, archived: false }, + { name: "Delete", component: DeleteMessageButton, routeKey: "PATCH /api/errors/archive", text: /Delete message/i, archived: false }, + { name: "Restore", component: RestoreMessageButton, routeKey: "PATCH /api/errors/unarchive", text: /Restore/i, archived: true }, ]; -function renderButton(component: Component, permissions: string[], archived: boolean) { +function makeRoutes(keys: string[]): Map { + return new Map(keys.map((k) => [k, { method: "", url_template: "" }])); +} + +function renderButton(component: Component, allowedRouteKeys: string[], archived: boolean) { render(component, { global: { plugins: [ @@ -36,7 +41,7 @@ function renderButton(component: Component, permissions: string[], archived: boo stubActions: true, initialState: { auth: { authEnabled: true, isAuthenticated: true }, - PermissionsStore: { permissions: new Set(permissions), loaded: true, loadAttempted: true }, + AllowedRoutesStore: { routes: makeRoutes(allowedRouteKeys), loaded: true, loadAttempted: true }, MessageStore: { state: { data: { id: "msg-1", status: MessageStatus.Failed, failure_status: { archived } } }, edit_and_retry_config: { enabled: true, locked_headers: [], sensitive_headers: [] }, @@ -55,23 +60,23 @@ describe("failed-message action button permissions", () => { document.body.innerHTML = '
'; }); - test.each(cases)("$name is shown when its permission is granted", ({ component, permission, text, archived }) => { - renderButton(component, [permission], archived); + test.each(cases)("$name is shown when its route is allowed", ({ component, routeKey, text, archived }) => { + renderButton(component, [routeKey], archived); expect(screen.queryByText(text)).not.toBeNull(); }); - test.each(cases)("$name is hidden when its permission is denied", ({ component, text, archived }) => { + test.each(cases)("$name is hidden when its route is not allowed", ({ component, text, archived }) => { renderButton(component, [], archived); expect(screen.queryByText(text)).toBeNull(); }); - test("Edit & retry is shown and enabled when error:messages:edit is granted", () => { - renderButton(EditAndRetryButton, ["error:messages:edit"], false); + test("Edit & retry is shown and enabled when edit route is allowed", () => { + renderButton(EditAndRetryButton, ["POST /api/edit/{}"], false); const button = screen.getByRole("button", { name: /Edit & retry/i }) as HTMLButtonElement; expect(button.disabled).toBe(false); }); - test("Edit & retry is shown but disabled when error:messages:edit is denied", () => { + test("Edit & retry is shown but disabled when edit route is not allowed", () => { renderButton(EditAndRetryButton, [], false); const button = screen.getByRole("button", { name: /Edit & retry/i }) as HTMLButtonElement; expect(button.disabled).toBe(true); diff --git a/src/Frontend/src/components/monitoring/EndpointInstances.vue b/src/Frontend/src/components/monitoring/EndpointInstances.vue index 8f662e80b..611750e55 100644 --- a/src/Frontend/src/components/monitoring/EndpointInstances.vue +++ b/src/Frontend/src/components/monitoring/EndpointInstances.vue @@ -1,5 +1,5 @@