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.
+ ⚠ 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.
+