diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/AppHomingServiceTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/AppHomingServiceTests.cs index ff11d8dc..6d5215ef 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/AppHomingServiceTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/AppHomingServiceTests.cs @@ -10,13 +10,16 @@ using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging.Abstractions; using Moq; +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; namespace AutopilotMonitor.Functions.Tests; /// -/// Unit tests for — funnel eligibility, the consent-driven -/// auto-flip, and the flip persist side-effect chain (save + cache invalidation + audit + -/// ops events). The consent probe and the kill-switch flag are mocked at their virtual seams. +/// Unit tests for — funnel eligibility, the consent probe's +/// role-superset rule, the consent-driven auto-flip, and the flip persist side-effect chain +/// (save + cache invalidation + audit + ops events). Token minting and the kill-switch flag +/// are mocked at their virtual seams. /// public class AppHomingServiceTests { @@ -24,6 +27,8 @@ public class AppHomingServiceTests private const string PrimaryId = "aaaaaaaa-0000-0000-0000-000000000001"; private const string LegacyId = "bbbbbbbb-0000-0000-0000-000000000002"; private const string Actor = "admin@contoso.com"; + private const string ValidationRole = "DeviceManagementServiceConfig.Read.All"; + private const string ScriptsRole = "DeviceManagementScripts.Read.All"; private readonly Mock _adminConfigMock; private readonly Mock _tenantConfigMock; @@ -93,14 +98,16 @@ public class AppHomingServiceTests opsService, new TelemetryClient(new TelemetryConfiguration { DisableTelemetry = true })); - // Defaults: flag on, legacy-homed config exists, probe succeeds. FlipAsync reads via - // the cache-bypassing GetConfigurationFreshAsync (read-modify-write on the whole entity). + // Defaults: flag on, legacy-homed config exists, both apps mint a role-less token (the + // superset rule passes trivially). FlipAsync reads via the cache-bypassing + // GetConfigurationFreshAsync (read-modify-write on the whole entity). _adminConfigMock.Setup(x => x.IsSelfServiceAppHomingEnabledAsync()).ReturnsAsync(true); var config = LegacyHomedConfig(); _tenantConfigMock.Setup(x => x.GetConfigurationFreshAsync(TenantId)).ReturnsAsync(config); _tenantConfigMock.Setup(x => x.SaveConfigurationAsync(It.IsAny(), It.IsAny(), It.IsAny())) .Returns(Task.CompletedTask); - SetupProbe(GraphTokenResult.Success("tok")); + SetupProbe(GraphTokenResult.Success(Jwt())); + SetupLegacyToken(GraphTokenResult.Success(Jwt())); } private static TenantConfiguration LegacyHomedConfig() @@ -110,12 +117,35 @@ public class AppHomingServiceTests return config; } + /// Unsigned app-only token carrying the given Graph application roles (the probe never validates signatures). + private static string Jwt(params string[] roles) + { + var jwt = new JwtSecurityToken( + issuer: "https://sts.windows.net/test", + audience: "https://graph.microsoft.com", + claims: roles.Select(r => new Claim("roles", r)), + notBefore: null, + expires: DateTime.UtcNow.AddHours(1)); + return new JwtSecurityTokenHandler().WriteToken(jwt); + } + + /// What the PRIMARY app's app-only token mint returns. private void SetupProbe(GraphTokenResult result) => _graphTokenMock .Setup(x => x.GetAccessTokenForAppAsync(TenantId, It.Is(c => !c.IsLegacy), It.IsAny())) .ReturnsAsync(result); + /// What the LEGACY app's app-only token mint returns (the superset baseline). + private void SetupLegacyToken(GraphTokenResult result) => + _graphTokenMock + .Setup(x => x.GetAccessTokenForAppAsync(TenantId, It.Is(c => c.IsLegacy), + It.IsAny())) + .ReturnsAsync(result); + + private void VerifyNeverSaved() => + _tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + // ── IsFunnelEligibleAsync ─────────────────────────────────────────────── [Fact] @@ -156,8 +186,104 @@ public class AppHomingServiceTests Assert.True(await _sut.IsFunnelEligibleAsync(config)); } + // ── ProbePrimaryConsentAsync (role-superset rule) ─────────────────────── + + [Fact] + public async Task Probe_refuses_when_primary_lacks_a_role_the_legacy_app_holds() + { + // The trap of the primary-default sign-in: a delegated User.Read consent created the + // primary SP (token acquirable, no roles) while the legacy app carries the validation + // role — flipping would break device validation until re-consent. + SetupProbe(GraphTokenResult.Success(Jwt())); + SetupLegacyToken(GraphTokenResult.Success(Jwt(ValidationRole))); + + var probe = await _sut.ProbePrimaryConsentAsync(TenantId); + + Assert.False(probe.Succeeded); + Assert.False(probe.IsTransient); + Assert.Equal(new[] { ValidationRole }, probe.MissingRoles); + } + + [Fact] + public async Task Probe_succeeds_when_primary_roles_cover_the_legacy_roles() + { + SetupProbe(GraphTokenResult.Success(Jwt(ValidationRole, ScriptsRole))); + SetupLegacyToken(GraphTokenResult.Success(Jwt(ValidationRole))); + + var probe = await _sut.ProbePrimaryConsentAsync(TenantId); + + Assert.True(probe.Succeeded); + Assert.Null(probe.MissingRoles); + } + + [Fact] + public async Task Probe_compares_roles_case_insensitively() + { + SetupProbe(GraphTokenResult.Success(Jwt(ValidationRole.ToUpperInvariant()))); + SetupLegacyToken(GraphTokenResult.Success(Jwt(ValidationRole))); + + Assert.True((await _sut.ProbePrimaryConsentAsync(TenantId)).Succeeded); + } + + [Fact] + public async Task Probe_succeeds_for_role_less_tenants() + { + // Neither app holds a Graph application role (validation never enabled): nothing to + // lose, the flip stays free so such tenants keep migrating on their own. + var probe = await _sut.ProbePrimaryConsentAsync(TenantId); + + Assert.True(probe.Succeeded); + } + + [Fact] + public async Task Probe_is_transient_when_the_legacy_token_is_transient() + { + SetupProbe(GraphTokenResult.Success(Jwt(ValidationRole))); + SetupLegacyToken(GraphTokenResult.TransientFailure()); + + var probe = await _sut.ProbePrimaryConsentAsync(TenantId); + + Assert.False(probe.Succeeded); + Assert.True(probe.IsTransient); + } + + [Fact] + public async Task Probe_succeeds_when_the_legacy_token_is_permanently_unacquirable() + { + // Legacy SP gone (admin deleted the previous app early): nothing left to lose. + SetupProbe(GraphTokenResult.Success(Jwt())); + SetupLegacyToken(GraphTokenResult.PermanentFailure()); + + Assert.True((await _sut.ProbePrimaryConsentAsync(TenantId)).Succeeded); + } + + [Fact] + public async Task Probe_never_mints_a_legacy_token_when_the_primary_token_fails() + { + SetupProbe(GraphTokenResult.PermanentFailure()); + + var probe = await _sut.ProbePrimaryConsentAsync(TenantId); + + Assert.False(probe.Succeeded); + _graphTokenMock.Verify(x => x.GetAccessTokenForAppAsync(TenantId, It.Is(c => c.IsLegacy), + It.IsAny()), Times.Never); + } + // ── TryAutoFlipToPrimaryAsync ─────────────────────────────────────────── + [Fact] + public async Task AutoFlip_does_not_flip_when_primary_lacks_a_legacy_role() + { + SetupProbe(GraphTokenResult.Success(Jwt())); + SetupLegacyToken(GraphTokenResult.Success(Jwt(ValidationRole))); + + var outcome = await _sut.TryAutoFlipToPrimaryAsync(LegacyHomedConfig(), Actor); + + Assert.Equal(AppHomingAutoFlipOutcome.ProbeFailed, outcome); + VerifyNeverSaved(); + Assert.Empty(_savedOpsEvents); + } + [Fact] public async Task AutoFlip_flips_on_probe_success_with_full_side_effect_chain() { diff --git a/src/Backend/AutopilotMonitor.Functions/Services/AppHomingService.cs b/src/Backend/AutopilotMonitor.Functions/Services/AppHomingService.cs index 21d7bc6e..078e1b06 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/AppHomingService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/AppHomingService.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Threading; using System.Threading.Tasks; using AutopilotMonitor.Functions.Security; @@ -21,11 +22,16 @@ namespace AutopilotMonitor.Functions.Services } /// - /// Result of the primary-app consent probe: an app-only token acquisition under the PRIMARY - /// app's credentials. Succeeded proves an admin consented the primary app in that - /// tenant; IsTransient means "unknown" (network/AAD blip), never "not consented". + /// Result of the primary-app consent probe. Succeeded proves the PRIMARY app is + /// consented in that tenant AND carries every Graph application role the legacy app holds + /// there (see ); MissingRoles + /// lists the legacy roles the primary app lacks when that superset check failed. + /// IsTransient means "unknown" (network/AAD blip), never "not consented". /// - public sealed record AppHomingProbeResult(bool Succeeded, bool IsTransient); + public sealed record AppHomingProbeResult( + bool Succeeded, + bool IsTransient, + IReadOnlyCollection? MissingRoles = null); public enum AppHomingDecisionKind { Allow, AllowNoOp, RequireProbe, Deny } @@ -94,19 +100,65 @@ namespace AutopilotMonitor.Functions.Services } /// - /// Proves (or refutes) admin consent for the PRIMARY app in this tenant by minting an - /// app-only token under the primary credentials, regardless of the tenant's homing. - /// Token acquirability — not the roles claim — is the flip criterion: the flip only moves - /// which app acts for the tenant; feature gates keep their own role-checked probes. + /// Proves (or refutes) that the PRIMARY app may take over this tenant, regardless of the + /// tenant's homing: an app-only token under the primary credentials must be acquirable + /// (the service principal exists ⇒ someone consented the app) AND its roles claim + /// must be a superset of the legacy app's — the flip must never cost the tenant a Graph + /// capability it has today. The superset rule (rather than "has the validation role") + /// keeps role-less tenants flipping freely while blocking the trap where a mere sign-in + /// consent (delegated User.Read, no application role) created the primary SP: with + /// the primary app as the default sign-in app that is a routine event, and flipping on it + /// would silently break device validation until an admin re-consents. + /// A transient failure on either token means "unknown" — never a refusal. /// public virtual async Task ProbePrimaryConsentAsync(string tenantId, CancellationToken ct = default) { - var result = await _graphTokenService.GetAccessTokenForAppAsync(tenantId, _appRegistry.Primary, ct); - return new AppHomingProbeResult( - Succeeded: !string.IsNullOrWhiteSpace(result.AccessToken), - IsTransient: result.IsTransient); + var primary = await _graphTokenService.GetAccessTokenForAppAsync(tenantId, _appRegistry.Primary, ct); + if (string.IsNullOrWhiteSpace(primary.AccessToken)) + { + return new AppHomingProbeResult(Succeeded: false, IsTransient: primary.IsTransient); + } + + var legacyApp = _appRegistry.Legacy; + if (legacyApp == null) + { + // Parallel window closed: nothing to compare against. + return new AppHomingProbeResult(Succeeded: true, IsTransient: false); + } + + var legacy = await _graphTokenService.GetAccessTokenForAppAsync(tenantId, legacyApp, ct); + if (string.IsNullOrWhiteSpace(legacy.AccessToken) && legacy.IsTransient) + { + return new AppHomingProbeResult(Succeeded: false, IsTransient: true); + } + + // A permanently unacquirable legacy token (SP gone) means there is nothing to lose. + var primaryRoles = ParseRoles(primary.AccessToken); + var legacyRoles = ParseRoles(legacy.AccessToken); + var missing = legacyRoles + .Where(role => !primaryRoles.Contains(role)) + .OrderBy(role => role, StringComparer.OrdinalIgnoreCase) + .ToList(); + if (missing.Count == 0) + { + return new AppHomingProbeResult(Succeeded: true, IsTransient: false); + } + + _logger.LogWarning( + "Tenant {TenantId}: primary app is consented but lacks {MissingCount} legacy Graph role(s) — homing flip refused until re-consent: {MissingRoles}", + tenantId, missing.Count, string.Join(", ", missing)); + return new AppHomingProbeResult(Succeeded: false, IsTransient: false, MissingRoles: missing); } + private static readonly IReadOnlySet EmptyRoles = new HashSet(StringComparer.OrdinalIgnoreCase); + + /// Roles claim of an app-only token; empty for a missing or unparsable token. + private static IReadOnlySet ParseRoles(string? accessToken) => + !string.IsNullOrWhiteSpace(accessToken) + && GraphFeatureDetector.TryParseToken(accessToken, out var roles, out _) + ? roles + : EmptyRoles; + /// /// Consent-driven auto-flip: if the tenant is funnel-eligible and the primary app proves /// consented, flip the homing to primary. Idempotent — primary-homed tenants (and any diff --git a/src/Web/autopilot-monitor-web/app/dashboard/components/AppHomingBanner.tsx b/src/Web/autopilot-monitor-web/app/dashboard/components/AppHomingBanner.tsx new file mode 100644 index 00000000..158038ee --- /dev/null +++ b/src/Web/autopilot-monitor-web/app/dashboard/components/AppHomingBanner.tsx @@ -0,0 +1,94 @@ +"use client"; + +import { useEffect, useSyncExternalStore } from "react"; +import Link from "next/link"; +import { DOCS_URL } from "@/utils/config"; +import { DOCS_PATHS } from "@/lib/docsPaths"; +import { trackEvent } from "@/lib/appInsights"; + +// Session-scoped dismissal (same shape as ActivelyDevelopedBanner): hidden across reloads in +// this tab, back in every new tab — the switch is a one-time minute of admin work, so the +// nudge stays persistent but never blocks anything. Module-level store because a same-tab +// sessionStorage write fires no "storage" event. +const DismissKey = "appHomingBannerDismissed"; +const listeners = new Set<() => void>(); + +const getSnapshot = (): boolean => sessionStorage.getItem(DismissKey) === "1"; +const getServerSnapshot = (): boolean => false; + +function subscribe(onStoreChange: () => void): () => void { + listeners.add(onStoreChange); + return () => listeners.delete(onStoreChange); +} + +function dismiss(): void { + sessionStorage.setItem(DismissKey, "1"); + for (const listener of [...listeners]) listener(); +} + +/** + * Dashboard nudge of the dual app-reg migration, shown to tenant admins while the tenant still + * runs on the previous app registration and the self-service switch is open + * (feature-flags `appHomingFunnelActive`). Keyed on the TENANT's state, not on which app this + * browser signed in with: the fix is a tenant-wide admin consent. The consent flow itself + * lives in Settings → Autopilot Validation (its redirect returns there) — this banner only + * links to it and to the docs. + */ +export function AppHomingBanner() { + const dismissed = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); + + useEffect(() => { + if (!dismissed) trackEvent("app_homing_banner_shown"); + }, [dismissed]); + + if (dismissed) return null; + + return ( +
+
+ + + +

+ Please switch to the new Autopilot Monitor app registration.{" "} + Your organization still signs in through the previous app. A one-time admin consent + (about a minute) switches it over — until then, colleagues signing in from a new + browser or device see an extra consent prompt.{" "} + trackEvent("app_homing_banner_clicked", { link: "docs" })} + > + Read why and how + + . +

+
+
+ trackEvent("app_homing_banner_clicked", { link: "settings" })} + > + Switch in Settings + + +
+
+ ); +} diff --git a/src/Web/autopilot-monitor-web/app/dashboard/components/__tests__/AppHomingBanner.test.ts b/src/Web/autopilot-monitor-web/app/dashboard/components/__tests__/AppHomingBanner.test.ts new file mode 100644 index 00000000..63c7d242 --- /dev/null +++ b/src/Web/autopilot-monitor-web/app/dashboard/components/__tests__/AppHomingBanner.test.ts @@ -0,0 +1,20 @@ +import { describe, it, expect } from "vitest"; +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { AppHomingBanner } from "../AppHomingBanner"; +import { DOCS_URL } from "@/utils/config"; + +describe("AppHomingBanner", () => { + it("renders the nudge with the settings CTA, the docs link and a per-tab dismiss control", () => { + const html = renderToStaticMarkup(createElement(AppHomingBanner)); + expect(html).toContain("Please switch to the new Autopilot Monitor app registration."); + expect(html).toContain('href="/settings/tenant/autopilot"'); + expect(html).toContain(`href="${DOCS_URL}/troubleshooting-and-support/app-registration-migration"`); + expect(html).toContain('target="_blank"'); + expect(html).toContain('rel="noopener noreferrer"'); + expect(html).toContain('aria-label="Hide for this browser tab"'); + // Stays in the blue family of the settings funnel banner (no new colour family). + expect(html).toContain("bg-blue-50"); + expect(html).not.toMatch(/bg-(amber|red|green)-/); + }); +}); diff --git a/src/Web/autopilot-monitor-web/app/dashboard/hooks/__tests__/useTenantSecurityConfig.test.ts b/src/Web/autopilot-monitor-web/app/dashboard/hooks/__tests__/useTenantSecurityConfig.test.ts new file mode 100644 index 00000000..d0533795 --- /dev/null +++ b/src/Web/autopilot-monitor-web/app/dashboard/hooks/__tests__/useTenantSecurityConfig.test.ts @@ -0,0 +1,22 @@ +import { describe, it, expect } from "vitest"; +import { summarizeTenantSecurityConfig } from "../useTenantSecurityConfig"; + +describe("summarizeTenantSecurityConfig", () => { + it("shows the app-homing banner only on an explicit true", () => { + expect(summarizeTenantSecurityConfig({ validateAutopilotDevice: true, appHomingFunnelActive: true }).appHomingFunnelActive).toBe(true); + expect(summarizeTenantSecurityConfig({ validateAutopilotDevice: true, appHomingFunnelActive: false }).appHomingFunnelActive).toBe(false); + // Older backend without the field: never nag. + expect(summarizeTenantSecurityConfig({ validateAutopilotDevice: true }).appHomingFunnelActive).toBe(false); + }); + + it("keys the validation banner on the flag and the contact nag on Pro editions only", () => { + const community = summarizeTenantSecurityConfig({ validateAutopilotDevice: false, edition: "community", contactEmailSet: false }); + expect(community.serialValidationEnabled).toBe(false); + expect(community.proContactMissing).toBe(false); + + const pro = summarizeTenantSecurityConfig({ validateAutopilotDevice: true, edition: "pro", contactEmailSet: false, companyNameSet: true }); + expect(pro.serialValidationEnabled).toBe(true); + expect(pro.proContactMissing).toBe(true); + expect(pro.proContactMissingParts).toEqual(["contact address"]); + }); +}); diff --git a/src/Web/autopilot-monitor-web/app/dashboard/hooks/useTenantSecurityConfig.ts b/src/Web/autopilot-monitor-web/app/dashboard/hooks/useTenantSecurityConfig.ts index e776301b..78397b16 100644 --- a/src/Web/autopilot-monitor-web/app/dashboard/hooks/useTenantSecurityConfig.ts +++ b/src/Web/autopilot-monitor-web/app/dashboard/hooks/useTenantSecurityConfig.ts @@ -15,11 +15,13 @@ type AddNotification = ( href?: string, ) => void; -interface TenantConfigurationSummary { +/** The subset of the feature-flags response the dashboard banners read. */ +export interface TenantConfigurationSummary { validateAutopilotDevice: boolean; edition?: string; contactEmailSet?: boolean; companyNameSet?: boolean; + appHomingFunnelActive?: boolean; } interface User { @@ -39,14 +41,39 @@ export interface TenantSecuritySummary { proContactMissing: boolean; /** Display labels of the missing parts ("contact address", "company name"); empty when complete. */ proContactMissingParts: string[]; + /** + * Dual app-reg window: the tenant still runs on the previous app registration and the + * self-service switch is open — drives the blue "switch to the new app" banner. Requires an + * EXPLICIT true from the backend; loading/error/older-backend states show nothing. + */ + appHomingFunnelActive: boolean; +} + +const EMPTY_SUMMARY: TenantSecuritySummary = { + serialValidationEnabled: null, + proContactMissing: false, + proContactMissingParts: [], + appHomingFunnelActive: false, +}; + +/** Pure projection of a feature-flags body onto the banner summary (unit-tested). */ +export function summarizeTenantSecurityConfig(data: TenantConfigurationSummary): TenantSecuritySummary { + const isPro = data.edition === "pro" || data.edition === "enterprise"; + const missingParts = isPro ? missingContactProfileParts(data) : []; + return { + serialValidationEnabled: !!data.validateAutopilotDevice, + proContactMissing: missingParts.length > 0, + proContactMissingParts: missingParts, + appHomingFunnelActive: data.appHomingFunnelActive === true, + }; } /** * Fetches the tenant's feature-flags summary to drive the dashboard banners: - * the red "Autopilot Device Validation is disabled" banner and the amber - * Pro-without-contact-address banner. + * the red "Autopilot Device Validation is disabled" banner, the amber + * Pro-without-contact-address banner, and the blue app-registration migration banner. * - * Skips the fetch for regular users (they never see the dashboard). + * Skips the fetch for users without a tenant role (they never see the dashboard). */ export function useTenantSecurityConfig( tenantId: string | null | undefined, @@ -54,11 +81,7 @@ export function useTenantSecurityConfig( getAccessToken: (forceRefresh?: boolean) => Promise, addNotification: AddNotification, ): TenantSecuritySummary { - const [summary, setSummary] = useState({ - serialValidationEnabled: null, - proContactMissing: false, - proContactMissingParts: [], - }); + const [summary, setSummary] = useState(EMPTY_SUMMARY); useEffect(() => { // Wait until both tenant and user are resolved. Without the !user guard @@ -75,23 +98,17 @@ export function useTenantSecurityConfig( const response = await dedupedAuthFetch(api.config.featureFlags(tenantId), getAccessToken); if (!response.ok) { - setSummary({ serialValidationEnabled: null, proContactMissing: false, proContactMissingParts: [] }); + setSummary(EMPTY_SUMMARY); return; } const data: TenantConfigurationSummary = await response.json(); - const isPro = data.edition === "pro" || data.edition === "enterprise"; - const missingParts = isPro ? missingContactProfileParts(data) : []; - setSummary({ - serialValidationEnabled: !!data.validateAutopilotDevice, - proContactMissing: missingParts.length > 0, - proContactMissingParts: missingParts, - }); + setSummary(summarizeTenantSecurityConfig(data)); } catch (error) { if (error instanceof TokenExpiredError) { addNotification('error', 'Session Expired', error.message, 'session-expired-error'); } - setSummary({ serialValidationEnabled: null, proContactMissing: false, proContactMissingParts: [] }); + setSummary(EMPTY_SUMMARY); } }; diff --git a/src/Web/autopilot-monitor-web/app/dashboard/page.tsx b/src/Web/autopilot-monitor-web/app/dashboard/page.tsx index 870ce143..18a9dcb1 100644 --- a/src/Web/autopilot-monitor-web/app/dashboard/page.tsx +++ b/src/Web/autopilot-monitor-web/app/dashboard/page.tsx @@ -13,6 +13,7 @@ import { useNotifications } from "../../contexts/NotificationContext"; import { StatsCard } from "./components/StatsCards"; import { WelcomeMessage } from "./components/WelcomeMessage"; import { ActivelyDevelopedBanner } from "./components/ActivelyDevelopedBanner"; +import { AppHomingBanner } from "./components/AppHomingBanner"; import { SessionTable } from "./components/SessionTable"; import { TenantFilterBar } from "./components/TenantFilterBar"; import { DeleteConfirmModal, BlockConfirmModal } from "./components/ConfirmationModals"; @@ -242,7 +243,7 @@ function HomeContent() { } }, [user, router]); - const { serialValidationEnabled, proContactMissing, proContactMissingParts } = useTenantSecurityConfig(tenantId, user, getAccessToken, addNotification); + const { serialValidationEnabled, proContactMissing, proContactMissingParts, appHomingFunnelActive } = useTenantSecurityConfig(tenantId, user, getAccessToken, addNotification); const rawTenantList = useTenantList(crossTenant, getAccessToken); // Delegated: bound the tenant filter's autocomplete to the managed subset (defense in depth on top of the // backend-bounded config/all), plus the caller's own HOME tenant when they hold a member role there — @@ -348,6 +349,13 @@ function HomeContent() { {/* Feedback & bug report banner (session-dismissable, telemetry-instrumented) */} + {/* Dual app-reg migration nudge (blue, session-dismissable): the TENANT still runs on + the previous app registration. Admins only — they own the consent; delegated admins + are excluded (the link would target their own tenant, not the viewed one). */} + {appHomingFunnelActive && !isDelegated && (user?.isTenantAdmin || user?.isGlobalAdmin) && ( + + )} + {serialValidationEnabled === false && (
diff --git a/src/Web/autopilot-monitor-web/app/settings/tenant/sections/SectionAutopilotValidation.tsx b/src/Web/autopilot-monitor-web/app/settings/tenant/sections/SectionAutopilotValidation.tsx index e878c764..1e17db33 100644 --- a/src/Web/autopilot-monitor-web/app/settings/tenant/sections/SectionAutopilotValidation.tsx +++ b/src/Web/autopilot-monitor-web/app/settings/tenant/sections/SectionAutopilotValidation.tsx @@ -3,6 +3,7 @@ import { useAuth } from "@/contexts/AuthContext"; import { useGlobalAdminUi } from "@/hooks/useGlobalAdminUi"; import { legacyConfigured, switchAuthApp } from "@/lib/authApp"; +import { DOCS_PATHS } from "@/lib/docsPaths"; import { DOCS_URL } from "@/utils/config"; import { useTenantConfig } from "../../TenantConfigContext"; import { TenantNotifications } from "../../TenantNotifications"; @@ -56,12 +57,22 @@ export function SectionAutopilotValidation() { Your tenant now runs on the new Autopilot Monitor app registration. Current sessions keep working; every next sign-in uses the new app automatically.

- +
+ + + What to check after the switch + +
) : appHomingFunnelActive ? (
@@ -87,12 +98,12 @@ export function SectionAutopilotValidation() { (DeviceManagementServiceConfig.Read.All).

- See the documentation for details on the required permissions + Read what changes and what to check before you migrate diff --git a/src/Web/autopilot-monitor-web/components/ProtectedRoute.tsx b/src/Web/autopilot-monitor-web/components/ProtectedRoute.tsx index da51d595..25d8653d 100644 --- a/src/Web/autopilot-monitor-web/components/ProtectedRoute.tsx +++ b/src/Web/autopilot-monitor-web/components/ProtectedRoute.tsx @@ -7,6 +7,8 @@ import { savePostLoginReturnUrl } from "../lib/postLoginReturn"; import { isOnPublicHost } from "../lib/hostRouting"; import { consumeLoginDeclined, legacyConfigured, switchAuthApp } from "../lib/authApp"; import { activeAuthApp } from "../lib/msalConfig"; +import { DOCS_PATHS } from "../lib/docsPaths"; +import { DOCS_URL } from "../utils/config"; import { useAdminMode } from "../hooks/useAdminMode"; interface ProtectedRouteProps { @@ -134,6 +136,18 @@ export function ProtectedRoute({ children, requireGlobalAdmin = false, requireGl

)} + {legacyConfigured() && ( +

+ + Why are there two Autopilot Monitor apps? + +

+ )}
); diff --git a/src/Web/autopilot-monitor-web/components/landing/AuthGate.tsx b/src/Web/autopilot-monitor-web/components/landing/AuthGate.tsx index 14269773..463956b8 100644 --- a/src/Web/autopilot-monitor-web/components/landing/AuthGate.tsx +++ b/src/Web/autopilot-monitor-web/components/landing/AuthGate.tsx @@ -5,9 +5,12 @@ import { useRouter } from "next/navigation"; import { useEffect } from "react"; import type { Route } from "next"; import { trustedRoute } from "../../lib/routes"; -import { consumePostLoginReturnUrl } from "../../lib/postLoginReturn"; +import { consumePostLoginReturnUrl, savePostLoginReturnUrl } from "../../lib/postLoginReturn"; import { hasOwnTenantOrPlatformRole, hasTenantReadScope } from "../../lib/tenantScope"; -import { PORTAL_HOST, shouldCrossOriginToPortal } from "../../lib/hostRouting"; +import { portalHandoverUrl, shouldCrossOriginToPortal } from "../../lib/hostRouting"; +import { consumePendingRehome, getSelectedAuthApp, legacyConfigured, switchAuthApp, tryBeginRehome } from "../../lib/authApp"; +import { activeAuthApp } from "../../lib/msalConfig"; +import { trackEvent } from "../../lib/appInsights"; /** * Invisible client component that handles auth redirect logic. @@ -37,11 +40,22 @@ export function AuthGate() { } else { target = "/progress"; } + // Dual app-reg window: the sign-in that just completed ran on the other app than the + // tenant is homed on (fresh browser, wrong default). Same-origin: re-home NOW — switch + // the browser to the homed app and land on the target, where ProtectedRoute completes + // the sign-in silently via the Entra session and returns here with the deep link intact. + // One hop per tab; the pending request is consumed either way. + const rehome = consumePendingRehome(); // On the public host, hand over to the portal origin in ONE full-page // navigation instead of router.push + HostRoutingGuard bounce. Auth state - // is per-origin — the portal side runs its own (silent) MSAL sign-in. + // is per-origin — the portal side runs its own (silent) MSAL sign-in, on + // the app this browser just learned (passed along as ?authapp=). if (shouldCrossOriginToPortal()) { - window.location.href = `https://${PORTAL_HOST}${target}`; + window.location.href = portalHandoverUrl(target, legacyConfigured() ? getSelectedAuthApp() : null); + } else if (rehome && tryBeginRehome()) { + savePostLoginReturnUrl(target); + trackEvent("auth_app_rehomed", { from: activeAuthApp, to: rehome }); + switchAuthApp(rehome, target); } else { router.replace(target); } diff --git a/src/Web/autopilot-monitor-web/contexts/AuthContext.tsx b/src/Web/autopilot-monitor-web/contexts/AuthContext.tsx index 7dcd48e4..a1c5a78f 100644 --- a/src/Web/autopilot-monitor-web/contexts/AuthContext.tsx +++ b/src/Web/autopilot-monitor-web/contexts/AuthContext.tsx @@ -13,8 +13,10 @@ import { loginFallbackActive, markLoginDeclined, otherApp, + requestRehome, setLoginAttemptApp, setSelectedAuthApp, + shouldRehome, tryBeginLoginFallback, } from '@/lib/authApp'; import { api } from '@/lib/api'; @@ -36,6 +38,11 @@ let msalReady = false; // otherwise a cold backend would keep the UI on a white screen. let prefetchedAuthMePromise: Promise | null = null; +// Post-login re-homing inputs (see lib/authApp.ts shouldRehome): set by the redirect handler +// below for the page load that completed a redirect sign-in, consumed by the first auth/me. +let redirectLoginCompleted = false; +let redirectLoginViaFallback = false; + /** * Safety net of the dual app-reg model, for the rare browser that lands on an app its tenant * never consented to (no stored selection, wrong default): Entra reports "needs admin @@ -87,7 +94,9 @@ const msalInitPromise = msalInstance // (the "learn" half of the model — future boots go straight to the right one). // A still-set fallback marker means THIS login only worked via the one-shot cross-app // retry — the strategic signal that one of the two apps is not consented in the tenant. - if (loginFallbackActive()) { + redirectLoginCompleted = true; + redirectLoginViaFallback = loginFallbackActive(); + if (redirectLoginViaFallback) { trackEvent('auth_app_fallback_succeeded', { app: activeAuthApp }); } setSelectedAuthApp(activeAuthApp); @@ -201,9 +210,14 @@ interface AuthContextType { /** * Background learning of the dual app-reg model: auth/me returns which app registration the * tenant is homed on; storing it makes the NEXT login on this browser use that app directly. - * No reload — the current session keeps its valid token (both audiences are accepted). + * No reload here — the current session keeps its valid token (both audiences are accepted). * This is also how an operator flip propagates: flip → next login of every user runs via the * new app, at which point the tenant's admin consent already exists. + * + * Exception: the page load that just completed a redirect sign-in on the OTHER app (fresh + * browser, wrong default). That browser is re-homed immediately — AuthGate, the post-login + * hop, switches it to the homed app and the sign-in completes silently — so the first session + * never runs on an app the tenant did not set up (Entra app roles, Conditional Access scope). */ function learnHomedAppFromAuthMe(data: AuthMeResponse): void { const homedApp = data.homedApp; @@ -214,6 +228,17 @@ function learnHomedAppFromAuthMe(data: AuthMeResponse): void { trackEvent('auth_app_homing_learned', { homedApp }); } setSelectedAuthApp(homedApp); + if (shouldRehome({ + homedApp, + activeApp: activeAuthApp, + redirectLoginCompleted, + viaFallback: redirectLoginViaFallback, + })) { + requestRehome(homedApp); + } + // The redirect-login page load is spent either way: a later refreshUserInfo must not + // re-request a re-home. + redirectLoginCompleted = false; } } diff --git a/src/Web/autopilot-monitor-web/lib/__tests__/authApp.test.ts b/src/Web/autopilot-monitor-web/lib/__tests__/authApp.test.ts index df148a3a..66d97b05 100644 --- a/src/Web/autopilot-monitor-web/lib/__tests__/authApp.test.ts +++ b/src/Web/autopilot-monitor-web/lib/__tests__/authApp.test.ts @@ -2,14 +2,19 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { classifyEntraAuthError, consumeLoginDeclined, + consumePendingRehome, getBootAuthApp, getSelectedAuthApp, legacyConfigured, markLoginDeclined, otherApp, + requestRehome, setLoginAttemptApp, setSelectedAuthApp, + shouldRehome, + switchAuthApp, tryBeginLoginFallback, + tryBeginRehome, } from "../authApp"; const LEGACY_ID = "1a400946-62c1-4ab4-aa37-f730ac89704d"; @@ -33,7 +38,7 @@ function stubWindow(search = ""): void { vi.stubGlobal("window", { localStorage: makeStorage(), sessionStorage: makeStorage(), - location: { search, reload: vi.fn() }, + location: { search, reload: vi.fn(), assign: vi.fn() }, }); } @@ -151,3 +156,69 @@ describe("safety-net primitives", () => { expect(otherApp("primary")).toBe("legacy"); }); }); + +describe("post-login re-homing", () => { + const base = { homedApp: "legacy" as const, activeApp: "primary" as const, redirectLoginCompleted: true, viaFallback: false }; + + it("re-homes only when a redirect sign-in just completed on the other app", () => { + stubWindow(); + vi.stubEnv("NEXT_PUBLIC_ENTRA_LEGACY_CLIENT_ID", LEGACY_ID); + expect(shouldRehome(base)).toBe(true); + expect(shouldRehome({ ...base, homedApp: "primary" })).toBe(false); + // A later auth/me refresh in the same page life is not a sign-in. + expect(shouldRehome({ ...base, redirectLoginCompleted: false })).toBe(false); + }); + + it("never re-homes after a cross-app fallback — the homed app is proven unusable there", () => { + stubWindow(); + vi.stubEnv("NEXT_PUBLIC_ENTRA_LEGACY_CLIENT_ID", LEGACY_ID); + expect(shouldRehome({ ...base, viaFallback: true })).toBe(false); + }); + + it("is inert outside the parallel window", () => { + stubWindow(); + expect(legacyConfigured()).toBe(false); + expect(shouldRehome(base)).toBe(false); + }); + + it("requestRehome / consumePendingRehome is a one-shot handoff", () => { + expect(consumePendingRehome()).toBeNull(); + requestRehome("legacy"); + expect(consumePendingRehome()).toBe("legacy"); + expect(consumePendingRehome()).toBeNull(); + }); + + it("tryBeginRehome is one-shot per tab", () => { + stubWindow(); + expect(tryBeginRehome()).toBe(true); + expect(tryBeginRehome()).toBe(false); + }); + + it("switchAuthApp with a target navigates there instead of reloading, on the new app with MSAL state purged", () => { + stubWindow(); + vi.stubEnv("NEXT_PUBLIC_ENTRA_LEGACY_CLIENT_ID", LEGACY_ID); + setSelectedAuthApp("primary"); + setLoginAttemptApp("primary"); + window.sessionStorage.setItem("msal.account.keys", "x"); + window.sessionStorage.setItem("abc.login.request", "y"); + window.sessionStorage.setItem("apm:postLoginReturnUrl", "/sessions?id=1"); + + switchAuthApp("legacy", "/sessions?id=1"); + + expect(window.localStorage.getItem("am_auth_app")).toBe("legacy"); + expect(window.sessionStorage.getItem("am_login_attempt")).toBeNull(); + expect(window.sessionStorage.getItem("msal.account.keys")).toBeNull(); + expect(window.sessionStorage.getItem("abc.login.request")).toBeNull(); + // The deep link survives the purge — only MSAL keys are doomed. + expect(window.sessionStorage.getItem("apm:postLoginReturnUrl")).toBe("/sessions?id=1"); + expect(window.location.assign).toHaveBeenCalledWith("/sessions?id=1"); + expect(window.location.reload).not.toHaveBeenCalled(); + }); + + it("switchAuthApp without a target reloads in place (manual switch)", () => { + stubWindow(); + switchAuthApp("primary"); + expect(window.location.reload).toHaveBeenCalled(); + expect(window.location.assign).not.toHaveBeenCalled(); + }); +}); diff --git a/src/Web/autopilot-monitor-web/lib/__tests__/hostRouting.handover.test.ts b/src/Web/autopilot-monitor-web/lib/__tests__/hostRouting.handover.test.ts new file mode 100644 index 00000000..c98e5945 --- /dev/null +++ b/src/Web/autopilot-monitor-web/lib/__tests__/hostRouting.handover.test.ts @@ -0,0 +1,16 @@ +import { describe, it, expect } from "vitest"; +import { PORTAL_HOST, portalHandoverUrl } from "../hostRouting"; + +describe("portalHandoverUrl", () => { + it("passes the learned app registration along as ?authapp=", () => { + expect(portalHandoverUrl("/dashboard", "legacy")).toBe(`https://${PORTAL_HOST}/dashboard?authapp=legacy`); + }); + + it("keeps an existing query string on the target", () => { + expect(portalHandoverUrl("/sessions?id=abc", "primary")).toBe(`https://${PORTAL_HOST}/sessions?id=abc&authapp=primary`); + }); + + it("adds nothing outside the parallel window", () => { + expect(portalHandoverUrl("/progress", null)).toBe(`https://${PORTAL_HOST}/progress`); + }); +}); diff --git a/src/Web/autopilot-monitor-web/lib/appHoming.ts b/src/Web/autopilot-monitor-web/lib/appHoming.ts index 336ab583..a4604a15 100644 --- a/src/Web/autopilot-monitor-web/lib/appHoming.ts +++ b/src/Web/autopilot-monitor-web/lib/appHoming.ts @@ -11,7 +11,7 @@ export function appHomingErrorMessage(reason: string | undefined, fallback: stri case "self-service-disabled": return "Self-service app switching is currently disabled by the operator."; case "probe-failed": - return "Admin consent for the new app registration was not found in this tenant. Grant consent first, or force the switch as Global Admin."; + return "The new app registration is not fully consented in this tenant (missing, or lacking a Graph permission the previous app holds). Grant admin consent first, or force the switch as Global Admin."; case "probe-transient": return "Could not verify consent for the new app registration right now (transient error). Please try again."; case "revert-is-ga-only": diff --git a/src/Web/autopilot-monitor-web/lib/authApp.ts b/src/Web/autopilot-monitor-web/lib/authApp.ts index f5f6423c..12cbfb72 100644 --- a/src/Web/autopilot-monitor-web/lib/authApp.ts +++ b/src/Web/autopilot-monitor-web/lib/authApp.ts @@ -1,15 +1,22 @@ /** * App-registration selection for the dual app-reg parallel window (legacy gktatooine app ∥ - * new C4A8 app). Model: "default legacy, funnel primary, learn in the background" — + * new C4A8 app). Model: "default app for fresh browsers, learn the tenant's app, re-home once" — * * - A browser that has signed in before carries `am_auth_app` in localStorage and always * boots MSAL with that app. auth/me returns the tenant's `homedApp` after every login and * AuthContext re-writes the flag, so an operator flipping a tenant to the new app makes * every user's NEXT login use it automatically (admin consent exists by then → seamless). - * - No flag → NEXT_PUBLIC_ENTRA_DEFAULT_APP decides (start: "legacy" = today's behaviour for - * every existing customer; flipped to "primary" once the re-consent campaign is done). + * - No flag → NEXT_PUBLIC_ENTRA_DEFAULT_APP decides which app a fresh browser signs in with + * ("primary" once the new app is the default; "legacy" during the initial window). + * - A redirect login that lands on the OTHER app than the tenant is homed on (fresh browser, + * wrong default) is re-homed right away: AuthGate switches the browser to the homed app + * and completes the sign-in silently via the Entra session (`requestRehome` → + * `consumePendingRehome`, one-shot per tab, never after a cross-app fallback). Without + * this the wrong app would carry the whole first session — e.g. without the tenant's + * Entra app-role assignments, which live on the homed app's enterprise application. * - The signup funnel (get-started CTA) hands over to portal with ?authapp=primary so brand-new - * tenants consent the NEW app as part of the expected signup flow. + * tenants consent the NEW app as part of the expected signup flow; the www → portal handover + * after a sign-in passes the learned app the same way (localStorage is per-origin). * - ?authapp=legacy|primary doubles as a silent support lever (persists the flag). * * Everything here is deliberately msal-free (no import cycles) and SSR-safe. @@ -23,6 +30,7 @@ const SELECTED_KEY = "am_auth_app"; // localStorage: app of the last s const ATTEMPT_KEY = "am_login_attempt"; // sessionStorage: app that started the in-flight redirect const FALLBACK_KEY = "am_login_fallback"; // sessionStorage: the one-shot 90094 auto-fallback ran const DECLINED_KEY = "am_login_declined"; // sessionStorage: user actively declined consent (65004) +const REHOME_KEY = "am_rehome"; // sessionStorage: the one-shot post-login re-home ran export function legacyClientId(): string | undefined { return process.env.NEXT_PUBLIC_ENTRA_LEGACY_CLIENT_ID || undefined; @@ -192,16 +200,75 @@ export function classifyEntraAuthError(errorText: string): "admin-approval-requi return "other"; } +// ── Post-login re-homing ──────────────────────────────────────────────────── + +/** Inputs of the re-home decision, captured by AuthContext's redirect handling. */ +export interface RehomeDecisionInput { + /** The app the tenant is homed on, as reported by auth/me. */ + homedApp: AuthApp; + /** The app this page load signed in with. */ + activeApp: AuthApp; + /** True only on the page load that completed a redirect sign-in (not on later refreshes). */ + redirectLoginCompleted: boolean; + /** + * True when that sign-in only worked through the one-shot cross-app fallback — the OTHER + * app is then proven unusable in this tenant, and re-homing to it would ping-pong. + */ + viaFallback: boolean; +} + +/** + * Whether the browser should switch to the tenant's homed app right after a redirect sign-in + * that ran on the other app. Pure; the one-shot tab guard lives in {@link tryBeginRehome}. + */ +export function shouldRehome(input: RehomeDecisionInput): boolean { + if (!legacyConfigured()) return false; + if (!input.redirectLoginCompleted || input.viaFallback) return false; + return input.homedApp !== input.activeApp; +} + +let pendingRehome: AuthApp | null = null; + +/** Records that the next post-login hop (AuthGate) should re-home the browser to `app`. */ +export function requestRehome(app: AuthApp): void { + pendingRehome = app; +} + +/** Reads AND clears the pending re-home request (one-shot, consumed by AuthGate). */ +export function consumePendingRehome(): AuthApp | null { + const app = pendingRehome; + pendingRehome = null; + return app; +} + +/** + * One-shot guard for the post-login re-home: true exactly once per browser tab. A tenant whose + * homed app cannot complete a sign-in for some other reason (a Conditional Access policy + * scoped to one app, say) thus costs at most one extra hop, never a loop. + */ +export function tryBeginRehome(): boolean { + try { + if (window.sessionStorage.getItem(REHOME_KEY)) return false; + window.sessionStorage.setItem(REHOME_KEY, "1"); + return true; + } catch { + return false; + } +} + /** * Switch this browser to the given app and reboot the bundle so the module-level MSAL * instance is reconstructed for it. Purges MSAL state to avoid interaction_in_progress - * leftovers from an interrupted flow on the other app. + * leftovers from an interrupted flow on the other app. With `navigateTo` the reboot lands on + * that in-app path instead of reloading the current one (the post-login re-home: the landing + * page would otherwise show the public site to a now signed-out browser). */ -export function switchAuthApp(app: AuthApp): void { +export function switchAuthApp(app: AuthApp, navigateTo?: string): void { // Strategic trace point of the dual app-reg migration: an explicit app switch (support - // lever, post-flip "sign in with the new app" button). Best-effort — the reload below may - // outrun the batch, but AI's pagehide beacon usually carries it out. - trackEvent("auth_app_switched", { to: app }); + // lever, post-flip "sign in with the new app" button) or the post-login re-home. + // Best-effort — the navigation below may outrun the batch, but AI's pagehide beacon + // usually carries it out. + trackEvent("auth_app_switched", { to: app, reason: navigateTo ? "rehome" : "manual" }); setSelectedAuthApp(app); clearLoginAttemptApp(); clearLoginFallback(); @@ -215,5 +282,9 @@ export function switchAuthApp(app: AuthApp): void { } catch { /* ignore */ } - window.location.reload(); + if (navigateTo) { + window.location.assign(navigateTo); + } else { + window.location.reload(); + } } diff --git a/src/Web/autopilot-monitor-web/lib/docsPaths.ts b/src/Web/autopilot-monitor-web/lib/docsPaths.ts index 31010ce7..81cbc537 100644 --- a/src/Web/autopilot-monitor-web/lib/docsPaths.ts +++ b/src/Web/autopilot-monitor-web/lib/docsPaths.ts @@ -30,6 +30,9 @@ export const DOCS_PATHS = { // Rules analyzeRules: "/rules/analyze-rules", gatherRules: "/rules/gather-rules", + // Dual app-registration window (sign-in banners, failed-sign-in screen) + appRegistrationMigration: "/troubleshooting-and-support/app-registration-migration", + appRegistrationMigrationAfter: "/troubleshooting-and-support/app-registration-migration#after-the-migration", } as const; /** Top-level URL segments GitBook publishes for docs.autopilotmonitor.com (SUMMARY.md groups + root pages). */ diff --git a/src/Web/autopilot-monitor-web/lib/hostRouting.ts b/src/Web/autopilot-monitor-web/lib/hostRouting.ts index 4d033b3a..1953f818 100644 --- a/src/Web/autopilot-monitor-web/lib/hostRouting.ts +++ b/src/Web/autopilot-monitor-web/lib/hostRouting.ts @@ -94,6 +94,18 @@ export function getPortalLoginUrl(path: string = DEFAULT_PORTAL_LANDING): string return `https://${PORTAL_HOST}${normalized}`; } +/** + * The www → portal handover URL after a sign-in on www. Auth state (and the dual app-reg + * selection in localStorage) is per-origin, so the app registration this browser learned + * travels along as `?authapp=` — the portal then boots the tenant's app directly instead of + * its default and a re-home hop. `target` may carry its own query string. + */ +export function portalHandoverUrl(target: string, authApp: "primary" | "legacy" | null): string { + const url = new URL(target, `https://${PORTAL_HOST}`); + if (authApp) url.searchParams.set("authapp", authApp); + return url.toString(); +} + export type HostBounce = "to-portal" | "to-www" | null; /**