diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/AuthFunctionSideEffectTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/AuthFunctionSideEffectTests.cs index 8091d7ff..20317ebc 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/AuthFunctionSideEffectTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/AuthFunctionSideEffectTests.cs @@ -168,7 +168,7 @@ public class AuthFunctionSideEffectTests Assert.Empty(_signalR.DisconnectedUsers); } - private static (HttpRequestData Req, FunctionContext Ctx) BuildAuthenticatedRequest(string callerUpn) + private static (HttpRequestData Req, FunctionContext Ctx) BuildAuthenticatedRequest(string callerUpn, string? tenantId = null) { var services = new ServiceCollection(); services.AddOptions(); @@ -177,8 +177,9 @@ public class AuthFunctionSideEffectTests new System.Text.Json.JsonSerializerOptions(System.Text.Json.JsonSerializerDefaults.Web))); var provider = services.BuildServiceProvider(); - var principal = new ClaimsPrincipal(new ClaimsIdentity( - new[] { new Claim("preferred_username", callerUpn) }, "test")); + var claims = new List { new("preferred_username", callerUpn) }; + if (tenantId != null) claims.Add(new Claim("tid", tenantId)); + var principal = new ClaimsPrincipal(new ClaimsIdentity(claims, "test")); var context = new Mock(); context.SetupGet(c => c.Items).Returns( @@ -231,12 +232,16 @@ public class AuthFunctionSideEffectTests // OnboardedBy is the immutable copy of the first-login UPN that auto-promote on // preview approval reads — UpdatedBy may later be clobbered by background syncs. Assert.Equal(Upn, config.OnboardedBy); - // The DPA version rides along in the same onboarding write (D-252). - Assert.Equal(CurrentDpa, config.DpaVersion); + // A first sign-in without the get-started tick leaves the Terms + DPA pending: the portal asks. + Assert.True(config.DpaAcceptancePending); + Assert.Null(config.DpaVersion); var (written, source, _) = Assert.Single(_store.Writes); Assert.Equal("contoso.com", written.DomainName); Assert.Equal(Upn, written.OnboardedBy); - Assert.Equal(CurrentDpa, written.DpaVersion); + Assert.True(written.DpaAcceptancePending); + Assert.Null(written.DpaVersion); + Assert.Null(written.DpaAcceptedBy); + Assert.Null(written.DpaAcceptedAt); Assert.Equal("auth", source); _telegramMock.Verify(x => x.SendNewTenantSignupAsync(TenantId, Upn), Times.Once); _opsEventsMock.Verify(x => x.RecordTenantSignupAsync(TenantId, Upn), Times.Once); // the routable record next to the ping @@ -443,20 +448,139 @@ public class AuthFunctionSideEffectTests private const string CurrentDpa = AutopilotMonitor.Shared.Constants.CurrentDpaVersion; [Fact] - public async Task HandleNewTenantDomain_WhenDomainAlreadySet_NeverStampsDpaVersion() + public async Task HandleNewTenantDomain_ThroughTheGetStartedTick_AcceptsWithTheOnboarding() { - // The DPA version is written once, with the onboarding (D-252): an existing tenant's - // login writes nothing — no stamp, no save. + var config = DefaultConfig(); + config.DomainName = null!; + + var before = DateTime.UtcNow; + await _sut.HandleNewTenantDomainAsync(config, TenantId, Upn, signupConsent: true); + + var written = Assert.Single(_store.Writes).Row; + Assert.False(written.DpaAcceptancePending); + Assert.Equal(CurrentDpa, written.DpaVersion); + Assert.Equal(Upn, written.DpaAcceptedBy); + Assert.NotNull(written.DpaAcceptedAt); + Assert.True(written.DpaAcceptedAt >= before); + // This login's view follows the stored row, so its auth/me carries no dialog. + Assert.False(config.DpaAcceptancePending); + Assert.Equal(CurrentDpa, config.DpaVersion); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task HandleNewTenantDomain_WhenDomainAlreadySet_NeverAsksOrStamps(bool signupConsent) + { + // Decided once, with the onboarding: an existing tenant's login writes nothing — no stamp, + // no pending flag, whichever button the user came through (tenants that pre-date the + // acceptance are never asked). var config = DefaultConfig(); config.DomainName = "contoso.com"; config.DpaVersion = null; - await _sut.HandleNewTenantDomainAsync(config, TenantId, Upn); + await _sut.HandleNewTenantDomainAsync(config, TenantId, Upn, signupConsent: signupConsent); Assert.Null(config.DpaVersion); + Assert.False(config.DpaAcceptancePending); Assert.Empty(_store.Writes); } + // ------------------------------------------------------------------------- + // AcceptDpa — POST auth/dpa-acceptance (portal dialog) + // ------------------------------------------------------------------------- + + private static TenantConfiguration PendingTenant() + { + var config = DefaultConfig(); + config.DomainName = "contoso.com"; + config.OnboardedBy = "first@contoso.com"; + config.DpaAcceptancePending = true; + return config; + } + + [Fact] + public async Task AcceptDpa_PendingTenant_RecordsTheAcceptanceAndClearsTheDialog() + { + _store.Row = PendingTenant(); + var (req, ctx) = BuildAuthenticatedRequest(callerUpn: Upn, tenantId: TenantId); + + var before = DateTime.UtcNow; + var response = await _sut.AcceptDpa(req, ctx); + + Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); + var (written, source, _) = Assert.Single(_store.Writes); + Assert.Equal("auth", source); + Assert.False(written.DpaAcceptancePending); + Assert.Equal(CurrentDpa, written.DpaVersion); + Assert.Equal(Upn, written.DpaAcceptedBy); + Assert.True(written.DpaAcceptedAt >= before); + Assert.Equal("first@contoso.com", written.OnboardedBy); + } + + [Fact] + public async Task AcceptDpa_NothingPending_IsANoOp() + { + // Already accepted (a double click, a second user) or a tenant that was never asked. + var accepted = DefaultConfig(); + accepted.DomainName = "contoso.com"; + accepted.DpaVersion = "2026-01-1.0"; + accepted.DpaAcceptedBy = "first@contoso.com"; + _store.Row = accepted; + var (req, ctx) = BuildAuthenticatedRequest(callerUpn: Upn, tenantId: TenantId); + + var response = await _sut.AcceptDpa(req, ctx); + + Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); + Assert.Empty(_store.Writes); + Assert.Equal("2026-01-1.0", _store.Row!.DpaVersion); + Assert.Equal("first@contoso.com", _store.Row.DpaAcceptedBy); + } + + [Fact] + public async Task AcceptDpa_OnlyEverWritesTheCallersOwnTenant() + { + // The row is keyed by the token's tid: a caller from another tenant reaches no pending row. + const string otherTenant = "ffffffff-0000-1111-2222-333333333333"; + var otherStore = _tenantConfigMock.StubWrites(otherTenant, row: null); + _store.Row = PendingTenant(); + var (req, ctx) = BuildAuthenticatedRequest(callerUpn: "user@fabrikam.com", tenantId: otherTenant); + + var response = await _sut.AcceptDpa(req, ctx); + + Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); + Assert.Empty(otherStore.Writes); + Assert.Empty(_store.Writes); + Assert.True(_store.Row!.DpaAcceptancePending); + } + + [Fact] + public async Task AcceptDpa_OffboardingTombstone_IsRefused() + { + var tombstone = PendingTenant(); + tombstone.Disabled = true; + tombstone.DisabledReason = AutopilotMonitor.Functions.Functions.Admin.TenantOffboardFunction.OffboardingDisabledReason; + _store.Row = tombstone; + var (req, ctx) = BuildAuthenticatedRequest(callerUpn: Upn, tenantId: TenantId); + + var response = await _sut.AcceptDpa(req, ctx); + + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + Assert.Empty(_store.Writes); + } + + [Fact] + public async Task AcceptDpa_LostWriteRace_AsksToRetry() + { + _store.Row = PendingTenant(); + _store.Force = TenantConfigUpdateStatus.Conflict; + var (req, ctx) = BuildAuthenticatedRequest(callerUpn: Upn, tenantId: TenantId); + + var response = await _sut.AcceptDpa(req, ctx); + + Assert.Equal(HttpStatusCode.Conflict, response.StatusCode); + } + [Fact] public async Task HandleNewTenantDomain_PreservesExistingOnboardedBy() { diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/AuthFunctionTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/AuthFunctionTests.cs index 9a927787..64023730 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/AuthFunctionTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/AuthFunctionTests.cs @@ -75,6 +75,24 @@ public class AuthFunctionTests Assert.Equal(agentSeen, (DateTime)body.whatsNewSeenAgentUtc); } + [Theory] + [InlineData(true)] + [InlineData(false)] + public void HappyPath_CarriesThePendingTermsAndDpaAcceptance(bool pending) + { + var config = DefaultConfig(); + config.DpaAcceptancePending = pending; + + var result = AuthFunction.BuildAuthResult( + config, isGlobalAdmin: false, isGlobalReader: false, isPreviewApproved: true, + memberRole: AdminRole(), mcpCheck: McpAllowed(), + hasTenantAdmins: true, + TenantId, Upn, DisplayName, ObjectId); + + Assert.True(result.IsSuccess); + Assert.Equal(pending, (bool)ToDynamic(result.Body).dpaAcceptancePending); + } + [Theory] [InlineData("platform", "platform")] [InlineData("Platform", "platform")] diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/InfrastructureWireParityTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/InfrastructureWireParityTests.cs index 2c2dbbce..fcdb3713 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/InfrastructureWireParityTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/InfrastructureWireParityTests.cs @@ -51,7 +51,8 @@ public class InfrastructureWireParityTests unrestrictedModeEnabled = false, mcpClientRegistrationEnabled = true, whatsNewSeenPlatformUtc = platformSeen, - whatsNewSeenAgentUtc = agentSeen + whatsNewSeenAgentUtc = agentSeen, + dpaAcceptancePending = true }, new AuthMeResponse { @@ -72,7 +73,8 @@ public class InfrastructureWireParityTests UnrestrictedModeEnabled = false, McpClientRegistrationEnabled = true, WhatsNewSeenPlatformUtc = platformSeen, - WhatsNewSeenAgentUtc = agentSeen + WhatsNewSeenAgentUtc = agentSeen, + DpaAcceptancePending = true }); } @@ -109,7 +111,8 @@ public class InfrastructureWireParityTests unrestrictedModeEnabled = false, mcpClientRegistrationEnabled = false, whatsNewSeenPlatformUtc, - whatsNewSeenAgentUtc + whatsNewSeenAgentUtc, + dpaAcceptancePending = false }, new AuthMeResponse { @@ -130,7 +133,8 @@ public class InfrastructureWireParityTests UnrestrictedModeEnabled = false, McpClientRegistrationEnabled = false, WhatsNewSeenPlatformUtc = null, - WhatsNewSeenAgentUtc = null + WhatsNewSeenAgentUtc = null, + DpaAcceptancePending = false }); } diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/TenantConfigProjectionTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/TenantConfigProjectionTests.cs index b9e7f79c..e5142d81 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/TenantConfigProjectionTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/TenantConfigProjectionTests.cs @@ -30,7 +30,8 @@ public class TenantConfigProjectionTests "tenantId", "domainName", "planTier", "trialExpiresUtc", "trialConsumed", "maxDelegatedTenantsOverride", "mcpUsagePlanOverride", "payingCustomer", "managedByProTenantId", "disabled", "disabledReason", "mcpDisabled", "mcpDisabledReason", - "onboardedAt", "onboardedBy", "dpaVersion", "lastUpdated", "dataRetentionDays", + "onboardedAt", "onboardedBy", "dpaVersion", "dpaAcceptancePending", "dpaAcceptedBy", "dpaAcceptedAt", + "lastUpdated", "dataRetentionDays", "homedAppClientId", "lastAuthClientId", "lastAuthClientIdSince", }; diff --git a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableConfigRepository.cs b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableConfigRepository.cs index 4ca2c9dd..1e4bfa7f 100644 --- a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableConfigRepository.cs +++ b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableConfigRepository.cs @@ -766,6 +766,9 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage { "UpdatedBy", config.UpdatedBy }, { "OnboardedBy", config.OnboardedBy }, { "DpaVersion", config.DpaVersion }, + { "DpaAcceptancePending", config.DpaAcceptancePending }, + { "DpaAcceptedBy", config.DpaAcceptedBy }, + { "DpaAcceptedAt", config.DpaAcceptedAt }, { "ContactEmail", config.ContactEmail }, { "CompanyName", config.CompanyName }, { "Disabled", config.Disabled }, @@ -877,6 +880,9 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage UpdatedBy = entity.GetString("UpdatedBy") ?? "Unknown", OnboardedBy = entity.GetString("OnboardedBy"), DpaVersion = entity.GetString("DpaVersion"), + DpaAcceptancePending = entity.GetBoolean("DpaAcceptancePending") ?? false, + DpaAcceptedBy = entity.GetString("DpaAcceptedBy"), + DpaAcceptedAt = entity.GetDateTime("DpaAcceptedAt"), ContactEmail = entity.GetString("ContactEmail"), CompanyName = entity.GetString("CompanyName"), Disabled = entity.GetBoolean("Disabled") ?? false, diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Infrastructure/AuthFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Infrastructure/AuthFunction.cs index 287180b2..4d3b5b4c 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Infrastructure/AuthFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Infrastructure/AuthFunction.cs @@ -167,9 +167,12 @@ public class AuthFunction // Which app registration minted this login's token (dual app-reg window) — drives the // onboarding homing decision and the per-tenant last-seen provenance below. var tokenAudience = principal.GetAudience(); + // The get-started CTA is inert until its Terms + DPA tick is set; the portal forwards that + // sign-in path here so the onboarding records the acceptance instead of asking again. + var signupConsent = string.Equals(req.Query?["signupConsent"], "1", StringComparison.Ordinal); if (isHomeTenantParticipant) { - await HandleNewTenantDomainAsync(tenantConfig, tenantId, upn, tokenAudience); + await HandleNewTenantDomainAsync(tenantConfig, tenantId, upn, tokenAudience, signupConsent); await HandleAutoReEnableAsync(tenantConfig, tenantId); await HandleAuthClientIdTrackingAsync(tenantConfig, tenantId, tokenAudience); } @@ -241,6 +244,56 @@ public class AuthFunction return req.CreateResponse(HttpStatusCode.NoContent); } + /// + /// POST /api/auth/dpa-acceptance + /// Records that the caller accepted the Terms + DPA for their own tenant, which clears the + /// portal's acceptance dialog (). Only the + /// caller's home tenant is ever written. Nothing pending (already accepted, or a tenant that was + /// never asked) is a no-op 204, so a double click or a second user is harmless. + /// + [Function("AcceptDpa")] + [Authorize] + public async Task AcceptDpa( + [HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "auth/dpa-acceptance")] HttpRequestData req, + FunctionContext context) + { + var principal = context.GetUser(); + if (principal == null) + return req.CreateResponse(HttpStatusCode.Unauthorized); + + var tenantId = principal.GetTenantId(); + var upn = principal.GetUserPrincipalName(); + if (string.IsNullOrEmpty(tenantId) || string.IsNullOrEmpty(upn)) + return await req.BadRequestAsync("Missing required claims"); + + var update = await _tenantConfigService.UpdateAsync(tenantId, row => + { + if (!row.DpaAcceptancePending) + return false; + row.DpaAcceptancePending = false; + row.DpaVersion = Constants.CurrentDpaVersion; + row.DpaAcceptedBy = upn; + row.DpaAcceptedAt = DateTime.UtcNow; + row.UpdatedBy = upn; + return true; + }, "auth", "terms + dpa acceptance"); + + switch (update.Status) + { + case TenantConfigUpdateStatus.Updated: + _logger.LogInformation("Terms + DPA {DpaVersion} accepted for tenant {TenantId} by {Upn}", + Constants.CurrentDpaVersion, tenantId, upn); + return req.CreateResponse(HttpStatusCode.NoContent); + case TenantConfigUpdateStatus.Declined: + case TenantConfigUpdateStatus.NotFound: + return req.CreateResponse(HttpStatusCode.NoContent); + case TenantConfigUpdateStatus.OffboardingInProgress: + return await req.ForbiddenAsync("This tenant is being offboarded.", Constants.ApiErrorCodes.TenantSuspended); + default: + return await req.ConflictAsync("The acceptance could not be saved. Please try again."); + } + } + /// /// GET /api/auth/is-global-admin /// Checks if the current user is a Global Admin @@ -377,7 +430,8 @@ public class AuthFunction /// the login that wrote it fires the signup side effects. /// internal async Task HandleNewTenantDomainAsync( - TenantConfiguration tenantConfig, string tenantId, string upn, string? tokenAudience = null) + TenantConfiguration tenantConfig, string tenantId, string upn, string? tokenAudience = null, + bool signupConsent = false) { if (!string.IsNullOrEmpty(tenantConfig.DomainName) || string.IsNullOrEmpty(upn)) return; @@ -424,10 +478,22 @@ public class AuthFunction // into TenantAdmins. if (string.IsNullOrWhiteSpace(row.OnboardedBy)) row.OnboardedBy = upn; - // The DPA is accepted with the onboarding itself (D-251/D-252): record the version in force, - // once, in this same first write — later logins never touch it. - if (string.IsNullOrWhiteSpace(row.DpaVersion)) - row.DpaVersion = Constants.CurrentDpaVersion; + // Terms + DPA, decided once in this same first write — later logins never touch it. A first + // sign-in through the get-started tick accepts with the onboarding (D-251/D-252); any other + // first sign-in leaves the acceptance pending, and the portal asks before it opens. + if (string.IsNullOrWhiteSpace(row.DpaVersion) && !row.DpaAcceptancePending) + { + if (signupConsent) + { + row.DpaVersion = Constants.CurrentDpaVersion; + row.DpaAcceptedBy = upn; + row.DpaAcceptedAt = seededAt; + } + else + { + row.DpaAcceptancePending = true; + } + } // Dual app-reg window: home a NEW tenant on the primary app ONLY when its first login // actually arrived via the primary app. A first login via the legacy app leaves the field // null (= legacy) — keeps the "null = legacy" invariant clean and never routes a tenant @@ -748,7 +814,8 @@ public class AuthFunction && tenantConfig.UnrestrictedModeEnabled, McpClientRegistrationEnabled = mcpClientRegistrationEnabled, WhatsNewSeenPlatformUtc = whatsNewSeenPlatformUtc, - WhatsNewSeenAgentUtc = whatsNewSeenAgentUtc + WhatsNewSeenAgentUtc = whatsNewSeenAgentUtc, + DpaAcceptancePending = tenantConfig.DpaAcceptancePending }, needsAutoAdmin); } diff --git a/src/Backend/AutopilotMonitor.Functions/Helpers/TenantConfigProjection.cs b/src/Backend/AutopilotMonitor.Functions/Helpers/TenantConfigProjection.cs index fe75783c..4a0cb685 100644 --- a/src/Backend/AutopilotMonitor.Functions/Helpers/TenantConfigProjection.cs +++ b/src/Backend/AutopilotMonitor.Functions/Helpers/TenantConfigProjection.cs @@ -41,6 +41,10 @@ namespace AutopilotMonitor.Functions.Helpers ("onboardedBy", c => c.OnboardedBy), // DPA version accepted at onboarding — a version label, not a secret. ("dpaVersion", c => c.DpaVersion), + // Terms + DPA acceptance state — a flag, the accepting UPN (as onboardedBy) and a timestamp. + ("dpaAcceptancePending", c => c.DpaAcceptancePending), + ("dpaAcceptedBy", c => c.DpaAcceptedBy), + ("dpaAcceptedAt", c => c.DpaAcceptedAt), ("lastUpdated", c => c.LastUpdated), ("dataRetentionDays", c => c.DataRetentionDays), // App-reg migration observability: which app the tenant is homed on (null = legacy) diff --git a/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs b/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs index 76e8fe74..014fa74a 100644 --- a/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs +++ b/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs @@ -233,6 +233,9 @@ public static class EndpointAccessPolicyCatalog // ── AuthenticatedUser ─────────────────────────────────────────── new("GET", "auth/me", EndpointPolicy.AuthenticatedUser), new("PUT", "auth/me/whats-new-seen", EndpointPolicy.AuthenticatedUser), + // Terms + DPA acceptance: writes only the caller's own home tenant row (token tid), and only + // while that row asks for it — the portal dialog of a tenant onboarded without the signup tick. + new("POST", "auth/dpa-acceptance", EndpointPolicy.AuthenticatedUser), new("GET", "auth/is-global-admin", EndpointPolicy.AuthenticatedUser), new("POST", "realtime/negotiate", EndpointPolicy.AuthenticatedUser), // Group join/leave is AuthenticatedUserWithRole — NOT MemberRead — on purpose. The real, diff --git a/src/Backend/AutopilotMonitor.Functions/Services/TenantConfigPatchService.cs b/src/Backend/AutopilotMonitor.Functions/Services/TenantConfigPatchService.cs index e59a2e5a..ba047b32 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/TenantConfigPatchService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/TenantConfigPatchService.cs @@ -104,12 +104,13 @@ namespace AutopilotMonitor.Functions.Services /// with dedicated endpoints (plan/trial), and system-written provenance: /// HomedAppClientId caused the 2026-07-31 prod incident when a stale round-trip /// reverted it — only the app-homing flow writes it; LastAuthClientId*/OnboardedBy/ - /// OnboardedAt/DpaVersion are AuthFunction-owned; LastUpdated/UpdatedBy are stamped server-side. + /// OnboardedAt/DpaVersion/DpaAccept* are AuthFunction-owned; LastUpdated/UpdatedBy are stamped server-side. /// internal static readonly HashSet BaseDeniedFields = new(StringComparer.OrdinalIgnoreCase) { "TenantId", "DomainName", "PartitionKey", "RowKey", "Timestamp", "ETag", "LastUpdated", "UpdatedBy", "OnboardedAt", "OnboardedBy", "DpaVersion", + "DpaAcceptancePending", "DpaAcceptedBy", "DpaAcceptedAt", "HomedAppClientId", "LastAuthClientId", "LastAuthClientIdSince", "PlanTier", "TrialExpiresUtc", "TrialStartedUtc", "TrialConsumed", "TrialGrantedBy", "ProDowngradedUtc", "MaxDelegatedTenantsOverride", "McpUsagePlanOverride", "PayingCustomer", @@ -161,6 +162,7 @@ namespace AutopilotMonitor.Functions.Services { "HomedAppClientId", "LastAuthClientId", "LastAuthClientIdSince", "OnboardedBy", "OnboardedAt", "DpaVersion", + "DpaAcceptancePending", "DpaAcceptedBy", "DpaAcceptedAt", "PlanTier", "TrialExpiresUtc", "TrialStartedUtc", "TrialConsumed", "TrialGrantedBy", "ProDowngradedUtc", "MaxDelegatedTenantsOverride", "McpUsagePlanOverride", "PayingCustomer", }; @@ -507,6 +509,7 @@ namespace AutopilotMonitor.Functions.Services "TenantId" or "DomainName" => "identity — never writable", "LastUpdated" or "UpdatedBy" => "stamped server-side on every write", "OnboardedAt" or "OnboardedBy" or "DpaVersion" => "system-owned onboarding provenance (set at first login)", + "DpaAcceptancePending" or "DpaAcceptedBy" or "DpaAcceptedAt" => "system-owned — written by the onboarding and the Terms + DPA acceptance", "HomedAppClientId" => "system-owned — written only by the app-homing flow", "LastAuthClientId" or "LastAuthClientIdSince" => "system-owned auth provenance", "PlanTier" or "TrialExpiresUtc" or "TrialStartedUtc" or "TrialConsumed" or "TrialGrantedBy" diff --git a/src/McpServer/autopilot-monitor-mcp/src/__tests__/security-guards.test.ts b/src/McpServer/autopilot-monitor-mcp/src/__tests__/security-guards.test.ts index 281e7f7e..20b81283 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/__tests__/security-guards.test.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/__tests__/security-guards.test.ts @@ -534,6 +534,9 @@ describe('list_tenants — extractTenantList keep-list projection', () => { onboardedAt: '2026-01-15T00:00:00Z', onboardedBy: 'alice@contoso.example.com', dpaVersion: '2026-09-1.7', + dpaAcceptancePending: false, + dpaAcceptedBy: 'alice@contoso.example.com', + dpaAcceptedAt: '2026-01-15T00:00:00Z', lastUpdated: '2026-05-01T00:00:00Z', dataRetentionDays: 90, // ── sensitive / must NOT leak ── @@ -555,6 +558,7 @@ describe('list_tenants — extractTenantList keep-list projection', () => { for (const present of [ 'tenantId', 'domainName', 'planTier', 'disabled', 'onboardedAt', 'onboardedBy', 'dpaVersion', 'lastUpdated', 'dataRetentionDays', + 'dpaAcceptancePending', 'dpaAcceptedBy', 'dpaAcceptedAt', ]) { expect(t).toHaveProperty(present); } diff --git a/src/McpServer/autopilot-monitor-mcp/src/generated/wire-types.generated.ts b/src/McpServer/autopilot-monitor-mcp/src/generated/wire-types.generated.ts index 826a0a7e..81b42abf 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/generated/wire-types.generated.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/generated/wire-types.generated.ts @@ -782,6 +782,8 @@ export interface AuthMeResponse { whatsNewSeenPlatformUtc?: string; /** Same for the Agent tab; null = never and the key is omitted. */ whatsNewSeenAgentUtc?: string; + /** True while the caller's tenant still has to accept the Terms + DPA (its first sign-in skipped the get-started tick); the portal asks before it opens. */ + dpaAcceptancePending: boolean; } export interface AutoResolveCpeMappingItem { @@ -4932,8 +4934,14 @@ export interface TenantConfiguration { updatedBy: string; /** UPN of the user whose first login created this tenant configuration. Set once in HandleNewTenantDomainAsync alongside DomainName and never overwritten. Used by the preview-approval auto-promote path so background jobs that mutate UpdatedBy (e.g. global rate-limit sync) cannot leak a sentinel string into the TenantAdmins table. Null on rows that pre-date the OnboardedBy field — auto-promote falls back to UpdatedBy with a UPN-shape guard. */ onboardedBy?: string | null; - /** Version of the data processing agreement this tenant accepted with its onboarding — CurrentDpaVersion at that moment. Set once in HandleNewTenantDomainAsync together with OnboardedBy and never overwritten; never client-writable. Null on tenants onboarded before the field existed. */ + /** Version of the data processing agreement this tenant accepted — CurrentDpaVersion at that moment. Written once: with the onboarding when the first sign-in came through the get-started tick, otherwise when the portal's acceptance dialog is confirmed (DpaAcceptancePending). Never client-writable. Null on tenants onboarded before the field existed. */ dpaVersion?: string | null; + /** True while a tenant whose first sign-in skipped the get-started Terms + DPA tick has not confirmed them in the portal yet; the portal asks before anything else. Set only by the onboarding write, cleared by the acceptance endpoint. Tenants onboarded before the field existed never carry it, so they are never asked. */ + dpaAcceptancePending: boolean; + /** UPN of the user who accepted the Terms + DPA for this tenant; null when DpaVersion is null. */ + dpaAcceptedBy?: string | null; + /** When the Terms + DPA were accepted for this tenant; null when DpaVersion is null. */ + dpaAcceptedAt?: string | null; /** Address used to reach this tenant about the service itself — a technical problem, a security matter, or a change that needs an administrator's attention. Editable by the tenant's own admins under Settings → Tenant → Contact. Seeded once at onboarding from the tenant's notification address if one was given, and never re-synced afterwards: from that point the value belongs to the tenant, and a later edit must not be overwritten by the onboarding source. Purpose-limited by design — service communication only. It is never used for marketing and never disclosed. Null means we have no way to reach this tenant, which is why enforcement actions cannot promise prior warning. */ contactEmail?: string | null; /** Organization name behind this tenant, as the tenant's admins want it read by a support engineer — the counterpart of ContactEmail in the tenant's contact profile. Editable under Settings → Tenant → Contact. Optional on Community. Together with it is required at the self-service Pro entry point (trial start) so a paying tenant is reachable and identifiable for support; it is never a runtime gate on Pro features, and a GA plan assignment does not require it (the admin UI warns instead). Never derived from : the domain is a technical label, the company name is what the tenant tells us. Null means not provided. */ diff --git a/src/McpServer/autopilot-monitor-mcp/src/tools/admin.ts b/src/McpServer/autopilot-monitor-mcp/src/tools/admin.ts index 0fab8027..fa8d7537 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/tools/admin.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/tools/admin.ts @@ -171,6 +171,7 @@ export const TENANT_SAFE_FIELDS: ReadonlySet = new Set([ 'tenantId', 'domainName', 'planTier', 'trialExpiresUtc', 'trialConsumed', 'disabled', 'disabledReason', 'onboardedAt', 'onboardedBy', 'dpaVersion', 'lastUpdated', 'dataRetentionDays', + 'dpaAcceptancePending', 'dpaAcceptedBy', 'dpaAcceptedAt', ]); /** diff --git a/src/Shared/AutopilotMonitor.Shared/Models/Config/TenantConfiguration.cs b/src/Shared/AutopilotMonitor.Shared/Models/Config/TenantConfiguration.cs index d4a9f79b..d8ea35e3 100644 --- a/src/Shared/AutopilotMonitor.Shared/Models/Config/TenantConfiguration.cs +++ b/src/Shared/AutopilotMonitor.Shared/Models/Config/TenantConfiguration.cs @@ -45,13 +45,28 @@ namespace AutopilotMonitor.Shared.Models public string? OnboardedBy { get; set; } /// - /// Version of the data processing agreement this tenant accepted with its onboarding — - /// at that moment. Set once in - /// HandleNewTenantDomainAsync together with and never - /// overwritten; never client-writable. Null on tenants onboarded before the field existed. + /// Version of the data processing agreement this tenant accepted — + /// at that moment. Written once: with the onboarding + /// when the first sign-in came through the get-started tick, otherwise when the portal's + /// acceptance dialog is confirmed (). Never client-writable. + /// Null on tenants onboarded before the field existed. /// public string? DpaVersion { get; set; } + /// + /// True while a tenant whose first sign-in skipped the get-started Terms + DPA tick has not + /// confirmed them in the portal yet; the portal asks before anything else. Set only by the + /// onboarding write, cleared by the acceptance endpoint. Tenants onboarded before the field + /// existed never carry it, so they are never asked. + /// + public bool DpaAcceptancePending { get; set; } + + /// UPN of the user who accepted the Terms + DPA for this tenant; null when is null. + public string? DpaAcceptedBy { get; set; } + + /// When the Terms + DPA were accepted for this tenant; null when is null. + public DateTime? DpaAcceptedAt { get; set; } + /// /// Address used to reach this tenant about the service itself — a technical problem, /// a security matter, or a change that needs an administrator's attention. Editable by diff --git a/src/Shared/AutopilotMonitor.Shared/Models/InfrastructureApiModels.cs b/src/Shared/AutopilotMonitor.Shared/Models/InfrastructureApiModels.cs index 0f177684..edc91342 100644 --- a/src/Shared/AutopilotMonitor.Shared/Models/InfrastructureApiModels.cs +++ b/src/Shared/AutopilotMonitor.Shared/Models/InfrastructureApiModels.cs @@ -45,6 +45,9 @@ namespace AutopilotMonitor.Shared.Models public DateTime? WhatsNewSeenPlatformUtc { get; set; } /// Same for the Agent tab; null = never and the key is omitted. public DateTime? WhatsNewSeenAgentUtc { get; set; } + + /// True while the caller's tenant still has to accept the Terms + DPA (its first sign-in skipped the get-started tick); the portal asks before it opens. + public bool DpaAcceptancePending { get; set; } } /// Body of PUT auth/me/whats-new-seen. diff --git a/src/Web/autopilot-monitor-web/app/admin/components/__tests__/tenantEditorFields.test.ts b/src/Web/autopilot-monitor-web/app/admin/components/__tests__/tenantEditorFields.test.ts index c0551789..c0daadfb 100644 --- a/src/Web/autopilot-monitor-web/app/admin/components/__tests__/tenantEditorFields.test.ts +++ b/src/Web/autopilot-monitor-web/app/admin/components/__tests__/tenantEditorFields.test.ts @@ -12,6 +12,7 @@ import { TENANT_EDITOR_FIELDS, tenantEditorPatch } from "../tenantEditorFields"; const SERVER_DENIED_FIELDS = new Set([ "tenantId", "domainName", "partitionKey", "rowKey", "timestamp", "eTag", "lastUpdated", "updatedBy", "onboardedAt", "onboardedBy", "dpaVersion", + "dpaAcceptancePending", "dpaAcceptedBy", "dpaAcceptedAt", "homedAppClientId", "lastAuthClientId", "lastAuthClientIdSince", "planTier", "trialExpiresUtc", "trialStartedUtc", "trialConsumed", "trialGrantedBy", "proDowngradedUtc", "maxDelegatedTenantsOverride", "mcpUsagePlanOverride", "payingCustomer", diff --git a/src/Web/autopilot-monitor-web/app/admin/tenants/sections/tenantConfigReportCatalog.ts b/src/Web/autopilot-monitor-web/app/admin/tenants/sections/tenantConfigReportCatalog.ts index 7d94c60a..b13b9f3f 100644 --- a/src/Web/autopilot-monitor-web/app/admin/tenants/sections/tenantConfigReportCatalog.ts +++ b/src/Web/autopilot-monitor-web/app/admin/tenants/sections/tenantConfigReportCatalog.ts @@ -103,6 +103,9 @@ export const TENANT_FIELDS: Record(null); + + const handleContinue = async () => { + if (!accepted || saving) return; + setSaving(true); + setError(null); + try { + await fetchOk(api.auth.dpaAcceptance(), getAccessToken, { method: "POST" }); + // auth/me now answers without the flag and ProtectedRoute renders the page. + await refreshUserInfo(); + } catch (err: unknown) { + setError(apiErrorText(err, "Your confirmation could not be saved.")); + setSaving(false); + } + }; + + return ( + +
+
+
+
+
+ + + +
+

+ Before you continue +

+
+ +

+ Please confirm the terms for your organization. +

+ + + + {error && ( +
+ {error} +
+ )} + +
+ + +
+
+
+
+
+ ); +} diff --git a/src/Web/autopilot-monitor-web/components/ProtectedRoute.tsx b/src/Web/autopilot-monitor-web/components/ProtectedRoute.tsx index 28b75c1c..f5e8b2f0 100644 --- a/src/Web/autopilot-monitor-web/components/ProtectedRoute.tsx +++ b/src/Web/autopilot-monitor-web/components/ProtectedRoute.tsx @@ -11,6 +11,7 @@ import { DOCS_PATHS } from "../lib/docsPaths"; import { DOCS_URL } from "../utils/config"; import { useAdminMode } from "../hooks/useAdminMode"; import OffboardingFeedbackForm from "./OffboardingFeedbackForm"; +import { DpaAcceptanceDialog } from "./DpaAcceptanceDialog"; interface ProtectedRouteProps { children: React.ReactNode; @@ -234,6 +235,12 @@ export function ProtectedRoute({ children, requireGlobalAdmin = false, requireGl ); } + // A tenant whose first sign-in skipped the get-started Terms + DPA tick confirms them here once, + // before any page renders (and starts its API calls). + if (user?.dpaAcceptancePending) { + return ; + } + // Show nothing if the route's platform requirement isn't met. // requireGlobalAdmin → real Global Admin only; requireGlobalScope → Global Admin OR Global Reader. const platformDenied = diff --git a/src/Web/autopilot-monitor-web/components/landing/LoginButton.tsx b/src/Web/autopilot-monitor-web/components/landing/LoginButton.tsx index 24fa4b41..bbbe43b3 100644 --- a/src/Web/autopilot-monitor-web/components/landing/LoginButton.tsx +++ b/src/Web/autopilot-monitor-web/components/landing/LoginButton.tsx @@ -3,6 +3,7 @@ import { useAuth } from "../../contexts/AuthContext"; import { getPortalLoginUrl, shouldCrossOriginToPortal } from "../../lib/hostRouting"; import { getSelectedAuthApp, legacyConfigured, switchAuthApp } from "../../lib/authApp"; +import { markSignupConsent, SIGNUP_CONSENT_PARAM } from "../../lib/signupConsent"; export function LoginButton({ className, @@ -20,6 +21,8 @@ export function LoginButton({ * PRIMARY app registration so brand-new tenants consent the NEW app as part of the expected * signup flow — while the plain "Sign in" default keeps existing customers on their app * (dual app-reg window; localStorage is per-origin, hence the ?authapp handover to portal). + * It also carries the get-started Terms + DPA tick to portal (lib/signupConsent.ts), so a + * tenant onboarded this way is not asked again in the portal. */ signup?: boolean; /** data-track id for the anonymous marketing click count (components/MarketingTracker.tsx). */ @@ -34,11 +37,15 @@ export function LoginButton({ // portal after the post-auth redirect — and with prompt:"select_account" // that is not actually silent. if (shouldCrossOriginToPortal()) { - window.location.href = signup && legacyConfigured() - ? `${getPortalLoginUrl()}?authapp=primary` - : getPortalLoginUrl(); + const url = new URL(getPortalLoginUrl()); + if (signup) { + url.searchParams.set(SIGNUP_CONSENT_PARAM, "1"); + if (legacyConfigured()) url.searchParams.set("authapp", "primary"); + } + window.location.href = url.toString(); return; } + if (signup) markSignupConsent(); if (signup && legacyConfigured() && getSelectedAuthApp() !== "primary") { // Same-origin signup click while the bundle booted with the legacy app: persist the // choice and reboot so the module-level MSAL instance is reconstructed for primary. diff --git a/src/Web/autopilot-monitor-web/components/landing/SignupConsentCta.tsx b/src/Web/autopilot-monitor-web/components/landing/SignupConsentCta.tsx index 00bff27b..019bfa50 100644 --- a/src/Web/autopilot-monitor-web/components/landing/SignupConsentCta.tsx +++ b/src/Web/autopilot-monitor-web/components/landing/SignupConsentCta.tsx @@ -2,14 +2,15 @@ import { useState } from "react"; import { LoginButton } from "./LoginButton"; -import { DOCS_URL } from "@/utils/config"; +import { TermsDpaAgreementText } from "../legal/TermsDpaAgreementText"; const LINK_CLASS = "text-[var(--lp-accent-ink)] hover:opacity-80 underline"; /** * Get-started CTA: the signup sign-in stays inert until the visitor ticks that they accept the - * Terms of Use and the Data Processing Agreement. A client-side gate only — nothing is recorded - * here. Both links open in a new tab so the tick survives reading them. + * Terms of Use and the Data Processing Agreement. The signup sign-in carries the tick to portal, + * where the onboarding records it (lib/signupConsent.ts); a tenant onboarded through any other + * sign-in confirms the same sentence in the portal's acceptance dialog. */ export function SignupConsentCta({ children }: { children?: React.ReactNode }) { const [accepted, setAccepted] = useState(false); @@ -23,23 +24,7 @@ export function SignupConsentCta({ children }: { children?: React.ReactNode }) { onChange={(e) => setAccepted(e.target.checked)} className="mt-0.5 h-4 w-4 shrink-0 cursor-pointer accent-[var(--lp-accent-ink)]" /> - - I agree to the{" "} - - Terms of Use - {" "} - and the{" "} - - Data Processing Agreement - - . - +
diff --git a/src/Web/autopilot-monitor-web/components/legal/TermsDpaAgreementText.tsx b/src/Web/autopilot-monitor-web/components/legal/TermsDpaAgreementText.tsx new file mode 100644 index 00000000..59aad64d --- /dev/null +++ b/src/Web/autopilot-monitor-web/components/legal/TermsDpaAgreementText.tsx @@ -0,0 +1,31 @@ +import { DOCS_URL } from "@/utils/config"; + +const TERMS_PATH = "/terms/"; +const DPA_URL = `${DOCS_URL}/legal/data-privacy-agreement-dpa`; + +/** + * The one wording of the Terms + DPA tick: the get-started CTA and the portal's acceptance dialog + * show the same sentence. Both links open in a new tab so the tick survives reading them. + */ +export function TermsDpaAgreementText({ + linkClassName, + trackIds, +}: { + linkClassName: string; + /** data-track ids for the marketing click count; the portal dialog sends none. */ + trackIds?: { terms: string; dpa: string }; +}) { + return ( + + I agree to the{" "} + + Terms of Use + {" "} + and the{" "} + + Data Processing Agreement + + . + + ); +} diff --git a/src/Web/autopilot-monitor-web/contexts/AuthContext.tsx b/src/Web/autopilot-monitor-web/contexts/AuthContext.tsx index 3687ef1c..63e6fbb8 100644 --- a/src/Web/autopilot-monitor-web/contexts/AuthContext.tsx +++ b/src/Web/autopilot-monitor-web/contexts/AuthContext.tsx @@ -25,8 +25,13 @@ import { classifyAuthMeRefusal } from '@/lib/authMeRefusal'; import { clearCachedAuthFetch } from '@/lib/cachedAuthFetch'; import { trackEvent } from '@/lib/appInsights'; import { seedDashboardFirstFetch } from '@/lib/dashboardSeed'; +import { captureSignupConsentFromUrl, hasSignupConsent, stripSignupConsentParam } from '@/lib/signupConsent'; import type { AuthMeResponse } from '@/types/auth'; +// Before the auth/me prefetch below can fire: a get-started sign-in carries its Terms + DPA tick +// into portal as ?signupConsent=1 (lib/signupConsent.ts). The address bar is cleaned on mount. +captureSignupConsentFromUrl(); + // Initialize MSAL instance for the ACTIVE app registration (dual app-reg window: the // selection was decided at module boot in lib/msalConfig.ts; app switches always reload). const msalInstance = new PublicClientApplication(msalConfig); @@ -122,7 +127,7 @@ const msalInitPromise = msalInstance // The dashboard's first list/stats requests run in parallel with auth/me instead of // behind it (consumed once by authenticatedFetch, see lib/dashboardSeed.ts). seedDashboardFirstFetch(tokenResponse.accessToken, accounts[0].tenantId); - const res = await fetch(api.auth.me(), { + const res = await fetch(api.auth.me({ signupConsent: hasSignupConsent() }), { headers: { 'Authorization': `Bearer ${tokenResponse.accessToken}` }, signal: AbortSignal.timeout(8000), }); @@ -283,6 +288,7 @@ function toUserInfo(data: AuthMeResponse, account: AccountInfo): UserInfo { // What's new seen marks: absent key = never viewed (first-visit window applies). whatsNewSeenPlatformUtc: data.whatsNewSeenPlatformUtc, whatsNewSeenAgentUtc: data.whatsNewSeenAgentUtc, + dpaAcceptancePending: data.dpaAcceptancePending || false, }; } @@ -308,6 +314,7 @@ function claimsOnlyUserInfo(account: AccountInfo): UserInfo { bootstrapTokenEnabled: false, unrestrictedModeEnabled: false, mcpClientRegistrationEnabled: false, + dpaAcceptancePending: false, }; } @@ -359,7 +366,7 @@ function AuthProviderInternal({ children }: { children: React.ReactNode }) { const authMeTimeout = setTimeout(() => authMeController.abort(), 8000); let response: Response; try { - response = await fetch(api.auth.me(), { + response = await fetch(api.auth.me({ signupConsent: hasSignupConsent() }), { headers: { 'Authorization': `Bearer ${tokenResponse.accessToken}`, }, @@ -440,6 +447,13 @@ function AuthProviderInternal({ children }: { children: React.ReactNode }) { } }, [accounts, fetchUserInfo]); + // Clean the signup marker out of the address bar (captured at module load) before ProtectedRoute + // can stash the URL as the post-login deep link — a bookmark must never carry it. + useEffect(() => { + const cleaned = stripSignupConsentParam(window.location.pathname + window.location.search + window.location.hash); + if (cleaned !== null) window.history.replaceState(null, '', cleaned); + }, []); + /** * Load user info when authentication state changes. * Waits for MSAL to be fully initialized before proceeding. diff --git a/src/Web/autopilot-monitor-web/lib/__tests__/signupConsent.test.ts b/src/Web/autopilot-monitor-web/lib/__tests__/signupConsent.test.ts new file mode 100644 index 00000000..67360ea8 --- /dev/null +++ b/src/Web/autopilot-monitor-web/lib/__tests__/signupConsent.test.ts @@ -0,0 +1,70 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + captureSignupConsentFromUrl, + hasSignupConsent, + markSignupConsent, + stripSignupConsentParam, +} from "../signupConsent"; + +/** Minimal browser shims — vitest runs in node (no jsdom in this suite). */ +function stubWindow(search = ""): void { + const map = new Map(); + vi.stubGlobal("window", { + sessionStorage: { + getItem: (k: string) => (map.has(k) ? map.get(k)! : null), + setItem: (k: string, v: string) => void map.set(k, v), + removeItem: (k: string) => void map.delete(k), + }, + location: { search }, + }); +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("signup consent marker", () => { + it("is captured from the get-started handover and expires after 30 minutes", () => { + stubWindow("?authapp=primary&signupConsent=1"); + const t0 = Date.now(); + captureSignupConsentFromUrl(); + expect(hasSignupConsent(t0 + 1000)).toBe(true); + expect(hasSignupConsent(t0 + 29 * 60 * 1000)).toBe(true); + expect(hasSignupConsent(t0 + 31 * 60 * 1000)).toBe(false); + }); + + it("is absent for every other sign-in", () => { + stubWindow("?authapp=primary"); + captureSignupConsentFromUrl(); + expect(hasSignupConsent()).toBe(false); + stubWindow("?signupConsent=0"); + captureSignupConsentFromUrl(); + expect(hasSignupConsent()).toBe(false); + }); + + it("is set directly by a same-origin get-started click", () => { + stubWindow(); + markSignupConsent(1_000); + expect(hasSignupConsent(2_000)).toBe(true); + // A stamp from the future (clock change) never counts. + expect(hasSignupConsent(500)).toBe(false); + }); + + it("fails closed without storage", () => { + vi.stubGlobal("window", { location: { search: "?signupConsent=1" } }); + captureSignupConsentFromUrl(); + expect(hasSignupConsent()).toBe(false); + }); +}); + +describe("stripSignupConsentParam", () => { + it("removes only the marker and keeps the rest of the address", () => { + expect(stripSignupConsentParam("/dashboard?authapp=primary&signupConsent=1#top")).toBe("/dashboard?authapp=primary#top"); + expect(stripSignupConsentParam("/dashboard?signupConsent=1")).toBe("/dashboard"); + }); + + it("leaves an address without the marker alone", () => { + expect(stripSignupConsentParam("/dashboard?authapp=primary")).toBeNull(); + expect(stripSignupConsentParam("/")).toBeNull(); + }); +}); diff --git a/src/Web/autopilot-monitor-web/lib/api.ts b/src/Web/autopilot-monitor-web/lib/api.ts index 1a523055..7b653424 100644 --- a/src/Web/autopilot-monitor-web/lib/api.ts +++ b/src/Web/autopilot-monitor-web/lib/api.ts @@ -24,8 +24,12 @@ function pushRoot(scope: PushScope): string { export const api = { // ── Auth ────────────────────────────────────────────────────────────────── auth: { - me: () => `${API_BASE_URL}/api/auth/me`, + /** signupConsent: this tab's sign-in came through the get-started Terms + DPA tick (lib/signupConsent.ts). */ + me: (opts?: { signupConsent?: boolean }) => + `${API_BASE_URL}/api/auth/me${qs({ signupConsent: opts?.signupConsent ? "1" : undefined })}`, whatsNewSeen: () => `${API_BASE_URL}/api/auth/me/whats-new-seen`, + /** POST — accepts the Terms + DPA for the caller's own tenant (portal dialog); 204. */ + dpaAcceptance: () => `${API_BASE_URL}/api/auth/dpa-acceptance`, }, // ── Sessions ────────────────────────────────────────────────────────────── diff --git a/src/Web/autopilot-monitor-web/lib/signupConsent.ts b/src/Web/autopilot-monitor-web/lib/signupConsent.ts new file mode 100644 index 00000000..77dfe388 --- /dev/null +++ b/src/Web/autopilot-monitor-web/lib/signupConsent.ts @@ -0,0 +1,51 @@ +/** + * Get-started signup marker. The get-started CTA stays inert until its Terms + DPA tick is set, + * so a sign-in started there tells the first auth/me (`?signupConsent=1`) that a brand-new + * tenant already accepted them — the onboarding records it, and the portal does not ask again. + * Any other first sign-in leaves the acceptance pending and the portal asks once. + * + * The CTA hands the marker to portal as `?signupConsent=1` (cross-origin) or sets it directly + * (same origin). It lives in sessionStorage — it survives the MSAL redirect in the same tab and + * dies with it — and the parameter is removed from the address bar right away, so a bookmark + * or a shared link never carries it. Only the tenant's very first sign-in reads it server-side. + */ + +const KEY = "am_signup_consent"; +export const SIGNUP_CONSENT_PARAM = "signupConsent"; +/** Long enough for a sign-in with consent prompts and MFA, short enough that a stale tab cannot reuse it. */ +const TTL_MS = 30 * 60 * 1000; + +export function markSignupConsent(now: number = Date.now()): void { + try { + window.sessionStorage.setItem(KEY, String(now)); + } catch { + // Storage unavailable: the portal then asks once in its dialog instead. + } +} + +export function hasSignupConsent(now: number = Date.now()): boolean { + try { + const stamp = Number(window.sessionStorage.getItem(KEY)); + return Number.isFinite(stamp) && stamp > 0 && now - stamp >= 0 && now - stamp < TTL_MS; + } catch { + return false; + } +} + +/** Reads the marker from the address bar into sessionStorage. Call before the first auth/me can fire. */ +export function captureSignupConsentFromUrl(): void { + if (typeof window === "undefined") return; + try { + if (new URLSearchParams(window.location.search).get(SIGNUP_CONSENT_PARAM) === "1") markSignupConsent(); + } catch { + // Unparseable location: nothing to capture. + } +} + +/** The address bar without the marker, or null when it carries none. */ +export function stripSignupConsentParam(pathWithQuery: string): string | null { + const url = new URL(pathWithQuery, "https://portal.invalid"); + if (!url.searchParams.has(SIGNUP_CONSENT_PARAM)) return null; + url.searchParams.delete(SIGNUP_CONSENT_PARAM); + return url.pathname + url.search + url.hash; +} diff --git a/src/Web/autopilot-monitor-web/utils/shared-manifests.generated.ts b/src/Web/autopilot-monitor-web/utils/shared-manifests.generated.ts index d358608f..4be1a9bf 100644 --- a/src/Web/autopilot-monitor-web/utils/shared-manifests.generated.ts +++ b/src/Web/autopilot-monitor-web/utils/shared-manifests.generated.ts @@ -129,6 +129,9 @@ export const SHARED_MANIFEST = { "updatedBy", "onboardedBy", "dpaVersion", + "dpaAcceptancePending", + "dpaAcceptedBy", + "dpaAcceptedAt", "contactEmail", "companyName", "onboardedAt", @@ -231,6 +234,9 @@ export const SHARED_MANIFEST = { "updatedBy": null, "onboardedBy": null, "dpaVersion": null, + "dpaAcceptancePending": false, + "dpaAcceptedBy": null, + "dpaAcceptedAt": null, "contactEmail": null, "companyName": null, "onboardedAt": null, diff --git a/src/Web/autopilot-monitor-web/utils/shared-manifests.json b/src/Web/autopilot-monitor-web/utils/shared-manifests.json index a53eca79..f784d6b6 100644 --- a/src/Web/autopilot-monitor-web/utils/shared-manifests.json +++ b/src/Web/autopilot-monitor-web/utils/shared-manifests.json @@ -125,6 +125,9 @@ "updatedBy", "onboardedBy", "dpaVersion", + "dpaAcceptancePending", + "dpaAcceptedBy", + "dpaAcceptedAt", "contactEmail", "companyName", "onboardedAt", @@ -227,6 +230,9 @@ "updatedBy": null, "onboardedBy": null, "dpaVersion": null, + "dpaAcceptancePending": false, + "dpaAcceptedBy": null, + "dpaAcceptedAt": null, "contactEmail": null, "companyName": null, "onboardedAt": null, @@ -4449,6 +4455,14 @@ }, "optional": true, "doc": "Same for the Agent tab; null = never and the key is omitted." + }, + { + "name": "dpaAcceptancePending", + "type": { + "kind": "primitive", + "name": "boolean" + }, + "doc": "True while the caller's tenant still has to accept the Terms + DPA (its first sign-in skipped the get-started tick); the portal asks before it opens." } ] }, @@ -22603,7 +22617,35 @@ "nullable": true }, "optional": true, - "doc": "Version of the data processing agreement this tenant accepted with its onboarding — CurrentDpaVersion at that moment. Set once in HandleNewTenantDomainAsync together with OnboardedBy and never overwritten; never client-writable. Null on tenants onboarded before the field existed." + "doc": "Version of the data processing agreement this tenant accepted — CurrentDpaVersion at that moment. Written once: with the onboarding when the first sign-in came through the get-started tick, otherwise when the portal's acceptance dialog is confirmed (DpaAcceptancePending). Never client-writable. Null on tenants onboarded before the field existed." + }, + { + "name": "dpaAcceptancePending", + "type": { + "kind": "primitive", + "name": "boolean" + }, + "doc": "True while a tenant whose first sign-in skipped the get-started Terms + DPA tick has not confirmed them in the portal yet; the portal asks before anything else. Set only by the onboarding write, cleared by the acceptance endpoint. Tenants onboarded before the field existed never carry it, so they are never asked." + }, + { + "name": "dpaAcceptedBy", + "type": { + "kind": "primitive", + "name": "string", + "nullable": true + }, + "optional": true, + "doc": "UPN of the user who accepted the Terms + DPA for this tenant; null when DpaVersion is null." + }, + { + "name": "dpaAcceptedAt", + "type": { + "kind": "primitive", + "name": "string", + "nullable": true + }, + "optional": true, + "doc": "When the Terms + DPA were accepted for this tenant; null when DpaVersion is null." }, { "name": "contactEmail", diff --git a/src/Web/autopilot-monitor-web/utils/wire-types.generated.ts b/src/Web/autopilot-monitor-web/utils/wire-types.generated.ts index 6b87a35c..5cb0e161 100644 --- a/src/Web/autopilot-monitor-web/utils/wire-types.generated.ts +++ b/src/Web/autopilot-monitor-web/utils/wire-types.generated.ts @@ -782,6 +782,8 @@ export interface AuthMeResponse { whatsNewSeenPlatformUtc?: string; /** Same for the Agent tab; null = never and the key is omitted. */ whatsNewSeenAgentUtc?: string; + /** True while the caller's tenant still has to accept the Terms + DPA (its first sign-in skipped the get-started tick); the portal asks before it opens. */ + dpaAcceptancePending: boolean; } export interface AutoResolveCpeMappingItem { @@ -4932,8 +4934,14 @@ export interface TenantConfiguration { updatedBy: string; /** UPN of the user whose first login created this tenant configuration. Set once in HandleNewTenantDomainAsync alongside DomainName and never overwritten. Used by the preview-approval auto-promote path so background jobs that mutate UpdatedBy (e.g. global rate-limit sync) cannot leak a sentinel string into the TenantAdmins table. Null on rows that pre-date the OnboardedBy field — auto-promote falls back to UpdatedBy with a UPN-shape guard. */ onboardedBy?: string | null; - /** Version of the data processing agreement this tenant accepted with its onboarding — CurrentDpaVersion at that moment. Set once in HandleNewTenantDomainAsync together with OnboardedBy and never overwritten; never client-writable. Null on tenants onboarded before the field existed. */ + /** Version of the data processing agreement this tenant accepted — CurrentDpaVersion at that moment. Written once: with the onboarding when the first sign-in came through the get-started tick, otherwise when the portal's acceptance dialog is confirmed (DpaAcceptancePending). Never client-writable. Null on tenants onboarded before the field existed. */ dpaVersion?: string | null; + /** True while a tenant whose first sign-in skipped the get-started Terms + DPA tick has not confirmed them in the portal yet; the portal asks before anything else. Set only by the onboarding write, cleared by the acceptance endpoint. Tenants onboarded before the field existed never carry it, so they are never asked. */ + dpaAcceptancePending: boolean; + /** UPN of the user who accepted the Terms + DPA for this tenant; null when DpaVersion is null. */ + dpaAcceptedBy?: string | null; + /** When the Terms + DPA were accepted for this tenant; null when DpaVersion is null. */ + dpaAcceptedAt?: string | null; /** Address used to reach this tenant about the service itself — a technical problem, a security matter, or a change that needs an administrator's attention. Editable by the tenant's own admins under Settings → Tenant → Contact. Seeded once at onboarding from the tenant's notification address if one was given, and never re-synced afterwards: from that point the value belongs to the tenant, and a later edit must not be overwritten by the onboarding source. Purpose-limited by design — service communication only. It is never used for marketing and never disclosed. Null means we have no way to reach this tenant, which is why enforcement actions cannot promise prior warning. */ contactEmail?: string | null; /** Organization name behind this tenant, as the tenant's admins want it read by a support engineer — the counterpart of ContactEmail in the tenant's contact profile. Editable under Settings → Tenant → Contact. Optional on Community. Together with it is required at the self-service Pro entry point (trial start) so a paying tenant is reachable and identifiable for support; it is never a runtime gate on Pro features, and a GA plan assignment does not require it (the admin UI warns instead). Never derived from : the domain is a technical label, the company name is what the tenant tells us. Null means not provided. */