diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/GetTenantFeatureFlagsPayloadTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/GetTenantFeatureFlagsPayloadTests.cs index 2f28a7a9..30791f25 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/GetTenantFeatureFlagsPayloadTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/GetTenantFeatureFlagsPayloadTests.cs @@ -38,6 +38,9 @@ public class GetTenantFeatureFlagsPayloadTests // self-service migration banner). "appHomingFunnelActive", "bootstrapTokenEnabled", + // Non-sensitive by review: bool only ("is a contact address set"), never the + // address itself (admin-gated full config only). + "contactEmailSet", "diagnosticsUploadConfigured", "edition", "enableIntegrityBypassAnalyzer", @@ -249,4 +252,27 @@ public class GetTenantFeatureFlagsPayloadTests Assert.False(element.GetProperty("isTrial").GetBoolean()); Assert.False(element.GetProperty("trialAvailable").GetBoolean(), "consumed trial must not be offered again"); } + + // ── contactEmailSet (drives the Pro-requires-contact trial gate + banner) ── + + [Theory] + [InlineData(null, false)] + [InlineData("", false)] + [InlineData(" ", false)] + [InlineData("it-ops@contoso.com", true)] + public void Payload_ContactEmailSet_ReflectsWhetherAnAddressIsStored(string? stored, bool expected) + { + var element = Serialize(new TenantConfiguration { ContactEmail = stored }); + + Assert.Equal(expected, element.GetProperty("contactEmailSet").GetBoolean()); + } + + [Fact] + public void Payload_ContactEmailSet_NeverLeaksTheAddress() + { + var json = JsonSerializer.Serialize(GetTenantFeatureFlagsFunction.BuildPayload( + new TenantConfiguration { ContactEmail = "secret-contact@contoso.com" }, Now)); + + Assert.DoesNotContain("secret-contact", json); + } } diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/PlanManagementTrialGateTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/PlanManagementTrialGateTests.cs new file mode 100644 index 00000000..32aab0c5 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions.Tests/PlanManagementTrialGateTests.cs @@ -0,0 +1,76 @@ +using AutopilotMonitor.Functions.Functions.Config; +using AutopilotMonitor.Shared.Models; + +namespace AutopilotMonitor.Functions.Tests; + +/// +/// Tests for — the self-service +/// trial verdict, including the Pro-requires-contact gate. The check ORDER is semantic +/// and pinned here: terminal conditions (consumed / already Pro) must win over the +/// contact-address prompt, and the contact gate exists only at this entry point (never +/// as a runtime lockout for existing Pro tenants). +/// +public class PlanManagementTrialGateTests +{ + private static readonly DateTime Now = new(2026, 8, 2, 12, 0, 0, DateTimeKind.Utc); + private const string Contact = "it-ops@contoso.com"; + + [Fact] + public void Allowed_WhenCommunityWithContactAndUnconsumedTrial() + { + var config = new TenantConfiguration { ContactEmail = Contact }; + + Assert.Null(PlanManagementFunction.EvaluateTrialStart(config, Now)); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void Denied_ContactEmailRequired_WhenNoAddressStored(string? contact) + { + var config = new TenantConfiguration { ContactEmail = contact }; + + var deny = PlanManagementFunction.EvaluateTrialStart(config, Now); + + Assert.Equal("ContactEmailRequired", deny?.Error); + } + + [Fact] + public void Denied_TrialAlreadyConsumed_WinsOverMissingContact() + { + // A consumed trial is terminal — prompting for a contact address would be pointless. + var config = new TenantConfiguration { TrialConsumed = true, ContactEmail = null }; + + var deny = PlanManagementFunction.EvaluateTrialStart(config, Now); + + Assert.Equal("TrialAlreadyConsumed", deny?.Error); + } + + [Fact] + public void Denied_AlreadyPro_WinsOverMissingContact() + { + // Existing Pro tenants without a contact address are nagged via the dashboard + // banner, never blocked — the verdict must name the Pro state, not the address. + var config = new TenantConfiguration { PlanTier = "pro", ContactEmail = null }; + + var deny = PlanManagementFunction.EvaluateTrialStart(config, Now); + + Assert.Equal("AlreadyPro", deny?.Error); + } + + [Fact] + public void Denied_AlreadyPro_ForActiveTrialEdition() + { + var config = new TenantConfiguration + { + TrialExpiresUtc = Now.AddDays(5), + TrialConsumed = false, // hypothetical shape — edition resolution alone must deny + ContactEmail = Contact, + }; + + var deny = PlanManagementFunction.EvaluateTrialStart(config, Now); + + Assert.Equal("AlreadyPro", deny?.Error); + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Config/GetTenantFeatureFlagsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Config/GetTenantFeatureFlagsFunction.cs index 378021b1..5e65be9f 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Config/GetTenantFeatureFlagsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Config/GetTenantFeatureFlagsFunction.cs @@ -113,6 +113,10 @@ namespace AutopilotMonitor.Functions.Functions.Config isTrial, trialExpiresUtc = isTrial ? config.TrialExpiresUtc : null, trialAvailable = !config.TrialConsumed && edition == TenantEdition.Community, + // Pro-requires-contact surface: drives the trial CTA gate and the dashboard + // "set a contact address" banner for Pro tenants. Boolean only — the address + // itself stays in the admin-gated full config response. + contactEmailSet = !string.IsNullOrWhiteSpace(config.ContactEmail), entitlements = new { retentionCapDays = entitlements.RetentionCapDays, diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Config/PlanManagementFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Config/PlanManagementFunction.cs index 30ec5759..cf922409 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Config/PlanManagementFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Config/PlanManagementFunction.cs @@ -4,6 +4,7 @@ using AutopilotMonitor.Functions.Helpers; using AutopilotMonitor.Functions.Security; using AutopilotMonitor.Functions.Services; using AutopilotMonitor.Shared.DataAccess; +using AutopilotMonitor.Shared.Models; using AutopilotMonitor.Shared.Models.Config; using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Http; @@ -199,6 +200,36 @@ namespace AutopilotMonitor.Functions.Functions.Config /// 30-day Pro trial, exactly once per tenant. 409 when the trial was already /// consumed or the tenant is already effectively Pro. /// + /// + /// Pure verdict for the self-service trial start; null = allowed. Order matters and is + /// test-pinned: the terminal conditions (trial consumed, already Pro) win over the + /// contact-address prompt — asking for an address would be pointless there. The contact + /// requirement is enforced ONLY at this plan entry point, never as a runtime gate on Pro + /// features (existing Pro tenants get the dashboard banner instead of a lockout); GA + /// plan assignment (PATCH plan) deliberately has no such block — the admin UI warns. + /// + internal static (string Error, string Message)? EvaluateTrialStart(TenantConfiguration config, DateTime nowUtc) + { + if (config.TrialConsumed) + { + return ("TrialAlreadyConsumed", + "This tenant has already used its one self-service Pro trial. Contact support to extend."); + } + + if (FeatureEntitlementCatalog.ResolveEdition(config.PlanTier, config.TrialExpiresUtc, nowUtc) == TenantEdition.Pro) + { + return ("AlreadyPro", "This tenant is already on the Pro plan."); + } + + if (string.IsNullOrWhiteSpace(config.ContactEmail)) + { + return ("ContactEmailRequired", + "Pro requires a tenant contact address so we can reach you about service or security matters. Set it under Settings → Tenant → Contact, then start the trial."); + } + + return null; + } + [Function("StartTenantTrial")] public async Task StartTrial( [HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "config/{tenantId}/trial")] HttpRequestData req, @@ -220,25 +251,10 @@ namespace AutopilotMonitor.Functions.Functions.Config var nowUtc = _time.GetUtcNow().UtcDateTime; - if (config.TrialConsumed) + if (EvaluateTrialStart(config, nowUtc) is { } deny) { var conflict = req.CreateResponse(HttpStatusCode.Conflict); - await conflict.WriteAsJsonAsync(new - { - error = "TrialAlreadyConsumed", - message = "This tenant has already used its one self-service Pro trial. Contact support to extend." - }); - return conflict; - } - - if (FeatureEntitlementCatalog.ResolveEdition(config.PlanTier, config.TrialExpiresUtc, nowUtc) == TenantEdition.Pro) - { - var conflict = req.CreateResponse(HttpStatusCode.Conflict); - await conflict.WriteAsJsonAsync(new - { - error = "AlreadyPro", - message = "This tenant is already on the Pro plan." - }); + await conflict.WriteAsJsonAsync(new { error = deny.Error, message = deny.Message }); return conflict; } diff --git a/src/Web/autopilot-monitor-web/app/admin/components/TenantManagementSection.tsx b/src/Web/autopilot-monitor-web/app/admin/components/TenantManagementSection.tsx index 6832d600..6b7a68a3 100644 --- a/src/Web/autopilot-monitor-web/app/admin/components/TenantManagementSection.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/components/TenantManagementSection.tsx @@ -774,6 +774,14 @@ function TenantManagementSectionInner({ Stored legacy tier "{editingTenant.planTier}" resolves to Community. Saving normalizes it.

)} + {(editingTenant.planTier === "pro" || editingTenant.planTier === "enterprise") && !editingTenant.contactEmail && ( +

+ ⚠ No contact address set — Pro tenants should be reachable for service and + security matters. Self-service trials are blocked without one; GA assignment + is not, and the tenant sees a dashboard reminder until an admin sets it + (Settings → Tenant → Contact). +

+ )}
diff --git a/src/Web/autopilot-monitor-web/app/dashboard/hooks/useTenantSecurityConfig.ts b/src/Web/autopilot-monitor-web/app/dashboard/hooks/useTenantSecurityConfig.ts index f0e9c1e1..294eede8 100644 --- a/src/Web/autopilot-monitor-web/app/dashboard/hooks/useTenantSecurityConfig.ts +++ b/src/Web/autopilot-monitor-web/app/dashboard/hooks/useTenantSecurityConfig.ts @@ -16,6 +16,8 @@ type AddNotification = ( interface TenantConfigurationSummary { validateAutopilotDevice: boolean; + edition?: string; + contactEmailSet?: boolean; } interface User { @@ -24,11 +26,22 @@ interface User { role?: string | null; } +export interface TenantSecuritySummary { + /** null while loading or on error; the red banner keys on `=== false`. */ + serialValidationEnabled: boolean | null; + /** + * Pro tenant (incl. trial) with no stored contact address — drives the amber + * "set a contact address" banner. Requires an EXPLICIT contactEmailSet:false from + * the backend, so loading/error/older-backend states never nag. + */ + proContactMissing: boolean; +} + /** - * Fetches the tenant's security config (validateAutopilotDevice flag) to drive the - * "Autopilot Device Validation is disabled" banner on the dashboard. + * Fetches the tenant's feature-flags summary to drive the dashboard banners: + * the red "Autopilot Device Validation is disabled" banner and the amber + * Pro-without-contact-address banner. * - * Returns null while loading, true/false once resolved, or null on error. * Skips the fetch for regular users (they never see the dashboard). */ export function useTenantSecurityConfig( @@ -36,8 +49,11 @@ export function useTenantSecurityConfig( user: User | null | undefined, getAccessToken: (forceRefresh?: boolean) => Promise, addNotification: AddNotification, -): boolean | null { - const [serialValidationEnabled, setSerialValidationEnabled] = useState(null); +): TenantSecuritySummary { + const [summary, setSummary] = useState({ + serialValidationEnabled: null, + proContactMissing: false, + }); useEffect(() => { // Wait until both tenant and user are resolved. Without the !user guard @@ -54,17 +70,22 @@ export function useTenantSecurityConfig( const response = await dedupedAuthFetch(api.config.featureFlags(tenantId), getAccessToken); if (!response.ok) { - setSerialValidationEnabled(null); + setSummary({ serialValidationEnabled: null, proContactMissing: false }); return; } const data: TenantConfigurationSummary = await response.json(); - setSerialValidationEnabled(!!data.validateAutopilotDevice); + setSummary({ + serialValidationEnabled: !!data.validateAutopilotDevice, + proContactMissing: + (data.edition === "pro" || data.edition === "enterprise") && + data.contactEmailSet === false, + }); } catch (error) { if (error instanceof TokenExpiredError) { addNotification('error', 'Session Expired', error.message, 'session-expired-error'); } - setSerialValidationEnabled(null); + setSummary({ serialValidationEnabled: null, proContactMissing: false }); } }; @@ -77,5 +98,5 @@ export function useTenantSecurityConfig( // eslint-disable-next-line react-hooks/exhaustive-deps }, [tenantId, user?.isTenantAdmin, user?.isGlobalAdmin, user?.role]); - return serialValidationEnabled; + return summary; } diff --git a/src/Web/autopilot-monitor-web/app/dashboard/page.tsx b/src/Web/autopilot-monitor-web/app/dashboard/page.tsx index bd50aff8..34739ab5 100644 --- a/src/Web/autopilot-monitor-web/app/dashboard/page.tsx +++ b/src/Web/autopilot-monitor-web/app/dashboard/page.tsx @@ -1,6 +1,7 @@ "use client"; import { sessionUrl } from "@/lib/routes"; +import Link from "next/link"; import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useRouter, useSearchParams } from "next/navigation"; import { ProtectedRoute } from "../../components/ProtectedRoute"; @@ -188,7 +189,7 @@ function HomeContent() { } }, [user, router]); - const serialValidationEnabled = useTenantSecurityConfig(tenantId, user, getAccessToken, addNotification); + const { serialValidationEnabled, proContactMissing } = useTenantSecurityConfig(tenantId, user, getAccessToken, addNotification); const rawTenantList = useTenantList(crossTenant, getAccessToken); // Delegated: bound the tenant filter's autocomplete to the managed subset (defense in depth on top of the // backend-bounded config/all), plus the caller's own HOME tenant when they hold a member role there — @@ -363,6 +364,30 @@ function HomeContent() {
)} + {/* Pro-requires-contact nag (amber, non-blocking): Pro tenants keep every feature, + but should be reachable. Admins only — they own the Contact section; delegated + admins are excluded (the link would target their own tenant, not the viewed one). */} + {proContactMissing && !isDelegated && (user?.isTenantAdmin || user?.isGlobalAdmin) && ( +
+
+ + + +

+ Your Pro tenant has no contact address.{" "} + Set one so we can reach you about service or security matters — it is used for + nothing else and never shared. +

+
+ + Set contact address + +
+ )} + {/* Stats cards — server-aggregated (see useDashboardStats). `dashboardStats === null` covers both initial load and post-scope-change reset; show a non-zero placeholder so a fetch error doesn't masquerade diff --git a/src/Web/autopilot-monitor-web/app/settings/tenant/sections/SectionPlan.tsx b/src/Web/autopilot-monitor-web/app/settings/tenant/sections/SectionPlan.tsx index 61cee189..3e9a484e 100644 --- a/src/Web/autopilot-monitor-web/app/settings/tenant/sections/SectionPlan.tsx +++ b/src/Web/autopilot-monitor-web/app/settings/tenant/sections/SectionPlan.tsx @@ -1,6 +1,7 @@ "use client"; import { useState } from "react"; +import Link from "next/link"; import { useTenantConfig } from "../../TenantConfigContext"; import { trialDaysLeft } from "@/lib/edition"; @@ -221,6 +222,17 @@ export function SectionPlan() { > Start 30-day Pro trial — coming soon + ) : canStartTrial && !editionInfo.contactEmailSet ? ( + // Pro-requires-contact gate (backend enforces the same via 409 + // ContactEmailRequired — this branch just makes the path obvious). +
+ Pro requires a contact address.{" "} + Set one under{" "} + + Contact + {" "} + so we can reach you about service or security matters — then start your trial here. +
) : canStartTrial && !confirming ? (