Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
242 changes: 117 additions & 125 deletions src/Frontend/package-lock.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion src/Frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@
"prettier": "3.8.3",
"typescript": "6.0.3",
"typescript-eslint": "8.60.0",
"vite": "8.0.14",
"vite": "8.0.16",
"vite-plugin-checker": "0.14.1",
"vite-plugin-vue-devtools": "8.1.2",
"vitest": "4.1.7",
Expand Down
17 changes: 16 additions & 1 deletion src/Frontend/src/App.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed } from "vue";
import { computed, watch } from "vue";
import { RouterView, useRoute } from "vue-router";
import PageFooter from "./components/PageFooter.vue";
import PageHeader from "./components/PageHeader.vue";
Expand All @@ -8,11 +8,26 @@ import LicenseNotifications from "@/components/LicenseNotifications.vue";
import BackendChecksNotifications from "@/components/BackendChecksNotifications.vue";
import { storeToRefs } from "pinia";
import { useAuthStore } from "@/stores/AuthStore";
import { useAllowedRoutes } from "@/composables/useAllowedRoutes";

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

// 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) {
fetchManifest();
}
},
{ immediate: true }
);

// Check if the current route allows anonymous access (e.g., logged-out page)
const isAnonymousRoute = computed(() => route.meta?.allowAnonymous === true);
const shouldShowApp = computed(() => !authEnabled.value || isAuthenticated.value || isAnonymousRoute.value);
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
35 changes: 22 additions & 13 deletions src/Frontend/src/components/PageHeader.vue
Original file line number Diff line number Diff line change
Expand Up @@ -15,27 +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 { 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 { 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,
HeartbeatsMenuItem,
...(isMonitoringEnabled ? [MonitoringMenuItem] : []),
AuditMenuItem,
FailedMessagesMenuItem,
CustomChecksMenuItem,
EventsMenuItem,
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