Terms + DPA: a new tenant whose first sign-in skipped Get started confirms them once in the portal

- Get started keeps its tick as the acceptance: the CTA hands ?signupConsent=1 to portal (kept per
  tab for 30 minutes, removed from the address bar at once), the first auth/me forwards it, and the
  onboarding write records DpaVersion together with DpaAcceptedBy (first-login UPN) and
  DpaAcceptedAt.
- Any other first sign-in (the navbar "Sign in") sets DpaAcceptancePending on the new row instead.
  auth/me carries the flag; ProtectedRoute renders a blocking dialog with the same sentence and
  links as Get started (shared TermsDpaAgreementText) instead of the page; POST
  auth/dpa-acceptance writes the acceptance for the caller's own tenant only, a no-op 204 when
  nothing is pending.
- Tenants onboarded before this carry no flag and are never asked. Activation is unchanged; the
  dialog appears after it.
- The three new fields are system-owned like OnboardedBy (patch deny-list, revert-protected) and
  projected by config/all, the GA tenant report and the MCP tenant list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-10-08 18:23:42 +02:00
parent 5485f75620
commit 1271658b16
29 changed files with 648 additions and 56 deletions
@@ -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<Claim> { 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<FunctionContext>();
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()
{
@@ -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")]
@@ -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
});
}
@@ -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",
};
@@ -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,
@@ -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);
}
/// <summary>
/// POST /api/auth/dpa-acceptance
/// Records that the caller accepted the Terms + DPA for their own tenant, which clears the
/// portal's acceptance dialog (<see cref="TenantConfiguration.DpaAcceptancePending"/>). 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.
/// </summary>
[Function("AcceptDpa")]
[Authorize]
public async Task<HttpResponseData> 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.");
}
}
/// <summary>
/// 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.
/// </summary>
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);
}
@@ -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)
@@ -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,
@@ -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.
/// </summary>
internal static readonly HashSet<string> 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"
@@ -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);
}
@@ -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. */
@@ -171,6 +171,7 @@ export const TENANT_SAFE_FIELDS: ReadonlySet<string> = new Set([
'tenantId', 'domainName', 'planTier', 'trialExpiresUtc', 'trialConsumed',
'disabled', 'disabledReason',
'onboardedAt', 'onboardedBy', 'dpaVersion', 'lastUpdated', 'dataRetentionDays',
'dpaAcceptancePending', 'dpaAcceptedBy', 'dpaAcceptedAt',
]);
/**
@@ -45,13 +45,28 @@ namespace AutopilotMonitor.Shared.Models
public string? OnboardedBy { get; set; }
/// <summary>
/// Version of the data processing agreement this tenant accepted with its onboarding —
/// <see cref="Constants.CurrentDpaVersion"/> at that moment. Set once in
/// <c>HandleNewTenantDomainAsync</c> together with <see cref="OnboardedBy"/> and never
/// overwritten; never client-writable. Null on tenants onboarded before the field existed.
/// Version of the data processing agreement this tenant accepted —
/// <see cref="Constants.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 (<see cref="DpaAcceptancePending"/>). Never client-writable.
/// Null on tenants onboarded before the field existed.
/// </summary>
public string? DpaVersion { get; set; }
/// <summary>
/// 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.
/// </summary>
public bool DpaAcceptancePending { get; set; }
/// <summary>UPN of the user who accepted the Terms + DPA for this tenant; null when <see cref="DpaVersion"/> is null.</summary>
public string? DpaAcceptedBy { get; set; }
/// <summary>When the Terms + DPA were accepted for this tenant; null when <see cref="DpaVersion"/> is null.</summary>
public DateTime? DpaAcceptedAt { get; set; }
/// <summary>
/// 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
@@ -45,6 +45,9 @@ namespace AutopilotMonitor.Shared.Models
public DateTime? WhatsNewSeenPlatformUtc { get; set; }
/// <summary>Same for the Agent tab; null = never and the key is omitted.</summary>
public DateTime? WhatsNewSeenAgentUtc { get; set; }
/// <summary>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.</summary>
public bool DpaAcceptancePending { get; set; }
}
/// <summary>Body of PUT auth/me/whats-new-seen.</summary>
@@ -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",
@@ -103,6 +103,9 @@ export const TENANT_FIELDS: Record<keyof TenantConfiguration, TenantRow | Exclud
onboardedAt: { section: "Tenant Status", label: "Onboarded At", kind: "date", informational: true },
onboardedBy: { section: "Tenant Status", label: "Onboarded By", informational: true },
dpaVersion: { section: "Tenant Status", label: "DPA Version", informational: true },
dpaAcceptancePending: { section: "Tenant Status", label: "DPA Acceptance Pending", informational: true },
dpaAcceptedBy: { section: "Tenant Status", label: "DPA Accepted By", informational: true },
dpaAcceptedAt: { section: "Tenant Status", label: "DPA Accepted At", kind: "date", informational: true },
contactEmail: { section: "Tenant Status", label: "Contact Email", informational: true },
companyName: { section: "Tenant Status", label: "Company", informational: true },
@@ -59,6 +59,7 @@ const EXPECTED_OWNED_FIELDS = [
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",
@@ -0,0 +1,101 @@
"use client";
import { useState } from "react";
import { useAuth } from "@/contexts/AuthContext";
import { api } from "@/lib/api";
import { apiErrorText, fetchOk } from "@/lib/apiClient";
import { ModalPortal } from "./ModalPortal";
import { TermsDpaAgreementText } from "./legal/TermsDpaAgreementText";
/**
* Terms + DPA confirmation for a tenant whose first sign-in skipped the get-started tick
* (auth/me `dpaAcceptancePending`). ProtectedRoute renders it instead of the page, so nothing
* loads before the tick; there is no way to dismiss it other than accepting or signing out.
*/
export function DpaAcceptanceDialog() {
const { getAccessToken, refreshUserInfo, logout } = useAuth();
const [accepted, setAccepted] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(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 (
<ModalPortal>
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div
role="dialog"
aria-modal="true"
aria-labelledby="dpa-acceptance-title"
className="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full"
>
<div className="p-6">
<div className="flex items-center mb-4">
<div className="flex-shrink-0 w-12 h-12 bg-blue-100 dark:bg-blue-900/40 rounded-full flex items-center justify-center">
<svg className="w-6 h-6 text-blue-600 dark:text-blue-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
</div>
<h3 id="dpa-acceptance-title" className="ml-4 text-lg font-semibold text-gray-900 dark:text-gray-100">
Before you continue
</h3>
</div>
<p className="text-sm text-gray-700 dark:text-gray-300 mb-4">
Please confirm the terms for your organization.
</p>
<label className="flex items-start gap-3 text-sm text-gray-700 dark:text-gray-300 leading-relaxed cursor-pointer select-none mb-4">
<input
type="checkbox"
checked={accepted}
onChange={e => setAccepted(e.target.checked)}
disabled={saving}
className="mt-0.5 h-4 w-4 shrink-0 cursor-pointer accent-blue-600"
/>
<TermsDpaAgreementText linkClassName="text-blue-600 dark:text-blue-400 hover:underline" />
</label>
{error && (
<div className="mb-4 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg text-sm text-red-700 dark:text-red-300">
{error}
</div>
)}
<div className="flex items-center justify-between gap-3">
<button
type="button"
onClick={() => { logout().catch(() => { /* MSAL already logged the error */ }); }}
disabled={saving}
className="text-sm text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 disabled:opacity-50"
>
Sign out
</button>
<button
type="button"
onClick={handleContinue}
disabled={!accepted || saving}
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors disabled:opacity-50 flex items-center gap-2"
>
{saving && <div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>}
Continue
</button>
</div>
</div>
</div>
</div>
</ModalPortal>
);
}
@@ -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 <DpaAcceptanceDialog />;
}
// Show nothing if the route's platform requirement isn't met.
// requireGlobalAdmin → real Global Admin only; requireGlobalScope → Global Admin OR Global Reader.
const platformDenied =
@@ -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.
@@ -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)]"
/>
<span>
I agree to the{" "}
<a href="/terms/" target="_blank" rel="noopener noreferrer" data-track="signup_terms" className={LINK_CLASS}>
Terms of Use
</a>{" "}
and the{" "}
<a
href={`${DOCS_URL}/legal/data-privacy-agreement-dpa`}
data-track="signup_dpa"
target="_blank"
rel="noopener noreferrer"
className={LINK_CLASS}
>
Data Processing Agreement
</a>
.
</span>
<TermsDpaAgreementText linkClassName={LINK_CLASS} trackIds={{ terms: "signup_terms", dpa: "signup_dpa" }} />
</label>
<div className="mt-5 flex flex-wrap items-center gap-3">
@@ -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 (
<span>
I agree to the{" "}
<a href={TERMS_PATH} target="_blank" rel="noopener noreferrer" data-track={trackIds?.terms} className={linkClassName}>
Terms of Use
</a>{" "}
and the{" "}
<a href={DPA_URL} target="_blank" rel="noopener noreferrer" data-track={trackIds?.dpa} className={linkClassName}>
Data Processing Agreement
</a>
.
</span>
);
}
@@ -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.
@@ -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<string, string>();
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();
});
});
+5 -1
View File
@@ -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 ──────────────────────────────────────────────────────────────
@@ -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;
}
@@ -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,
@@ -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",
@@ -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. */