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
+
+ .
+
+ );
+}
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.
-
+
);
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;
/**