From b0c5087cdf68d2d2911a19d6fe8b2580ada451b0 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 16:21:35 +0000 Subject: [PATCH 1/3] fix(web): suspended and offboarded tenants get their page again Since the typed error envelope (7cef2ea3, 2026-09-05) the policy middleware's suspension gate answers auth/me with { error: , code: "TenantSuspended" } before AuthFunction runs. AuthContext still compared error === "TenantSuspended", so every caller without platform scope of a suspended or offboarding tenant got the claims-only fallback and a 403 on every call instead of the suspended page or the farewell feedback form. - lib/authMeRefusal.ts classifies auth/me's 403 in both shapes: the envelope (code in `code`, text in `error`) and AuthFunction's pre-envelope body ({ error: , message }) that the activation gate and the platform-caller suspension still write. TenantSuspended is checked against the generated ApiErrorCode vocabulary. - AuthContext parses the refusal with apiErrorFromResponse; its JSON-parse ratchet drops to 2. - Tests: both shapes, the offboarding text, PendingActivation / PrivatePreview, non-matches. Web only; works against the deployed backend. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FfPq5odfetjR7ExY8K8gEu --- .../contexts/AuthContext.tsx | 18 ++--- .../lib/__tests__/apiClient.guard.baseline.ts | 2 +- .../lib/__tests__/authMeRefusal.test.ts | 68 +++++++++++++++++++ .../lib/authMeRefusal.ts | 32 +++++++++ 4 files changed, 111 insertions(+), 9 deletions(-) create mode 100644 src/Web/autopilot-monitor-web/lib/__tests__/authMeRefusal.test.ts create mode 100644 src/Web/autopilot-monitor-web/lib/authMeRefusal.ts diff --git a/src/Web/autopilot-monitor-web/contexts/AuthContext.tsx b/src/Web/autopilot-monitor-web/contexts/AuthContext.tsx index bc8697acc..3687ef1c7 100644 --- a/src/Web/autopilot-monitor-web/contexts/AuthContext.tsx +++ b/src/Web/autopilot-monitor-web/contexts/AuthContext.tsx @@ -20,6 +20,8 @@ import { tryBeginLoginFallback, } from '@/lib/authApp'; import { api } from '@/lib/api'; +import { apiErrorFromResponse } from '@/lib/apiClient'; +import { classifyAuthMeRefusal } from '@/lib/authMeRefusal'; import { clearCachedAuthFetch } from '@/lib/cachedAuthFetch'; import { trackEvent } from '@/lib/appInsights'; import { seedDashboardFirstFetch } from '@/lib/dashboardSeed'; @@ -369,23 +371,23 @@ function AuthProviderInternal({ children }: { children: React.ReactNode }) { if (!response.ok) { if (response.status === 403) { - const errorData = await response.json(); + // Both refusal body shapes (policy-middleware envelope, AuthFunction's own gates) are + // read by classifyAuthMeRefusal. + const refusal = classifyAuthMeRefusal(await apiErrorFromResponse(response)); // Suspended (operator kill-switch or the offboarding tombstone): keep the user signed // in with claim-only info so ProtectedRoute renders the suspended page — an admin who // just offboarded their tenant reloads into the farewell feedback form, not into a // browser alert and a forced logout. - if (errorData.error === 'TenantSuspended') { - console.warn('[Auth] Tenant suspended:', errorData.message); + if (refusal?.kind === 'suspended') { + console.warn('[Auth] Tenant suspended:', refusal.message); setTenantSuspended(true); - setSuspensionMessage(errorData.message || 'Your tenant has been suspended.'); + setSuspensionMessage(refusal.message || 'Your tenant has been suspended.'); return claimsOnlyUserInfo(account); } - // 'PendingActivation' is the current backend code; 'PrivatePreview' is the legacy - // code kept accepted so web and backend can deploy in any order. - if (errorData.error === 'PendingActivation' || errorData.error === 'PrivatePreview') { + if (refusal?.kind === 'activationPending') { console.log('[Auth] Tenant not yet activated'); setActivationPending(true); - setActivationMessage(errorData.message || 'Your organization is being activated.'); + setActivationMessage(refusal.message || 'Your organization is being activated.'); // Return basic user info so the user stays logged in but sees the activation page return claimsOnlyUserInfo(account); } diff --git a/src/Web/autopilot-monitor-web/lib/__tests__/apiClient.guard.baseline.ts b/src/Web/autopilot-monitor-web/lib/__tests__/apiClient.guard.baseline.ts index a426ad2ef..cb5679595 100644 --- a/src/Web/autopilot-monitor-web/lib/__tests__/apiClient.guard.baseline.ts +++ b/src/Web/autopilot-monitor-web/lib/__tests__/apiClient.guard.baseline.ts @@ -34,7 +34,7 @@ export const JSONPARSE_BASELINE: Record = { "app/dashboard/hooks/deleteSessionResponse.ts": 1, "app/health-check/page.tsx": 1, "components/landing/StatsBand.tsx": 2, - "contexts/AuthContext.tsx": 3, + "contexts/AuthContext.tsx": 2, }; /** diff --git a/src/Web/autopilot-monitor-web/lib/__tests__/authMeRefusal.test.ts b/src/Web/autopilot-monitor-web/lib/__tests__/authMeRefusal.test.ts new file mode 100644 index 000000000..188c1c88b --- /dev/null +++ b/src/Web/autopilot-monitor-web/lib/__tests__/authMeRefusal.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it, vi } from "vitest"; +import { apiErrorFromResponse } from "../apiClient"; +import { classifyAuthMeRefusal } from "../authMeRefusal"; + +vi.mock("../appInsights", () => ({ trackEvent: vi.fn() })); + +/** + * auth/me refuses in two body shapes; both must land on the right portal page. The envelope + * case is the one that regressed: since the typed envelope (2026-09-05) the policy middleware's + * suspension gate carries the code in `code`, and the old `error === "TenantSuspended"` check + * never matched — suspended and offboarded tenants saw neither page. + */ +async function refusal(body: unknown) { + const response = new Response(typeof body === "string" ? body : JSON.stringify(body), { + status: 403, + statusText: "Forbidden", + headers: { "Content-Type": "application/json" }, + }); + return classifyAuthMeRefusal(await apiErrorFromResponse(response)); +} + +describe("classifyAuthMeRefusal", () => { + it("reads the policy middleware's envelope for a suspended tenant", async () => { + expect( + await refusal({ + error: "Your tenant has been suspended. Please contact support for more information.", + code: "TenantSuspended", + correlationId: "3f2a9c1e-7b4d-4e0a-9d2c-1a2b3c4d5e6f", + }), + ).toEqual({ + kind: "suspended", + message: "Your tenant has been suspended. Please contact support for more information.", + }); + }); + + it("keeps the offboarding tombstone text, which switches ProtectedRoute to the farewell page", async () => { + expect(await refusal({ error: "Offboarding in progress", code: "TenantSuspended" })).toEqual({ + kind: "suspended", + message: "Offboarding in progress", + }); + }); + + it("reads AuthFunction's pre-envelope body for a suspended tenant", async () => { + expect( + await refusal({ error: "TenantSuspended", message: "Maintenance window", disabledUntil: null, contactSupport: true }), + ).toEqual({ kind: "suspended", message: "Maintenance window" }); + }); + + it("reads the activation gate and its legacy PrivatePreview spelling", async () => { + expect(await refusal({ error: "PendingActivation", message: "Your tenant is being activated." })).toEqual({ + kind: "activationPending", + message: "Your tenant is being activated.", + }); + expect(await refusal({ error: "PrivatePreview" })).toEqual({ kind: "activationPending", message: null }); + }); + + it("returns null for any other refusal", async () => { + expect( + await refusal({ + error: "Access denied. You do not have permission to access this resource.", + code: "InsufficientPermissions", + }), + ).toBeNull(); + // A suspension text in the envelope's `error` must not be mistaken for the code. + expect(await refusal({ error: "TenantSuspended", code: "Forbidden" })).toBeNull(); + expect(await refusal("Forbidden")).toBeNull(); + }); +}); diff --git a/src/Web/autopilot-monitor-web/lib/authMeRefusal.ts b/src/Web/autopilot-monitor-web/lib/authMeRefusal.ts new file mode 100644 index 000000000..88f9dc7da --- /dev/null +++ b/src/Web/autopilot-monitor-web/lib/authMeRefusal.ts @@ -0,0 +1,32 @@ +import type { ApiError } from "@/lib/apiClient"; +import type { ApiErrorCode } from "@/lib/apiErrorCodes"; + +/** What a 403 from auth/me means for the portal shell (ProtectedRoute renders the matching page). */ +export type AuthMeRefusal = + | { kind: "suspended"; message: string | null } + | { kind: "activationPending"; message: string | null }; + +const TENANT_SUSPENDED = "TenantSuspended" satisfies ApiErrorCode; +/** AuthFunction's activation gate; "PrivatePreview" is the legacy spelling, kept so web and backend deploy in any order. */ +const ACTIVATION_PENDING_CODES: ReadonlySet = new Set(["PendingActivation", "PrivatePreview"]); + +/** + * Classify auth/me's refusal. Two body shapes reach the SPA: + * - the error envelope `{ error: , code, correlationId }` — written by the policy middleware, + * whose tenant-suspension gate answers BEFORE AuthFunction runs (every caller without platform + * scope of a suspended or offboarded tenant); + * - AuthFunction's own pre-envelope body `{ error: , message: }` — the activation gate, + * and the suspension gate for platform callers. + * apiErrorFromResponse maps `error` to ApiError.message and `code` to ApiError.code, so an empty + * code marks the pre-envelope body, whose code then sits in the message. + */ +export function classifyAuthMeRefusal(err: ApiError): AuthMeRefusal | null { + const envelope = err.code !== ""; + const code = envelope ? err.code : err.message; + const bodyMessage = err.body?.message; + const message = envelope ? err.message : typeof bodyMessage === "string" && bodyMessage.length > 0 ? bodyMessage : null; + + if (code === TENANT_SUSPENDED) return { kind: "suspended", message }; + if (ACTIVATION_PENDING_CODES.has(code)) return { kind: "activationPending", message }; + return null; +} From b0367854f9b6a888ab1cf5e74c3b9e92b5ac3ccd Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 16:21:35 +0000 Subject: [PATCH 2/3] fix(web): tenant settings gate toggles snap back when their save fails The Cloud PC, Intune Enrollment and Unrestricted Mode toggles set their state before saveConfiguration runs, and its failure branch never re-synced it: after a refused save the toggle stayed on although nothing was stored. saveConfiguration now syncs the five validation gates and Unrestricted Mode through one helper, to the merged row on success (unchanged) and to the loaded row on failure. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FfPq5odfetjR7ExY8K8gEu --- .../app/settings/TenantConfigContext.tsx | 31 ++++++++++++------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/src/Web/autopilot-monitor-web/app/settings/TenantConfigContext.tsx b/src/Web/autopilot-monitor-web/app/settings/TenantConfigContext.tsx index 5f1c6c381..4a5e709d8 100644 --- a/src/Web/autopilot-monitor-web/app/settings/TenantConfigContext.tsx +++ b/src/Web/autopilot-monitor-web/app/settings/TenantConfigContext.tsx @@ -743,6 +743,18 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode } // that still reaches a save (the backend would 403 the PATCH regardless). if (!tenantId || !config || !canEditConfig) return false; + // The validation gates and Unrestricted Mode have no save bar, so their form state must always + // equal the stored row. Some of their toggles move the state before this save runs, so a failed + // save puts it back: success syncs to the merged row, failure to the loaded one. + const syncGateState = (stored: TenantConfiguration) => { + setValidateAutopilotDevice(stored.validateAutopilotDevice); + setValidateCorporateIdentifier(stored.validateCorporateIdentifier ?? false); + setValidateDeviceAssociation(stored.validateDeviceAssociation ?? false); + setValidateCloudPcDevice(stored.validateCloudPcDevice ?? false); + setValidateIntuneDeviceBinding(stored.validateIntuneDeviceBinding ?? false); + setUnrestrictedMode(stored.unrestrictedMode ?? false); + }; + try { setSavingSection(sectionName); setError(null); @@ -836,18 +848,13 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode } // Sync the gate-relevant form state with what is now persisted (merge of loaded // config + this patch) — mirrors the old PUT-response sync. - const persisted = { ...config, ...patchFields } as TenantConfiguration; - setValidateAutopilotDevice(persisted.validateAutopilotDevice); - setValidateCorporateIdentifier(persisted.validateCorporateIdentifier ?? false); - setValidateDeviceAssociation(persisted.validateDeviceAssociation ?? false); - setValidateCloudPcDevice(persisted.validateCloudPcDevice ?? false); - setValidateIntuneDeviceBinding(persisted.validateIntuneDeviceBinding ?? false); - setUnrestrictedMode(persisted.unrestrictedMode ?? false); + syncGateState({ ...config, ...patchFields } as TenantConfiguration); trackEvent("settings_saved", { section: sectionName, fieldCount: Object.keys(patchFields).length }); setSuccessMessage("Configuration saved successfully!"); setTimeout(() => setSuccessMessage(null), 3000); return true; } catch (err) { + syncGateState(config); const msg = apiErrorText(err, "Failed to save configuration"); trackEvent("settings_error", { action: "save", section: sectionName, error: msg }); setError(msg); @@ -900,8 +907,8 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode } }, [tenantId, getAccessToken, noteHomingProbe]); // Direct gate persist for the toggle UI (disable + second-gate enable): same shared config - // PUT, explicit override values. saveConfiguration re-syncs the local gate state from the - // server response, so the toggle reflects the persisted truth (or snaps back on failure). + // PATCH, explicit override values. saveConfiguration re-syncs the local gate state to the stored + // row (the merge on success, the loaded row on failure), so the toggle shows the persisted truth. const saveValidationGate = useCallback( (changes: { validateAutopilotDevice?: boolean; validateCorporateIdentifier?: boolean; validateDeviceAssociation?: boolean }): Promise => saveConfiguration("autopilotValidation", changes), @@ -1310,7 +1317,8 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode } * Toggle the Windows 365 Cloud PC validation fallback. No consent flow — the backing * CloudPC.Read.All permission is an Optional Graph capabilities add-on (grant script); * without the grant the backend simply keeps rejecting Cloud PCs with a pointer to the - * add-on, so enabling early is safe. + * add-on, so enabling early is safe. The toggle moves at once; a failed save moves it back + * (saveConfiguration re-syncs the gates). */ const handleToggleCloudPcValidation = useCallback(async (newValue: boolean) => { setValidateCloudPcDevice(newValue); @@ -1321,7 +1329,8 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode } * Toggle Intune Enrollment Validation (field name validateIntuneDeviceBinding). No consent * flow — DeviceManagementManagedDevices.Read.All is an Optional Graph capabilities add-on * granted with the script; until it is granted no device is admitted through this option - * (the backend answers 503 Retry-After, like a missing core consent). + * (the backend answers 503 Retry-After, like a missing core consent). Moves at once and back on + * a failed save, like the Cloud PC toggle. */ const handleToggleIntuneDeviceBinding = useCallback(async (newValue: boolean) => { setValidateIntuneDeviceBinding(newValue); From 3301c9252af6f365673db1ebb08c75200eaa708b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 16:21:35 +0000 Subject: [PATCH 3/3] fix(scripts,web): add-on grants name the roles that can do them and show Graph's refusal Assigning Microsoft Graph application permissions needs Global Administrator or Privileged Role Administrator. The grant script and the app-switch add-on step also named Cloud Application Administrator, which Microsoft excludes for Microsoft Graph; such an admin got "Response status code does not indicate success: Forbidden (Forbidden)" and no hint. - Grant-AutopilotMonitorAddOn.ps1: .NOTES and the scope warning name the two roles; the admin-consent prerequisite moves under Requires. Get-GraphFailure reads Graph's error code and message from the response body in ErrorDetails (behind the headers, one of which, x-ms-ags-diagnostic, carries JSON itself); a 403 on grant or revoke ends the summary with the roles to sign in with. New text is ASCII (the script is published without BOM). - AppHomingAddOnStep and the Optional Graph capabilities command section name the two roles (the latter said "tenant administrator", the portal role); the step's test pins them. The customer docs (reference/optional-graph-permissions.md, troubleshooting/app-registration-migration.md) still name Cloud Application Administrator and need the same fix in the docs repo. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FfPq5odfetjR7ExY8K8gEu --- .../Grant-AutopilotMonitorAddOn.ps1 | 52 ++++++++++++++++--- .../components/AppHomingAddOnStep.tsx | 9 ++-- .../__tests__/AppHomingAddOnStep.test.ts | 4 ++ .../SectionOptionalGraphCapabilities.tsx | 3 +- 4 files changed, 56 insertions(+), 12 deletions(-) diff --git a/scripts/CustomerSetup/Grant-AutopilotMonitorAddOn.ps1 b/scripts/CustomerSetup/Grant-AutopilotMonitorAddOn.ps1 index b909139ae..669db564c 100644 --- a/scripts/CustomerSetup/Grant-AutopilotMonitorAddOn.ps1 +++ b/scripts/CustomerSetup/Grant-AutopilotMonitorAddOn.ps1 @@ -75,15 +75,17 @@ .NOTES Requires: - Microsoft.Graph.Authentication PowerShell module (auto-installs if missing). - - Sign-in as Global Administrator OR Privileged Role Administrator OR Cloud Application Administrator. + - Sign-in as Global Administrator OR Privileged Role Administrator (with PIM, activate the + role first). Only these roles may assign Microsoft Graph application permissions; + Application Administrator and Cloud Application Administrator are not enough. + - The Autopilot Monitor app must be admin-consented in your tenant first + (its service principal must exist before grants can be added). Sign-in behaviour: In Azure Cloud Shell the script signs in via the ambient identity endpoint (a delegated token for the already-signed-in user) - no device-code prompt, which Conditional Access policies commonly block. Outside Cloud Shell it falls back to a normal interactive Microsoft Graph sign-in. -TenantId only applies to the interactive fallback. - - The Autopilot Monitor app must be admin-consented in your tenant first - (its service principal must exist before grants can be added). After granting: The Autopilot Monitor backend may cache an older token for up to ~1 hour. Use the @@ -139,6 +141,36 @@ if ($PSCmdlet.ParameterSetName -eq 'ByFeatures') { # ---- Constants ---- $MicrosoftGraphAppId = '00000003-0000-0000-c000-000000000000' +# ---- Graph failure details ---- +# Invoke-MgGraphRequest puts only "Response status code does not indicate success: ..." into the +# exception message. Graph's own error (code + message) is the response body in ErrorDetails, +# behind the request line, the status line and the headers. Some headers carry JSON themselves +# (x-ms-ags-diagnostic), so the body is taken from behind the blank line that ends the headers. +function Get-GraphFailure { + param([System.Management.Automation.ErrorRecord]$ErrorRecord) + + $status = 0 + if ($ErrorRecord.Exception.PSObject.Properties['Response'] -and $ErrorRecord.Exception.Response) { + $status = [int]$ErrorRecord.Exception.Response.StatusCode + } + + $text = $ErrorRecord.Exception.Message + if ($ErrorRecord.ErrorDetails -and $ErrorRecord.ErrorDetails.Message) { + $body = ($ErrorRecord.ErrorDetails.Message -split '\r?\n\r?\n', 2)[-1].Trim() + if ($body.StartsWith('{')) { + try { + $graphError = ($body | ConvertFrom-Json).error + if ($graphError.code) { $text = "$($graphError.code): $($graphError.message)" } + } + catch { + # Not a Graph JSON body - keep the exception message. + } + } + } + + [pscustomobject]@{ Status = $status; Text = $text } +} + # ---- Pre-flight: module ---- if (-not (Get-Module -ListAvailable -Name Microsoft.Graph.Authentication)) { Write-Host "[Setup] Microsoft.Graph.Authentication not installed. Installing for current user..." -ForegroundColor Yellow @@ -188,7 +220,7 @@ Write-Host "[Auth] Granted scopes: $($ctx.Scopes -join ', ')" # carries a fixed scope set (incl. Directory.AccessAsUser.All) instead of the requested scopes, # and its effective rights follow the signed-in user's directory role. if (-not $identityConnected -and -not ($ctx.Scopes -contains 'AppRoleAssignment.ReadWrite.All')) { - Write-Warning "Scope 'AppRoleAssignment.ReadWrite.All' was NOT granted. The signed-in user likely lacks the required role (Global Admin / Privileged Role Admin / Cloud App Admin)." + Write-Warning "Scope 'AppRoleAssignment.ReadWrite.All' was NOT granted. The signed-in user likely lacks the required role (Global Administrator or Privileged Role Administrator)." if (-not $VerifyOnly) { return } } @@ -262,6 +294,7 @@ $action = if ($Revoke) { 'Revoke' } else { 'Grant' } Write-Host "[4/4] $action mode - processing $($Permissions.Count) permission(s)..." -ForegroundColor Cyan $results = @() +$graphForbidden = $false foreach ($permName in $Permissions) { $appRole = $graphSp.appRoles | Where-Object { $_.value -eq $permName -and $_.allowedMemberTypes -contains 'Application' @@ -288,7 +321,9 @@ foreach ($permName in $Permissions) { $results += [pscustomobject]@{ Permission = $permName; Status = 'Revoked' } } catch { - Write-Warning " [FAIL] '$permName' revoke failed: $($_.Exception.Message)" + $failure = Get-GraphFailure $_ + if ($failure.Status -eq 403) { $graphForbidden = $true } + Write-Warning " [FAIL] '$permName' revoke failed: $($failure.Text)" $results += [pscustomobject]@{ Permission = $permName; Status = 'Failed' } } } @@ -315,7 +350,9 @@ foreach ($permName in $Permissions) { $results += [pscustomobject]@{ Permission = $permName; Status = 'Granted' } } catch { - Write-Warning " [FAIL] '$permName' grant failed: $($_.Exception.Message)" + $failure = Get-GraphFailure $_ + if ($failure.Status -eq 403) { $graphForbidden = $true } + Write-Warning " [FAIL] '$permName' grant failed: $($failure.Text)" $results += [pscustomobject]@{ Permission = $permName; Status = 'Failed' } } } @@ -324,6 +361,9 @@ foreach ($permName in $Permissions) { Write-Host "" Write-Host "=========== Summary ===========" -ForegroundColor Cyan $results | Format-Table -AutoSize | Out-Host +if ($graphForbidden) { + Write-Warning "Microsoft Graph refused the change (403): the signed-in account may not manage Microsoft Graph application permissions. Sign in as Global Administrator or Privileged Role Administrator (with PIM, activate the role first) and run the script again." +} Write-Host "" Write-Host "Note: token-cached backends may need up to ~1h to see the new permission set." -ForegroundColor Yellow Write-Host " Use 'Refresh permission status' in the admin UI to apply changes immediately." -ForegroundColor Yellow diff --git a/src/Web/autopilot-monitor-web/app/settings/components/AppHomingAddOnStep.tsx b/src/Web/autopilot-monitor-web/app/settings/components/AppHomingAddOnStep.tsx index ad5e01746..2d6d7f799 100644 --- a/src/Web/autopilot-monitor-web/app/settings/components/AppHomingAddOnStep.tsx +++ b/src/Web/autopilot-monitor-web/app/settings/components/AppHomingAddOnStep.tsx @@ -59,11 +59,10 @@ export function AppHomingAddOnStep({ missingRoles, command, busy, onDetectExisti ))}

- Run the command below once with an account that can assign application permissions - (Global Administrator, Privileged Role Administrator or{" "} - Cloud Application Administrator) — Azure Cloud Shell is the easiest - place. Then click Detect existing access: the switch completes - automatically. + Run the command below once, signed in as a Global Administrator or{" "} + Privileged Role Administrator (only these Entra roles can grant Microsoft + Graph application permissions) — Azure Cloud Shell is the easiest place. Then click{" "} + Detect existing access: the switch completes automatically.

 {command}
diff --git a/src/Web/autopilot-monitor-web/app/settings/components/__tests__/AppHomingAddOnStep.test.ts b/src/Web/autopilot-monitor-web/app/settings/components/__tests__/AppHomingAddOnStep.test.ts
index a444bc291..81c06b954 100644
--- a/src/Web/autopilot-monitor-web/app/settings/components/__tests__/AppHomingAddOnStep.test.ts
+++ b/src/Web/autopilot-monitor-web/app/settings/components/__tests__/AppHomingAddOnStep.test.ts
@@ -17,6 +17,10 @@ describe("AppHomingAddOnStep", () => {
     expect(html).toContain('-ClientId "886ab5e2-0000-0000-0000-000000000000"');
     expect(html).toContain("Copy command");
     expect(html).toContain("Detect existing access");
+    // Only these two Entra roles can grant Microsoft Graph application permissions.
+    expect(html).toContain("Global Administrator");
+    expect(html).toContain("Privileged Role Administrator");
+    expect(html).not.toContain("Cloud Application Administrator");
     expect(html).toContain(`href="${DOCS_URL}/troubleshooting-and-support/app-registration-migration#optional-graph-add-on-permissions"`);
     expect(html).toContain(`href="${DOCS_URL}/reference/optional-graph-permissions"`);
     // Progress, not an error: stays in the blue family of the funnel banner.
diff --git a/src/Web/autopilot-monitor-web/app/settings/tenant/sections/SectionOptionalGraphCapabilities.tsx b/src/Web/autopilot-monitor-web/app/settings/tenant/sections/SectionOptionalGraphCapabilities.tsx
index c14ec35dd..11efc4a60 100644
--- a/src/Web/autopilot-monitor-web/app/settings/tenant/sections/SectionOptionalGraphCapabilities.tsx
+++ b/src/Web/autopilot-monitor-web/app/settings/tenant/sections/SectionOptionalGraphCapabilities.tsx
@@ -237,7 +237,8 @@ export function SectionOptionalGraphCapabilities() {
         

PowerShell grant command

- Run the commands below as a tenant administrator in{" "} + Run the commands below signed in as a Global Administrator or Privileged Role Administrator + (only these Entra roles can grant Microsoft Graph application permissions) in{" "}