From 0c01f3ed768b5de5dcd5a227940eb684fc4c0756 Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Fri, 9 Oct 2026 15:17:25 +0200 Subject: [PATCH] Session links: a member without a role gets a notice instead of an empty session page A member without a tenant role who opened a shared session link landed on a session page whose every read answered 403: an empty page that looked like a valid session without data, its two error toasts in a bell this user's navbar does not show. ProtectedRoute takes a new opt-in requireTenantReadScope (the Progress Portal hint's definition of "no role"); the session page sets it on all three branches and keeps its data hooks inert once auth/me shows no read scope (before that nothing changes, so members are never delayed). The full-page notice says whom to ask, links the Progress Portal and the docs article, and reports tenant_role_missing_shown { path } once per mount. Co-Authored-By: Claude Opus 5.5 --- .../app/sessions/page.tsx | 15 ++++-- .../components/NoTenantRoleNotice.tsx | 54 +++++++++++++++++++ .../components/ProtectedRoute.tsx | 19 +++++-- 3 files changed, 80 insertions(+), 8 deletions(-) create mode 100644 src/Web/autopilot-monitor-web/components/NoTenantRoleNotice.tsx diff --git a/src/Web/autopilot-monitor-web/app/sessions/page.tsx b/src/Web/autopilot-monitor-web/app/sessions/page.tsx index 056e9228..3f568bfe 100644 --- a/src/Web/autopilot-monitor-web/app/sessions/page.tsx +++ b/src/Web/autopilot-monitor-web/app/sessions/page.tsx @@ -19,6 +19,7 @@ import { useScriptDisplayNames } from '@/lib/scriptDisplayNames'; import { api } from "@/lib/api"; import { isGuid } from "@/utils/inputValidation"; import { isTerminalStatus } from "@/utils/sessionStatus"; +import { hasTenantReadScope } from "@/lib/tenantScope"; import { ApiError, fetchBlob, fetchJson, fetchOk, jsonBody } from "@/lib/apiClient"; import { useSessionAnalysis } from "./hooks/useSessionAnalysis"; @@ -68,13 +69,18 @@ export default function SessionDetailPage() { function SessionDetailContent() { const router = useRouter(); const searchParams = useSearchParams(); + const { getAccessToken, user } = useAuth(); // Query-string identity (`?id=`) — path params are gone with the static export; // legacy /sessions/{id} URLs are rewritten here by LegacyPathRedirect. // The raw value is attacker-deliverable (deep link) and useSearchParams returns it // percent-DECODED, so anything but a GUID must never reach an API URL builder: an empty // sessionId keeps every data hook inert and the page renders the invalid-id branch below. const rawSessionId = searchParams?.get("id") ?? ""; - const sessionId = isGuid(rawSessionId) ? rawSessionId.trim() : ""; + // A member without a tenant role sees ProtectedRoute's notice, but the hooks below run outside its + // JSX: the empty id keeps them inert, every read would answer 403. Until auth/me has answered `user` + // is null and nothing changes, so members are never delayed. + const canReadTenant = !user || hasTenantReadScope(user); + const sessionId = canReadTenant && isGuid(rawSessionId) ? rawSessionId.trim() : ""; // Explicit target tenant from the fleet drill-in (`?tenantId=`). Drives the cross-tenant reads for a // delegated ("MSP") admin viewing a managed tenant's session, and flips the page into read-only mode. const tenantIdOverride = searchParams?.get("tenantId") || undefined; @@ -100,7 +106,6 @@ function SessionDetailContent() { // Global contexts const { on, off, isConnected, joinGroup, leaveGroup } = useSignalR(); const { tenantId } = useTenant(); - const { getAccessToken, user } = useAuth(); const { addNotification, notifyError } = useNotifications(); const { latestAgentVersion, latestBootstrapVersion } = useLatestVersions(getAccessToken); @@ -455,7 +460,7 @@ function SessionDetailContent() { // the MSAL login redirect. Wrapping here lets ProtectedRoute drive re-auth. if (!sessionId) { return ( - +
{rawSessionId ? "Invalid session id." : "No session id given."}{" "} @@ -468,7 +473,7 @@ function SessionDetailContent() { if (loading && !session && events.length === 0) { return ( - +
Loading session details...
@@ -477,7 +482,7 @@ function SessionDetailContent() { } return ( - +
{/* Header */}
diff --git a/src/Web/autopilot-monitor-web/components/NoTenantRoleNotice.tsx b/src/Web/autopilot-monitor-web/components/NoTenantRoleNotice.tsx new file mode 100644 index 00000000..af7edf3d --- /dev/null +++ b/src/Web/autopilot-monitor-web/components/NoTenantRoleNotice.tsx @@ -0,0 +1,54 @@ +"use client"; + +import { useEffect } from "react"; +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { trackEvent } from "@/lib/appInsights"; +import { DOCS_PATHS } from "@/lib/docsPaths"; +import { DOCS_URL } from "@/utils/config"; + +/** + * Full-page notice for a signed-in member without a tenant role who opened a page that needs one + * (typically a shared session link). Every data call of such a page would answer 403, which used + * to render as an empty but valid-looking page. Always the "member" wording: the link itself + * proves that the organization uses Autopilot Monitor. + */ +export function NoTenantRoleNotice({ upn }: { upn: string }) { + const pathname = usePathname(); + + useEffect(() => { + trackEvent("tenant_role_missing_shown", { path: pathname ?? "" }); + }, [pathname]); + + return ( +
+
+ +

You don't have access yet

+

+ You're signed in, but you have no role in your organization's Autopilot Monitor yet. Ask your + Autopilot Monitor admin to add you under Access Management, then reload this page. +

+

Signed in as {upn}

+
+ + Open Progress Portal + + + Learn more + +
+
+
+ ); +} diff --git a/src/Web/autopilot-monitor-web/components/ProtectedRoute.tsx b/src/Web/autopilot-monitor-web/components/ProtectedRoute.tsx index f5e8b2f0..17c0c2a5 100644 --- a/src/Web/autopilot-monitor-web/components/ProtectedRoute.tsx +++ b/src/Web/autopilot-monitor-web/components/ProtectedRoute.tsx @@ -10,8 +10,10 @@ import { activeAuthApp } from "../lib/msalConfig"; import { DOCS_PATHS } from "../lib/docsPaths"; import { DOCS_URL } from "../utils/config"; import { useAdminMode } from "../hooks/useAdminMode"; +import { hasTenantReadScope } from "../lib/tenantScope"; import OffboardingFeedbackForm from "./OffboardingFeedbackForm"; import { DpaAcceptanceDialog } from "./DpaAcceptanceDialog"; +import { NoTenantRoleNotice } from "./NoTenantRoleNotice"; interface ProtectedRouteProps { children: React.ReactNode; @@ -30,6 +32,12 @@ interface ProtectedRouteProps { * client route so a single-tenant user with no fleet doesn't land on an empty fleet page. */ requireFleetScope?: boolean; + /** + * Require tenant read scope (any tenant role, platform scope or delegated scope — the Progress + * Portal hint's definition). A member without one gets a notice naming whom to ask instead of the + * page, whose data calls would all answer 403. Opt-in: /progress itself sits in a ProtectedRoute. + */ + requireTenantReadScope?: boolean; } /** @@ -40,10 +48,11 @@ const OFFBOARDING_SUSPENSION_MESSAGE = "Offboarding in progress"; /** * Protects routes by requiring authentication. Optionally requires Global Admin, platform scope - * (Global Admin or read-only Global Reader), or fleet scope (platform scope OR a delegated MSP admin). - * A suspended tenant (auth/me 403 TenantSuspended) gets the suspended page instead of the children. + * (Global Admin or read-only Global Reader), fleet scope (platform scope OR a delegated MSP admin), + * or tenant read scope. A suspended tenant (auth/me 403 TenantSuspended) gets the suspended page + * instead of the children. */ -export function ProtectedRoute({ children, requireGlobalAdmin = false, requireGlobalScope = false, requireFleetScope = false }: ProtectedRouteProps) { +export function ProtectedRoute({ children, requireGlobalAdmin = false, requireGlobalScope = false, requireFleetScope = false, requireTenantReadScope = false }: ProtectedRouteProps) { const { isAuthenticated, user, hasGlobalScope, hasFleetScope, isLoading, login, logout, getAccessToken, isTenantSuspended, suspensionMessage } = useAuth(); const router = useRouter(); @@ -241,6 +250,10 @@ export function ProtectedRoute({ children, requireGlobalAdmin = false, requireGl return ; } + if (requireTenantReadScope && user && !hasTenantReadScope(user)) { + return ; + } + // Show nothing if the route's platform requirement isn't met. // requireGlobalAdmin → real Global Admin only; requireGlobalScope → Global Admin OR Global Reader. const platformDenied =