mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 12:42:38 +00:00
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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 (
|
||||
<ProtectedRoute>
|
||||
<ProtectedRoute requireTenantReadScope>
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<div className="text-gray-600">
|
||||
{rawSessionId ? "Invalid session id." : "No session id given."}{" "}
|
||||
@@ -468,7 +473,7 @@ function SessionDetailContent() {
|
||||
|
||||
if (loading && !session && events.length === 0) {
|
||||
return (
|
||||
<ProtectedRoute>
|
||||
<ProtectedRoute requireTenantReadScope>
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<div className="text-gray-600">Loading session details...</div>
|
||||
</div>
|
||||
@@ -477,7 +482,7 @@ function SessionDetailContent() {
|
||||
}
|
||||
|
||||
return (
|
||||
<ProtectedRoute>
|
||||
<ProtectedRoute requireTenantReadScope>
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
{/* Header */}
|
||||
<header className="bg-white shadow">
|
||||
|
||||
@@ -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 (
|
||||
<div className="min-h-screen bg-[var(--lp-bg)] flex items-center justify-center p-4">
|
||||
<div className="bg-white rounded-lg shadow-xl p-8 max-w-md w-full text-center space-y-4">
|
||||
<svg className="h-12 w-12 text-amber-500 mx-auto" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5} aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z" />
|
||||
</svg>
|
||||
<h2 className="text-xl font-semibold text-gray-900">You don't have access yet</h2>
|
||||
<p className="text-gray-600">
|
||||
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.
|
||||
</p>
|
||||
<p className="text-xs text-gray-400">Signed in as {upn}</p>
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<Link
|
||||
href="/progress"
|
||||
className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors"
|
||||
>
|
||||
Open Progress Portal
|
||||
</Link>
|
||||
<a
|
||||
href={`${DOCS_URL}${DOCS_PATHS.progressPortalOnly}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm text-green-700 underline hover:text-green-800"
|
||||
>
|
||||
Learn more
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 <DpaAcceptanceDialog />;
|
||||
}
|
||||
|
||||
if (requireTenantReadScope && user && !hasTenantReadScope(user)) {
|
||||
return <NoTenantRoleNotice upn={user.upn} />;
|
||||
}
|
||||
|
||||
// Show nothing if the route's platform requirement isn't met.
|
||||
// requireGlobalAdmin → real Global Admin only; requireGlobalScope → Global Admin OR Global Reader.
|
||||
const platformDenied =
|
||||
|
||||
Reference in New Issue
Block a user