Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
c81e574
✨ Add permission-based UI gating
dvdstelt Jun 23, 2026
e5a3aba
✨ Add dormant resource-scope matcher for future per-queue scoping
dvdstelt Jun 23, 2026
c6513f6
🐛 Handle errors in ThroughputStore auto-refresh
dvdstelt Jun 23, 2026
0ddcdfd
✨ Gate nav menu on user permissions
dvdstelt Jun 23, 2026
d507c20
✨ Gate failed-message action buttons on permissions
dvdstelt Jun 23, 2026
d1e7ef8
✨ Gate configuration tabs on permissions; show flat permission list
dvdstelt Jun 24, 2026
f285967
🔥 Remove the retired 7-bool permission-summary path
dvdstelt Jun 24, 2026
59000ba
💄 Show the User Permissions page as a per-instance permission matrix
dvdstelt Jun 24, 2026
c49f0f6
✨ Disable (not hide) failed-message actions without permission
dvdstelt Jun 24, 2026
9e8fbb0
✨ Gate more action buttons on permissions
dvdstelt Jun 24, 2026
4ef6840
✨ Add API route registry for UI gating
ramonsmits Jun 25, 2026
6ef129f
✨ Add structural route-key matcher
ramonsmits Jun 25, 2026
0fad7d2
✨ Add AllowedRoutesStore fetching my/routes from Primary + Monitoring
ramonsmits Jun 25, 2026
bee86e4
✨ Add useAllowedRoutes composable (canCall/canAnyCall)
ramonsmits Jun 25, 2026
f6dc1d0
🐛 Guard AllowedRoutesStore against non-array fetch response; add fail…
ramonsmits Jun 25, 2026
0c4f53d
✨ Fetch my/routes at startup and surface 403 denial reason
ramonsmits Jun 25, 2026
f602867
♻️ Gate nav and configuration tabs on allowed routes
ramonsmits Jun 25, 2026
b80e977
🎨 Fix lint in new route-gating files
ramonsmits Jun 25, 2026
fde4a3b
♻️ Expose route-based capability getters on resource-owning stores
ramonsmits Jun 25, 2026
160813f
♻️ Gate list/local-state actions on allowed routes
ramonsmits Jun 25, 2026
05aad55
♻️ Render User Permissions page as a route-derived capability list
ramonsmits Jun 25, 2026
480c5b5
♻️ Remove permission-string gating model
ramonsmits Jun 25, 2026
f5f86ed
♻️ Remove dead my/permissions test helper
ramonsmits Jun 25, 2026
5a11ee5
🐛 Fix Monitoring instance route paths (no /api prefix) in UI gating
ramonsmits Jun 26, 2026
a22ef9c
🐛 Read snake_case url_template from my/routes manifest
ramonsmits Jun 26, 2026
5ba11b5
Merge pull request #3044 from Particular/route-based-ui-gating
dvdstelt Jun 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 9 additions & 8 deletions src/Frontend/src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -8,20 +8,21 @@ import LicenseNotifications from "@/components/LicenseNotifications.vue";
import BackendChecksNotifications from "@/components/BackendChecksNotifications.vue";
import { storeToRefs } from "pinia";
import { useAuthStore } from "@/stores/AuthStore";
import { useUserPermissionsStore } from "@/stores/UserPermissionsStore";
import { useEnvironmentAndVersionsStore } from "@/stores/EnvironmentAndVersionsStore";
import { useAllowedRoutes } from "@/composables/useAllowedRoutes";

const authStore = useAuthStore();
const route = useRoute();
const { isAuthenticated, authEnabled } = storeToRefs(authStore);

const permissionsStore = useUserPermissionsStore();
const environmentStore = useEnvironmentAndVersionsStore();
// 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, () => environmentStore.environment.supportsUserPermissions],
([enabled, authenticated, supported]) => {
if (enabled && authenticated && supported) {
permissionsStore.refresh();
[authEnabled, isAuthenticated],
([enabled, authenticated]) => {
if (enabled && authenticated) {
fetchManifest();
}
},
{ immediate: true }
Expand Down
1 change: 1 addition & 0 deletions src/Frontend/src/assets/main.css
Original file line number Diff line number Diff line change
Expand Up @@ -353,6 +353,7 @@ input.check-label {
.btn-toolbar > .btn,
.btn-toolbar > .btn-group,
.btn-toolbar > .input-group,
.btn-toolbar > .permission-gate,
.action-btns .btn {
margin-left: 0;
margin-right: 5px;
Expand Down
23 changes: 17 additions & 6 deletions src/Frontend/src/components/OnOffSwitch.vue
Original file line number Diff line number Diff line change
@@ -1,15 +1,19 @@
<script setup lang="ts">
defineProps<{
id: string;
value: boolean | null;
}>();
withDefaults(
defineProps<{
id: string;
value: boolean | null;
disabled?: boolean;
}>(),
{ disabled: false }
);

const emit = defineEmits<{ toggle: [] }>();
</script>

<template>
<div class="onoffswitch">
<input type="checkbox" :id="`onoffswitch${id}`" :name="`onoffswitch${id}`" :aria-label="`onoffswitch${id}`" class="onoffswitch-checkbox" @click="emit('toggle')" :checked="value ?? false" />
<div class="onoffswitch" :class="{ disabled }">
<input type="checkbox" :id="`onoffswitch${id}`" :name="`onoffswitch${id}`" :aria-label="`onoffswitch${id}`" class="onoffswitch-checkbox" :disabled="disabled" @click="emit('toggle')" :checked="value ?? false" />
<label class="onoffswitch-label" :for="`onoffswitch${id}`" role="switch" :aria-checked="value ?? false" :aria-label="id">
<span class="onoffswitch-inner"></span>
<span class="onoffswitch-switch"></span>
Expand All @@ -27,6 +31,13 @@ const emit = defineEmits<{ toggle: [] }>();
width: 76px;
}

/* Disabled: dim it and let pointer events pass through to a wrapping PermissionGate so its
tooltip shows. The native :disabled on the checkbox already prevents toggling. */
.onoffswitch.disabled {
opacity: 0.65;
pointer-events: none;
}

.onoffswitch-checkbox {
display: none;
}
Expand Down
36 changes: 21 additions & 15 deletions src/Frontend/src/components/PageHeader.vue
Original file line number Diff line number Diff line change
Expand Up @@ -15,30 +15,36 @@ import AuditMenuItem from "./audit/AuditMenuItem.vue";
import monitoringClient from "@/components/monitoring/monitoringClient";
import UserProfileMenuItem from "@/components/UserProfileMenuItem.vue";
import { useAuthStore } from "@/stores/AuthStore";
import usePermissionGate from "@/composables/usePermissionGate";
import { useAllowedRoutes } from "@/composables/useAllowedRoutes";
import { ApiRoutes } from "@/composables/apiRoutes";
import { storeToRefs } from "pinia";

const isMonitoringEnabled = monitoringClient.isMonitoringEnabled;

const authStore = useAuthStore();
const { authEnabled, isAuthenticated } = storeToRefs(authStore);

const { has } = usePermissionGate();
const { canCall, ready } = useAllowedRoutes();

// 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. canCall() fails open, so auth-disabled / failed-load shows everything.
// prettier-ignore
const menuItems = computed(
() => [
DashboardMenuItem,
...(has("failed_messages_read") ? [HeartbeatsMenuItem] : []),
...(isMonitoringEnabled && has("monitoring_read") ? [MonitoringMenuItem] : []),
...(has("auditing_read") ? [AuditMenuItem] : []),
...(has("failed_messages_read") ? [FailedMessagesMenuItem] : []),
...(has("failed_messages_read") ? [CustomChecksMenuItem] : []),
...(has("failed_messages_read") ? [EventsMenuItem] : []),
...(has("admin_read") ? [ThroughputMenuItem] : []),
ConfigurationMenuItem,
FeedbackButton,
]);
const menuItems = computed(() => {
if (!ready.value) return [];
return [
DashboardMenuItem,
...(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,
];
});
</script>

<template>
Expand Down
25 changes: 25 additions & 0 deletions src/Frontend/src/components/PermissionGate.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { render } from "@testing-library/vue";
import { describe, test, expect } from "vitest";
import PermissionGate from "@/components/PermissionGate.vue";

function mountGate(allowed: boolean) {
return render(PermissionGate, {
props: { allowed, reason: "You don't have permission" },
slots: { default: '<button class="btn">Do it</button>' },
global: { directives: { tippy: () => {} } },
});
}

describe("PermissionGate", () => {
test("renders the slot and marks the wrapper denied when not allowed", () => {
const { container, getByText } = mountGate(false);
expect(getByText("Do it")).toBeTruthy();
expect(container.querySelector(".permission-gate.denied")).not.toBeNull();
});

test("does not mark the wrapper denied when allowed", () => {
const { container } = mountGate(true);
expect(container.querySelector(".permission-gate")).not.toBeNull();
expect(container.querySelector(".permission-gate.denied")).toBeNull();
});
});
45 changes: 45 additions & 0 deletions src/Frontend/src/components/PermissionGate.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
<script setup lang="ts">
// Wraps an action (button or link) so that, when the current user is not allowed to use it,
// it stays visible but reads as disabled and shows a tooltip explaining why, instead of
// silently failing server-side.
//
// The consumer still disables the inner control (so it gets the native disabled state and
// any other disable conditions still apply); this wrapper adds the tooltip, lets pointer
// events reach the wrapper so the tooltip shows even over a disabled button, and tidies the
// disabled look of link-style buttons.
interface Props {
allowed: boolean;
reason?: string;
}
withDefaults(defineProps<Props>(), { reason: "" });
</script>

<template>
<span class="permission-gate" :class="{ denied: !allowed }" v-tippy="!allowed ? reason : ''">
<slot />
</span>
</template>

<style scoped>
.permission-gate {
display: inline-flex;
}

.permission-gate.denied {
cursor: not-allowed;
}

/* Let pointer events reach the wrapper so its tooltip shows even though the button is disabled. */
.permission-gate.denied :deep(.btn) {
pointer-events: none;
}

/* A permission-disabled link should read as disabled: gray, without the default button box. */
.permission-gate.denied :deep(.btn-link),
.permission-gate.denied :deep(.btn-link .button-text) {
color: #6c757d;
opacity: 1;
background-color: transparent;
border-color: transparent;
}
</style>
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,16 @@ import type UpdateEmailNotificationsSettingsRequest from "@/resources/UpdateEmai
import OnOffSwitch from "../OnOffSwitch.vue";
import FAIcon from "@/components/FAIcon.vue";
import ActionButton from "@/components/ActionButton.vue";
import PermissionGate from "@/components/PermissionGate.vue";
import { faCheck, faEdit, faEnvelope, faExclamationTriangle } from "@fortawesome/free-solid-svg-icons";
import { useHealthChecksStore } from "@/stores/HealthChecksStore";
import { storeToRefs } from "pinia";

const healthChecksStore = useHealthChecksStore();
const { emailNotifications } = storeToRefs(healthChecksStore);
const { emailNotifications, canManageNotifications, canTestNotifications } = storeToRefs(healthChecksStore);

const manageDeniedTooltip = "You don't have permission to manage notifications.";
const testDeniedTooltip = "You don't have permission to send test notifications.";

const emailTestSuccessful = ref<boolean | null>(null);
const emailTestInProgress = ref<boolean | null>(null);
Expand Down Expand Up @@ -77,7 +81,9 @@ onMounted(async () => {
<div class="col-12 no-side-padding">
<div class="row">
<div class="col-auto">
<OnOffSwitch id="emailNotifications" @toggle="toggleEmailNotifications" :value="emailNotifications.enabled" />
<PermissionGate :allowed="canManageNotifications" :reason="manageDeniedTooltip">
<OnOffSwitch id="emailNotifications" @toggle="toggleEmailNotifications" :value="emailNotifications.enabled" :disabled="!canManageNotifications" />
</PermissionGate>
<div>
<span class="connection-test connection-failed">
<template v-if="emailToggleSuccessful === false"> <FAIcon :icon="faExclamationTriangle" /> Update failed </template>
Expand All @@ -89,10 +95,14 @@ onMounted(async () => {
<div class="col-12">
<p class="lead">Email notifications</p>
<p class="endpoint-metadata">
<ActionButton variant="link" size="sm" :icon="faEdit" @click="editEmailNotifications">Configure</ActionButton>
<PermissionGate :allowed="canManageNotifications" :reason="manageDeniedTooltip">
<ActionButton variant="link" size="sm" :icon="faEdit" :disabled="!canManageNotifications" @click="editEmailNotifications">Configure</ActionButton>
</PermissionGate>
</p>
<p class="endpoint-metadata">
<ActionButton variant="link" size="sm" :icon="faEnvelope" @click="testEmailNotifications" :disabled="!!emailTestInProgress">Send test notification</ActionButton>
<PermissionGate :allowed="canTestNotifications" :reason="testDeniedTooltip">
<ActionButton variant="link" size="sm" :icon="faEnvelope" :disabled="!!emailTestInProgress || !canTestNotifications" @click="testEmailNotifications">Send test notification</ActionButton>
</PermissionGate>
<span class="connection-test connection-testing">
<template v-if="emailTestInProgress">
<i class="glyphicon glyphicon-refresh rotate"></i>
Expand Down
17 changes: 14 additions & 3 deletions src/Frontend/src/components/configuration/RetryRedirects.vue
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,16 @@ import type Redirect from "@/resources/Redirect";
import RetryRedirectEdit, { type RetryRedirect } from "@/components/configuration/RetryRedirectEdit.vue";
import FAIcon from "@/components/FAIcon.vue";
import ActionButton from "@/components/ActionButton.vue";
import PermissionGate from "@/components/PermissionGate.vue";
import { faClock } from "@fortawesome/free-regular-svg-icons";
import { useRedirectsStore } from "@/stores/RedirectsStore";
import LoadingSpinner from "../LoadingSpinner.vue";
import { storeToRefs } from "pinia";

const redirectsStore = useRedirectsStore();
const { canManageRedirects } = storeToRefs(redirectsStore);
// Creating, modifying and ending redirects all manage redirects.
const redirectsDeniedTooltip = "You don't have permission to manage redirects.";

const loadingData = ref(true);
const redirects = redirectsStore.redirects;
Expand Down Expand Up @@ -131,7 +136,9 @@ onMounted(async () => {
<div class="row">
<div class="col-sm-12">
<div class="btn-toolbar">
<ActionButton @click="createRedirect"><i class="fa pa-redirect-source pa-redirect-small"></i> Create Redirect</ActionButton>
<PermissionGate :allowed="canManageRedirects" :reason="redirectsDeniedTooltip">
<ActionButton @click="createRedirect" :disabled="!canManageRedirects"><i class="fa pa-redirect-source pa-redirect-small"></i> Create Redirect</ActionButton>
</PermissionGate>
<span></span>
</div>
</div>
Expand Down Expand Up @@ -163,8 +170,12 @@ onMounted(async () => {
<div class="row">
<div class="col-sm-12">
<p class="small">
<ActionButton variant="link" size="sm" @click="deleteRedirect(redirect)">End Redirect</ActionButton>
<ActionButton variant="link" size="sm" @click="editRedirect(redirect)">Modify Redirect</ActionButton>
<PermissionGate :allowed="canManageRedirects" :reason="redirectsDeniedTooltip">
<ActionButton variant="link" size="sm" :disabled="!canManageRedirects" @click="deleteRedirect(redirect)">End Redirect</ActionButton>
</PermissionGate>
<PermissionGate :allowed="canManageRedirects" :reason="redirectsDeniedTooltip">
<ActionButton variant="link" size="sm" :disabled="!canManageRedirects" @click="editRedirect(redirect)">Modify Redirect</ActionButton>
</PermissionGate>
</p>
</div>
</div>
Expand Down
Loading