fix(offboarding): farewell feedback reaches the backend again; farewell mail leaves an ops event; suspended page instead of alert

The offboard endpoint tombstones the tenant (Disabled=true, "Offboarding in
progress") before it answers 202, so the departing admin submits the farewell
feedback from an already-suspended tenant. Since the suspension gate moved onto
the shared authorization path (43ddc601, 2026-08-29) every feedback POST was
403 TenantSuspended — invisible, because nobody clicked and the two stored
feedbacks predate the gate (D-270).

- Endpoint catalog: `AllowedDuringOffboarding` on exactly
  POST tenants/{tenantId}/offboard/feedback; the middleware ignores the
  offboarding tombstone for that route only. An operator suspension still
  denies; policy tier, cross-tenant check and the function's active-marker
  check are untouched. Middleware tests for all four cases plus a catalog
  ratchet pinning the flag to the one route.
- Farewell mail: IOffboardFarewellEmailSender.SendAsync returns whether the
  provider accepted; the offboarding worker records FarewellEmailSent /
  FarewellEmailSkipped / FarewellEmailFailed like the welcome mail — the
  send's own success log is Information and never reaches Application
  Insights. Missing Email:ApiKey logs Warning. Handler tests per outcome;
  OpsAlertRules catalog and the generated manifests (Web + MCP vocabulary)
  carry the three types.
- Web: auth/me 403 TenantSuspended no longer shows alert() and forces a
  logout. AuthContext keeps the user signed in with claims-only info and
  ProtectedRoute renders a suspended page: for the offboarding tombstone
  "Your organization is being offboarded" with the farewell feedback form
  (a reload during the 6-minute preparation window keeps the form), for any
  other suspension the reason text plus contact links; both with sign-out.
  The feedback widget moved from OffboardingSection into
  components/OffboardingFeedbackForm.tsx.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-09-22 21:18:44 +02:00
parent 7a7a31e8e3
commit efcb4f9ca1
23 changed files with 592 additions and 189 deletions
@@ -32,24 +32,28 @@ public sealed class EmailServiceTests
// ----- configuration gates -----
[Fact]
public async Task SendAsync_EmptyApiKey_NoOps_AndLogsMissingKeyDebugLine()
public async Task SendAsync_EmptyApiKey_NoOps_ReturnsFalse_AndLogsMissingKeyWarning()
{
var (sut, handler, logger) = Build(apiKey: "");
await sut.SendAsync("ops@contoso.invalid", "contoso.invalid", TenantId, CancellationToken.None);
var sent = await sut.SendAsync("ops@contoso.invalid", "contoso.invalid", TenantId, CancellationToken.None);
Assert.False(sent);
Assert.Equal(0, handler.CallCount);
// Warning, not Debug: same reasoning as the welcome mail — worker logs below Warning never
// reach Application Insights, and the handler records FarewellEmailFailed on the false.
Assert.Contains(logger.Entries,
e => e.Level == LogLevel.Debug && e.Message.Contains("Email:ApiKey not configured"));
e => e.Level == LogLevel.Warning && e.Message.Contains("Email:ApiKey not configured"));
}
[Fact]
public async Task SendAsync_EmptyRecipient_NoOps_AndLogsMissingEmailDebugLine()
public async Task SendAsync_EmptyRecipient_NoOps_ReturnsFalse_AndLogsMissingEmailDebugLine()
{
var (sut, handler, logger) = Build(apiKey: "any-non-empty-key");
await sut.SendAsync("", "contoso.invalid", TenantId);
var sent = await sut.SendAsync("", "contoso.invalid", TenantId);
Assert.False(sent);
Assert.Equal(0, handler.CallCount);
Assert.Contains(logger.Entries,
e => e.Level == LogLevel.Debug && e.Message.Contains("No notification email captured"));
@@ -65,7 +69,7 @@ public sealed class EmailServiceTests
await sut.SendAsync("", "contoso.invalid", TenantId);
Assert.Contains(logger.Entries,
e => e.Level == LogLevel.Debug && e.Message.Contains("Email:ApiKey not configured"));
e => e.Level == LogLevel.Warning && e.Message.Contains("Email:ApiKey not configured"));
Assert.DoesNotContain(logger.Entries,
e => e.Message.Contains("No notification email captured"));
}
@@ -123,8 +127,9 @@ public sealed class EmailServiceTests
{
var (sut, handler, logger) = Build(apiKey: "md-test-key");
await sut.SendAsync("ops@contoso.invalid", "contoso.invalid", TenantId);
var sent = await sut.SendAsync("ops@contoso.invalid", "contoso.invalid", TenantId);
Assert.True(sent);
using var doc = JsonDocument.Parse(handler.LastBody!);
var message = doc.RootElement.GetProperty("message");
Assert.Equal(EmailTemplates.OffboardingFarewellSubject, message.GetProperty("subject").GetString());
@@ -161,8 +166,9 @@ public sealed class EmailServiceTests
responder: _ => Json(HttpStatusCode.InternalServerError,
"{\"status\":\"error\",\"code\":-1,\"name\":\"Invalid_Key\",\"message\":\"Invalid API key\"}"));
await sut.SendAsync("ops@contoso.invalid", "contoso.invalid", TenantId);
var sent = await sut.SendAsync("ops@contoso.invalid", "contoso.invalid", TenantId);
Assert.False(sent);
Assert.Contains(logger.Entries,
e => e.Level == LogLevel.Warning && e.Message.Contains("500") && e.Message.Contains("Invalid_Key"));
Assert.DoesNotContain(logger.Entries, e => e.Level == LogLevel.Information);
@@ -175,8 +181,9 @@ public sealed class EmailServiceTests
responder: _ => Json(HttpStatusCode.OK,
"[{\"email\":\"ops@contoso.invalid\",\"status\":\"rejected\",\"reject_reason\":\"hard-bounce\",\"_id\":\"abc\"}]"));
await sut.SendAsync("ops@contoso.invalid", "contoso.invalid", TenantId);
var sent = await sut.SendAsync("ops@contoso.invalid", "contoso.invalid", TenantId);
Assert.False(sent);
Assert.Contains(logger.Entries,
e => e.Level == LogLevel.Warning && e.Message.Contains("rejected") && e.Message.Contains("hard-bounce"));
Assert.DoesNotContain(logger.Entries, e => e.Level == LogLevel.Information);
@@ -192,8 +199,9 @@ public sealed class EmailServiceTests
responder: _ => Json(HttpStatusCode.OK,
$"[{{\"email\":\"ops@contoso.invalid\",\"status\":\"{status}\",\"_id\":\"abc\"}}]"));
await sut.SendAsync("ops@contoso.invalid", "contoso.invalid", TenantId);
var sent = await sut.SendAsync("ops@contoso.invalid", "contoso.invalid", TenantId);
Assert.True(sent);
Assert.Contains(logger.Entries, e => e.Level == LogLevel.Information);
Assert.DoesNotContain(logger.Entries, e => e.Level == LogLevel.Warning);
}
@@ -204,8 +212,9 @@ public sealed class EmailServiceTests
var (sut, _, logger) = Build(apiKey: "md-test-key",
responder: _ => Json(HttpStatusCode.OK, "not json"));
await sut.SendAsync("ops@contoso.invalid", "contoso.invalid", TenantId);
var sent = await sut.SendAsync("ops@contoso.invalid", "contoso.invalid", TenantId);
Assert.False(sent);
Assert.Contains(logger.Entries, e => e.Level == LogLevel.Warning);
Assert.DoesNotContain(logger.Entries, e => e.Level == LogLevel.Information);
}
@@ -237,6 +237,22 @@ public class EndpointPolicyCatalogCompletenessTests
string.Join("\n", missing.Select(m => $" - {m}")));
}
/// <summary>
/// AllowedDuringOffboarding punches a hole in the tenant suspension gate (offboarding tombstone only).
/// It exists for exactly one route — the farewell feedback a departing admin submits after the offboard
/// endpoint has already tombstoned their tenant. Any second route here needs the same justification.
/// </summary>
[Fact]
public void AllowedDuringOffboarding_IsOnlyTheFarewellFeedbackRoute()
{
var routes = EndpointAccessPolicyCatalog.Entries
.Where(e => e.AllowedDuringOffboarding)
.Select(e => $"{e.HttpMethod} {e.RouteTemplate}")
.ToList();
Assert.Equal(new[] { "POST tenants/{tenantId}/offboard/feedback" }, routes);
}
/// <summary>
/// Every entry with TenantScoping.RouteParam must have {tenantId} in its route template.
/// Prevents misattributed scoping declarations.
@@ -7,18 +7,20 @@ namespace AutopilotMonitor.Functions.Tests.Offboarding;
/// <summary>
/// Test double for <see cref="IOffboardFarewellEmailSender"/>. Records every invocation so
/// the handler-side tests can assert "did the farewell send fire after History → Completed?"
/// without booting the real mail provider client. Default = silent noop; set <see cref="ThrowOnSend"/>
/// to simulate downstream failures and prove the handler's fail-soft contract.
/// without booting the real mail provider client. Default = accepted (<see cref="Result"/> true);
/// set <see cref="Result"/> false to simulate a refused send, or <see cref="ThrowOnSend"/> to
/// simulate downstream failures and prove the handler's fail-soft contract.
/// </summary>
internal sealed class FakeOffboardFarewellEmailSender : IOffboardFarewellEmailSender
{
public readonly List<(string ToEmail, string DomainName, string TenantId)> Calls = new();
public bool Result { get; set; } = true;
public System.Exception? ThrowOnSend { get; set; }
public Task SendAsync(string toEmail, string domainName, string tenantId, CancellationToken ct = default)
public Task<bool> SendAsync(string toEmail, string domainName, string tenantId, CancellationToken ct = default)
{
Calls.Add((toEmail, domainName, tenantId));
if (ThrowOnSend is { } ex) throw ex;
return Task.CompletedTask;
return Task.FromResult(Result);
}
}
@@ -383,6 +383,77 @@ public class TenantOffboardingHandlerDrainContractTests
Times.Never(), "Drain-skip-gate must also bypass the enumerate+enqueue phase");
}
// ── Farewell mail record (Side-effect 6) ────────────────────────────────────
// The send fails soft and its success log never reaches Application Insights, so the ops
// event is the only record of whether the departing customer got the farewell mail.
[Fact]
public async Task Completed_WithCapturedAddress_ProviderAccepts_RecordsFarewellEmailSent()
{
var harness = Harness.New();
harness.Repo.History[HistoryRowKey].NotificationEmail = "it@contoso.invalid";
await harness.Sut.HandleAsync(harness.Envelope(drainPollCount: 0));
Assert.Equal("Completed", harness.Repo.History[HistoryRowKey].Status);
var call = Assert.Single(harness.Farewell.Calls);
Assert.Equal(("it@contoso.invalid", "contoso.invalid", TenantId), call);
var evt = Assert.Single(harness.OpsEvents, e => e.EventType.StartsWith("FarewellEmail", StringComparison.Ordinal));
Assert.Equal(OpsEventTypes.FarewellEmailSent, evt.EventType);
Assert.Equal(OpsEventSeverity.Info, evt.Severity);
Assert.Equal(TenantId, evt.TenantId);
Assert.Contains("it@contoso.invalid", evt.Message);
}
[Fact]
public async Task Completed_WithoutCapturedAddress_RecordsFarewellEmailSkipped_AndNeverCallsSender()
{
var harness = Harness.New();
harness.Repo.History[HistoryRowKey].NotificationEmail = null;
await harness.Sut.HandleAsync(harness.Envelope(drainPollCount: 0));
Assert.Equal("Completed", harness.Repo.History[HistoryRowKey].Status);
Assert.Empty(harness.Farewell.Calls);
var evt = Assert.Single(harness.OpsEvents, e => e.EventType.StartsWith("FarewellEmail", StringComparison.Ordinal));
Assert.Equal(OpsEventTypes.FarewellEmailSkipped, evt.EventType);
Assert.Equal(OpsEventSeverity.Warning, evt.Severity);
Assert.Contains("no contact address", evt.Message);
}
[Fact]
public async Task Completed_ProviderRefuses_RecordsFarewellEmailFailed()
{
var harness = Harness.New();
harness.Repo.History[HistoryRowKey].NotificationEmail = "it@contoso.invalid";
harness.Farewell.Result = false;
await harness.Sut.HandleAsync(harness.Envelope(drainPollCount: 0));
var evt = Assert.Single(harness.OpsEvents, e => e.EventType.StartsWith("FarewellEmail", StringComparison.Ordinal));
Assert.Equal(OpsEventTypes.FarewellEmailFailed, evt.EventType);
Assert.Equal(OpsEventSeverity.Error, evt.Severity);
Assert.Contains("did not accept", evt.Message);
}
[Fact]
public async Task Completed_SenderThrows_RecordsFarewellEmailFailed_AndStillCompletes()
{
// Fail-soft contract: the History row is already Completed when the mail goes out; a
// throwing sender must neither propagate nor stop the TenantConfiguration delete after it.
var harness = Harness.New();
harness.Repo.History[HistoryRowKey].NotificationEmail = "it@contoso.invalid";
harness.Farewell.ThrowOnSend = new HttpRequestException("simulated provider outage");
await harness.Sut.HandleAsync(harness.Envelope(drainPollCount: 0));
Assert.Equal("Completed", harness.Repo.History[HistoryRowKey].Status);
Assert.Equal("Completed", harness.Repo.Markers[TenantId].Status);
var evt = Assert.Single(harness.OpsEvents, e => e.EventType.StartsWith("FarewellEmail", StringComparison.Ordinal));
Assert.Equal(OpsEventTypes.FarewellEmailFailed, evt.EventType);
Assert.Contains("HttpRequestException", evt.Message);
}
[Fact]
public async Task History_AlreadyCompleted_IsNoOp()
{
@@ -436,6 +507,9 @@ public class TenantOffboardingHandlerDrainContractTests
public CountingSafeWipeService SafeWipeProbe { get; } = new();
public Mock<IMaintenanceRepository> Maintenance { get; } = new();
public Mock<IConfigRepository> ConfigRepo { get; } = new();
public FakeOffboardFarewellEmailSender Farewell { get; } = new();
/// <summary>Every ops event the handler recorded, in order (the repository is a capturing mock).</summary>
public List<OpsEventEntry> OpsEvents { get; } = new();
public List<string> EnumeratorYields { get; } = new();
public Exception? EnumeratorThrow { get; set; }
@@ -475,7 +549,7 @@ public class TenantOffboardingHandlerDrainContractTests
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<Dictionary<string, string>?>()))
.ReturnsAsync(true);
var opsService = BuildRealOpsService();
var opsService = BuildRealOpsService(h.OpsEvents);
var unusedTableStorage = new Mock<TableStorageService>(
Mock.Of<TableServiceClient>(),
@@ -494,7 +568,7 @@ public class TenantOffboardingHandlerDrainContractTests
opsService,
Mock.Of<ITenantCustomsArchiveRepository>(),
h.ConfigRepo.Object,
new FakeOffboardFarewellEmailSender(),
h.Farewell,
NullLogger<TenantOffboardingHandler>.Instance);
return h;
@@ -578,10 +652,12 @@ public class TenantOffboardingHandlerDrainContractTests
}, "\"0xFAKE_PTR_1\"");
}
private static OpsEventService BuildRealOpsService()
private static OpsEventService BuildRealOpsService(List<OpsEventEntry> captured)
{
var opsRepo = new Mock<IOpsEventRepository>();
opsRepo.Setup(r => r.SaveOpsEventAsync(It.IsAny<OpsEventEntry>())).Returns(Task.CompletedTask);
opsRepo.Setup(r => r.SaveOpsEventAsync(It.IsAny<OpsEventEntry>()))
.Callback<OpsEventEntry>(captured.Add)
.Returns(Task.CompletedTask);
var memCache = new MemoryCache(new MemoryCacheOptions());
var adminConfig = new Mock<AdminConfigurationService>(
Mock.Of<IConfigRepository>(), NullLogger<AdminConfigurationService>.Instance, memCache);
@@ -1,4 +1,5 @@
using System.Security.Claims;
using AutopilotMonitor.Functions.Functions.Admin;
using AutopilotMonitor.Functions.Functions.Config;
using AutopilotMonitor.Functions.Middleware;
using AutopilotMonitor.Functions.Security;
@@ -1623,6 +1624,82 @@ public class PolicyEnforcementMiddlewareTests
h.ConfigRepo.Verify(r => r.GetTenantConfigurationAsync(It.IsAny<string>()), Times.Never);
}
// ── Offboarding tombstone vs. the farewell feedback route (AllowedDuringOffboarding) ──────
// The offboard endpoint tombstones the tenant (Disabled=true, offboarding reason) BEFORE it answers
// 202, so the departing admin submits the farewell feedback from an already-suspended tenant. That
// one route opts out of the tombstone gate; every other route and every other suspension stay closed.
private static void AsOffboardingTombstone(Harness h, string tenantId) =>
AsSuspended(h, tenantId, reason: TenantOffboardFunction.OffboardingDisabledReason);
[Fact]
public async Task OffboardingTombstone_FeedbackRoute_IsAllowed_ForDepartingTenantAdmin()
{
const string upn = "admin@contoso.com";
var h = BuildHarness();
h.AsTenantAdmin(TenantA, upn);
AsOffboardingTombstone(h, TenantA);
var result = await h.Middleware.DecideAsync(
"POST", $"/api/tenants/{TenantA}/offboard/feedback", null, AuthedPrincipal(TenantA, upn));
Assert.True(result.Allowed);
Assert.Equal(TenantA, result.Context!.TargetTenantId);
}
[Fact]
public async Task OffboardingTombstone_OtherRoutes_StayForbidden()
{
// The opt-out is per route: the same tombstone still closes the rest of the API.
const string upn = "admin@contoso.com";
var h = BuildHarness();
h.AsTenantAdmin(TenantA, upn);
AsOffboardingTombstone(h, TenantA);
var read = await h.Middleware.DecideAsync("GET", "/api/sessions", null, AuthedPrincipal(TenantA, upn));
var write = await h.Middleware.DecideAsync("DELETE", $"/api/tenants/{TenantA}/offboard", null, AuthedPrincipal(TenantA, upn));
Assert.False(read.Allowed);
Assert.Equal("TenantSuspended", read.ErrorCode);
Assert.Equal(TenantOffboardFunction.OffboardingDisabledReason, read.ErrorMessage);
Assert.False(write.Allowed);
Assert.Equal("TenantSuspended", write.ErrorCode);
}
[Fact]
public async Task OperatorSuspension_FeedbackRoute_StaysForbidden()
{
// The opt-out is for the offboarding tombstone only: a tenant an operator suspended (any other
// reason) cannot reach the feedback route either — nothing in the flag grants access.
const string upn = "admin@contoso.com";
var h = BuildHarness();
h.AsTenantAdmin(TenantA, upn);
AsSuspended(h, TenantA, reason: "Abuse investigation");
var result = await h.Middleware.DecideAsync(
"POST", $"/api/tenants/{TenantA}/offboard/feedback", null, AuthedPrincipal(TenantA, upn));
Assert.False(result.Allowed);
Assert.Equal("TenantSuspended", result.ErrorCode);
}
[Fact]
public async Task OffboardingTombstone_FeedbackRoute_NonMember_StillNeedsTenantAdmin()
{
// Policy tier is untouched by the flag: a plain member (no tenant admin role) is denied by the
// TenantAdminOrGA tier before the suspension gate is even reached.
const string upn = "viewer@contoso.com";
var h = BuildHarness();
AsOffboardingTombstone(h, TenantA);
var result = await h.Middleware.DecideAsync(
"POST", $"/api/tenants/{TenantA}/offboard/feedback", null, AuthedPrincipal(TenantA, upn));
Assert.False(result.Allowed);
Assert.Equal(403, result.StatusCode);
Assert.NotEqual("TenantSuspended", result.ErrorCode);
}
// ── Tenant MCP switch (TenantConfiguration.McpDisabled) — the per-request half ────────────
// The MCP front door (McpUserService) already denies a caller whose home tenant is closed; this gate
// is what makes the switch hold per request (front-door verdicts are cached) and what keeps a closed
@@ -1,6 +1,7 @@
using System.Net;
using System.Security.Claims;
using AutopilotMonitor.Functions.Extensions;
using AutopilotMonitor.Functions.Functions.Admin;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Functions.Services;
@@ -303,10 +304,13 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
// and nobody without platform scope reaches a suspended target. Platform operators (GA/Reader) bypass —
// they must still administer the suspended tenant. Anonymous/device routes bring their own gate
// (SecurityValidator → 403 TenantDisabled). Cached, side-effect-free read; a missing row is not disabled.
// An AllowedDuringOffboarding route ignores the offboarding tombstone alone (the departing admin's
// farewell feedback arrives after their own tenant was tombstoned); any other suspension still holds.
if (principal != null && !hasGlobalScope)
{
var gateConfigs = await LoadGateConfigsAsync(jwtTenantId, targetTenantId);
var suspended = gateConfigs.FirstOrDefault(c => c.IsCurrentlyDisabled());
var suspended = gateConfigs.FirstOrDefault(c => c.IsCurrentlyDisabled()
&& !(catalogEntry.AllowedDuringOffboarding && TenantOffboardFunction.IsOffboardingTombstone(c)));
if (suspended != null)
{
_logger.LogWarning("[PolicyEnforcement] BLOCKED suspended tenant: user={User} tenant={Tenant} path={Path}",
@@ -134,11 +134,24 @@ public sealed class EndpointPolicyEntry
/// </summary>
public bool ApplicationAllowed { get; }
/// <summary>
/// When true, the tenant suspension gate lets the caller through while the ONLY reason the tenant
/// is suspended is the offboarding tombstone (<c>Disabled=true</c> with the offboarding reason,
/// <see cref="Functions.Admin.TenantOffboardFunction.IsOffboardingTombstone"/>). An operator
/// suspension (any other reason, or a DisabledUntil window) still denies. Exists for the one route
/// a departing admin must reach AFTER their tenant has been tombstoned: the farewell feedback
/// (<c>POST tenants/{tenantId}/offboard/feedback</c>), which the offboard endpoint tombstones
/// before it even answers 202. Never grants anything else — policy tier, cross-tenant check and
/// the function's own active-marker check stay in force.
/// </summary>
public bool AllowedDuringOffboarding { get; }
// Pre-compiled regex for matching actual request paths against the route template
internal Regex RouteRegex { get; }
public EndpointPolicyEntry(string httpMethod, string routeTemplate, EndpointPolicy policy,
TenantScoping tenantScoping = TenantScoping.None, bool excludeDelegated = false, bool applicationAllowed = false)
TenantScoping tenantScoping = TenantScoping.None, bool excludeDelegated = false, bool applicationAllowed = false,
bool allowedDuringOffboarding = false)
{
HttpMethod = httpMethod.ToUpperInvariant();
RouteTemplate = routeTemplate;
@@ -146,6 +159,7 @@ public sealed class EndpointPolicyEntry
TenantScoping = tenantScoping;
ExcludeDelegated = excludeDelegated;
ApplicationAllowed = applicationAllowed;
AllowedDuringOffboarding = allowedDuringOffboarding;
RouteRegex = BuildRouteRegex(routeTemplate);
}
@@ -367,7 +381,9 @@ public static class EndpointAccessPolicyCatalog
new("PATCH", "tenants/{tenantId}/admins/{adminUpn}/enable", EndpointPolicy.TenantAdminOrGA, TenantScoping.RouteParam),
new("PATCH", "tenants/{tenantId}/admins/{adminUpn}/permissions", EndpointPolicy.TenantAdminOrGA, TenantScoping.RouteParam),
new("DELETE", "tenants/{tenantId}/offboard", EndpointPolicy.TenantAdminOrGA, TenantScoping.RouteParam),
new("POST", "tenants/{tenantId}/offboard/feedback", EndpointPolicy.TenantAdminOrGA, TenantScoping.RouteParam),
// Farewell feedback is submitted AFTER the offboard endpoint has tombstoned the tenant (Disabled=true
// before the 202), so the suspension gate must let the departing admin through — for this route only.
new("POST", "tenants/{tenantId}/offboard/feedback", EndpointPolicy.TenantAdminOrGA, TenantScoping.RouteParam, allowedDuringOffboarding: true),
new("GET", "config/{tenantId}/autopilot-device-validation/consent-url", EndpointPolicy.TenantAdminOrGA, TenantScoping.RouteParam),
new("GET", "config/{tenantId}/autopilot-device-validation/consent-status", EndpointPolicy.TenantAdminOrGA, TenantScoping.RouteParam),
new("POST", "config/{tenantId}/autopilot-device-validation/consent-failure", EndpointPolicy.TenantAdminOrGA, TenantScoping.RouteParam),
@@ -94,18 +94,21 @@ public class EmailService : IEmailService, IOffboardFarewellEmailSender
/// <summary>
/// Sends the post-offboarding "sorry to see you go" farewell email.
/// No-op if the API key or recipient email is not configured. Best-effort: failures
/// are logged as warnings and never propagated (the offboarding correctness contract
/// does not depend on email delivery).
/// No-op (returns false) if the API key or recipient email is not configured. Best-effort:
/// failures are logged as warnings and never propagated (the offboarding correctness
/// contract does not depend on email delivery); the caller turns the result into the
/// FarewellEmailSent / FarewellEmailFailed ops events.
/// </summary>
public async Task SendAsync(string toEmail, string domainName, string tenantId, CancellationToken ct = default)
public async Task<bool> SendAsync(string toEmail, string domainName, string tenantId, CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(_apiKey))
{
_logger.LogDebug(
// Warning, not Debug: an unconfigured provider is operator misconfiguration, and worker
// logs below Warning never reach Application Insights.
_logger.LogWarning(
"{ConfigKey} not configured — skipping offboard farewell email for tenant {TenantId}",
ApiKeyConfigKey, tenantId);
return;
return false;
}
if (string.IsNullOrWhiteSpace(toEmail))
@@ -113,7 +116,7 @@ public class EmailService : IEmailService, IOffboardFarewellEmailSender
_logger.LogDebug(
"No notification email captured — skipping offboard farewell email for tenant {TenantId} ({Domain})",
tenantId, domainName);
return;
return false;
}
var sent = await SendViaMandrillAsync(
@@ -129,6 +132,8 @@ public class EmailService : IEmailService, IOffboardFarewellEmailSender
"Offboard farewell email sent to {ToEmail} for tenant {TenantId} ({Domain})",
toEmail, tenantId, domainName);
}
return sent;
}
/// <inheritdoc />
@@ -7,9 +7,13 @@ namespace AutopilotMonitor.Functions.Services;
/// Invocation point: <c>TenantOffboardingHandler.RunPostDrainPhasesAsync</c> immediately
/// after the History terminal write (Side-effect 6). Always fail-soft — implementations
/// must NOT throw; the offboarding correctness contract does not depend on email delivery.
/// The return value is what separates "sent" from "provider refused / not configured" for the
/// <c>FarewellEmailSent</c> / <c>FarewellEmailFailed</c> ops events the handler records.
/// </para>
/// </summary>
public interface IOffboardFarewellEmailSender
{
Task SendAsync(string toEmail, string domainName, string tenantId, CancellationToken ct = default);
/// <returns>True when the provider accepted the message; false when the send was skipped
/// (provider not configured, empty recipient) or the provider refused it.</returns>
Task<bool> SendAsync(string toEmail, string domainName, string tenantId, CancellationToken ct = default);
}
@@ -774,22 +774,10 @@ namespace AutopilotMonitor.Functions.Services.Offboarding
// Side-effect 6: post-completion farewell email to the captured Preview-Notification-
// Email. Fail-soft: any sender exception MUST NOT propagate, the offboarding
// correctness contract is independent of email delivery and the History row is
// already Completed. Skips silently when no email was captured at Phase 1
// (tenant never set a preview notification address).
if (!string.IsNullOrWhiteSpace(history.NotificationEmail))
{
try
{
await _farewellEmail.SendAsync(
history.NotificationEmail!, history.DomainName ?? string.Empty, tenantId, ct);
}
catch (Exception ex)
{
_logger.LogWarning(ex,
"Offboard farewell email send threw for tenant={Tenant} — ignored (best-effort)",
tenantId);
}
}
// already Completed. Every outcome leaves an ops event (Sent / Skipped / Failed):
// the send's own success log is Information and never reaches Application
// Insights from the worker, which made a lost farewell mail invisible.
await SendFarewellEmailAsync(history, tenantId, ct);
// ── 2.F-final — TenantConfiguration delete (PR3.B Codex Finding 2 reorder) ──
//
@@ -831,6 +819,49 @@ namespace AutopilotMonitor.Functions.Services.Offboarding
// ── Helpers ─────────────────────────────────────────────────────────────
/// <summary>
/// Side-effect 6 — the farewell mail plus its ops-event record. Never throws: the History
/// row is already Completed and the TenantConfiguration delete that follows must run.
/// Outcomes: no address captured at Phase 1 → <c>FarewellEmailSkipped</c> (Warning: the
/// customer left in silence, nothing is broken); provider accepted → <c>FarewellEmailSent</c>
/// (Info); provider refused, provider not configured or the send threw →
/// <c>FarewellEmailFailed</c> (Error: an address was there and the mail did not go out).
/// </summary>
private async Task SendFarewellEmailAsync(OffboardingHistoryEntry history, string tenantId, CancellationToken ct)
{
var domainName = history.DomainName ?? string.Empty;
var toEmail = history.NotificationEmail;
if (string.IsNullOrWhiteSpace(toEmail))
{
await _opsEvents.RecordFarewellEmailSkippedAsync(tenantId, domainName,
"no contact address was set before the offboarding");
return;
}
try
{
var sent = await _farewellEmail.SendAsync(toEmail!, domainName, tenantId, ct);
if (sent)
{
await _opsEvents.RecordFarewellEmailSentAsync(tenantId, domainName, toEmail!);
}
else
{
await _opsEvents.RecordFarewellEmailFailedAsync(tenantId, domainName, toEmail!,
"the email provider did not accept the message (or is not configured)");
}
}
catch (Exception ex)
{
_logger.LogWarning(ex,
"Offboard farewell email send threw for tenant={Tenant} — ignored (best-effort)",
tenantId);
await _opsEvents.RecordFarewellEmailFailedAsync(tenantId, domainName, toEmail!,
$"{ex.GetType().Name}: {ex.Message}");
}
}
/// <summary>
/// PR3.B §3 — Phase 2.D-archive. For each rules table, archive every row to
/// <see cref="ITenantCustomsArchiveRepository"/> THEN delete that exact (PK, RK)
@@ -663,6 +663,47 @@ namespace AutopilotMonitor.Functions.Services
tenantId, "System.Activation", new { domainName, toEmail, reason });
}
// ── Offboarding farewell mail ──
// Same shape as the welcome mail above, for the same reason: the send fails soft and its
// success log is Information, so without these events a lost farewell mail (2026-09-22:
// a tenant WITH a captured address, no trace either way) leaves no record at all.
// Dispatched by TenantOffboardingHandler after History → Completed. All three
// dual-registered in OpsAlertRulesSection.tsx OPS_EVENT_TYPES.
/// <summary>The provider accepted the farewell mail. Info-tier confirmation.</summary>
public Task RecordFarewellEmailSentAsync(string tenantId, string? domainName, string toEmail)
{
var tenantLabel = string.IsNullOrWhiteSpace(domainName) ? tenantId : $"{domainName} ({tenantId})";
return WriteAsync(OpsEventCategory.Tenant, OpsEventTypes.FarewellEmailSent, OpsEventSeverity.Info,
$"Farewell email sent to {toEmail} for offboarded tenant {tenantLabel}",
tenantId, "System.Offboarding", new { domainName, toEmail });
}
/// <summary>
/// The offboarding completed but no farewell mail went out because no contact address
/// was captured before the wipe. Warning-tier: nothing is broken, but a customer left
/// without the "sorry to see you go" and its feedback channels.
/// </summary>
public Task RecordFarewellEmailSkippedAsync(string tenantId, string? domainName, string reason)
{
var tenantLabel = string.IsNullOrWhiteSpace(domainName) ? tenantId : $"{domainName} ({tenantId})";
return WriteAsync(OpsEventCategory.Tenant, OpsEventTypes.FarewellEmailSkipped, OpsEventSeverity.Warning,
$"No farewell email for offboarded tenant {tenantLabel} — {reason}",
tenantId, "System.Offboarding", new { domainName, reason });
}
/// <summary>
/// An address was captured and the mail did not go out: provider refused, provider not
/// configured, or the send threw. Error-tier — this one IS a defect.
/// </summary>
public Task RecordFarewellEmailFailedAsync(string tenantId, string? domainName, string toEmail, string reason)
{
var tenantLabel = string.IsNullOrWhiteSpace(domainName) ? tenantId : $"{domainName} ({tenantId})";
return WriteAsync(OpsEventCategory.Tenant, OpsEventTypes.FarewellEmailFailed, OpsEventSeverity.Error,
$"Farewell email to {toEmail} failed for offboarded tenant {tenantLabel} — {reason}",
tenantId, "System.Offboarding", new { domainName, toEmail, reason });
}
// ── Tenant trial lifecycle (informational — enforcement is read-time) ──
// Both types are dual-registered in OpsAlertRulesSection.tsx OPS_EVENT_TYPES
// (memory feedback_ops_event_types_dual_register). Dispatched by TrialExpirySweepFunction.
@@ -111,6 +111,9 @@ export const OPS_EVENT_TYPES = [
"WelcomeEmailSent",
"WelcomeEmailSkipped",
"WelcomeEmailFailed",
"FarewellEmailSent",
"FarewellEmailSkipped",
"FarewellEmailFailed",
"TenantTrialStarted",
"TenantTrialExpiring",
"TenantTrialExpired",
@@ -94,6 +94,16 @@ namespace AutopilotMonitor.Shared.DataAccess
public const string WelcomeEmailSent = "WelcomeEmailSent";
public const string WelcomeEmailSkipped = "WelcomeEmailSkipped";
public const string WelcomeEmailFailed = "WelcomeEmailFailed";
/// <summary>
/// Post-offboarding farewell mail, recorded by the offboarding worker after the History
/// row went Completed. Sent (Info) = the provider accepted it; Skipped (Warning) = no
/// contact address was captured before the wipe, the customer left in silence; Failed
/// (Error) = an address was there and the mail did not go out. The only record of the
/// send: its success log is Information and never reaches Application Insights.
/// </summary>
public const string FarewellEmailSent = "FarewellEmailSent";
public const string FarewellEmailSkipped = "FarewellEmailSkipped";
public const string FarewellEmailFailed = "FarewellEmailFailed";
public const string TenantTrialStarted = "TenantTrialStarted";
public const string TenantTrialExpiring = "TenantTrialExpiring";
public const string TenantTrialExpired = "TenantTrialExpired";
@@ -139,7 +149,7 @@ namespace AutopilotMonitor.Shared.DataAccess
ConsentFlowStarted, ConsentFlowSuccess, ConsentFlowFailed, ConsentRedirectUriMismatch, AppHomingFlipped, AppHomingFlippedWithEntraRoles,
MaintenanceCompleted, MaintenanceFailed, MaintenanceLongRunning, MaintenanceStarted, MaintenanceSkippedLocked, SessionSweepCompleted, SessionSweepFailed, OpsEventCleanup, OrphanEventsCleaned, SessionDeletionMaintenanceStarted, SessionDeletionMaintenanceBudgetExceeded, SessionDeletionMaintenanceSkippedLocked, SessionDeletionMaintenanceLongRunning, SessionDeletionMaintenanceLongRunningSevere, SessionDeletionMaintenanceFailed, SessionDeletionStrandedQueued, SessionDeletionPoisoned, SessionDeletionMaintenanceCompleted, SessionDeletionMaintenanceFanoutSkipped, CriticalTableBackupCompleted, CriticalTableBackupPartial, CriticalTableBackupFailed, CriticalTableBackupSkippedLocked, BackupRowRestored, VerdictCalibrationDrift,
DeviceBlocked, VersionBlocked, SessionTenantConflict, SessionOwnerMismatch, KillSignalDelivered, EmbeddedCertExpiringSoon, EmbeddedCertExpiringUrgent, EmbeddedCertExpired, EmbeddedCertBundleEmpty, SignalRConnectionsHigh, SignalRConnectionsCritical, SignalRMessagesHigh, SignalRMessagesCritical, PoisonQueueBacklogHigh, PoisonQueueBacklogCritical, ExcessiveSessionEventsAutoActioned, PrivilegedRouteDenied, McpServicePrincipalFirstSeen,
OffboardingFeedbackReceived, TenantOffboarded, TenantOffboardingFailed, TenantAutoApproved, WelcomeEmailSent, WelcomeEmailSkipped, WelcomeEmailFailed, TenantTrialStarted, TenantTrialExpiring, TenantTrialExpired, TenantPlanDowngraded, TenantRetentionGraceExpiring, TenantRetentionGraceEnded, RuleFrequencyRegression, AppVersionDurationRegression, CollectLogsQuickConfigEnabled, DiagnosticsUploadEnabled, DiagnosticsUploadDisabled,
OffboardingFeedbackReceived, TenantOffboarded, TenantOffboardingFailed, TenantAutoApproved, WelcomeEmailSent, WelcomeEmailSkipped, WelcomeEmailFailed, FarewellEmailSent, FarewellEmailSkipped, FarewellEmailFailed, TenantTrialStarted, TenantTrialExpiring, TenantTrialExpired, TenantPlanDowngraded, TenantRetentionGraceExpiring, TenantRetentionGraceEnded, RuleFrequencyRegression, AppVersionDurationRegression, CollectLogsQuickConfigEnabled, DiagnosticsUploadEnabled, DiagnosticsUploadDisabled,
SessionActionQueued, SessionTimeouts, AgentEmergencyBreak, AgentBinaryIntegrityMismatch, CmTraceTimeSkewRegression, ExcessiveSessionEvents, NewImeVersionDetected, ImePatternDriftSuspected, BlobStorageMissing, BlobStorageUnreachable, TelemetryItemsRejected,
SlaBreachNotification, SlaConsecutiveFailures, SlaEvaluationCompleted,
AzureMonitorAlert,
@@ -186,6 +186,13 @@ const OPS_EVENT_TYPES: Record<string, string[]> = {
"WelcomeEmailSent",
"WelcomeEmailSkipped",
"WelcomeEmailFailed",
// Offboarding farewell mail, recorded by the offboarding worker after the wipe completed.
// Same three-way record as the welcome mail: Skipped (Warning) = no contact address was
// captured before the wipe, Failed (Error) = the provider refused an address we had,
// Sent (Info) = confirmation. Dual-register per memory feedback_ops_event_types_dual_register.
"FarewellEmailSent",
"FarewellEmailSkipped",
"FarewellEmailFailed",
// A tenant admin turned on-demand diagnostics upload (the Collect Logs capability) on or
// off — portal Settings, the Collect Logs quick-config dialog, MCP update_tenant_config,
// or a config revert. Fires on the flip only. Backend
@@ -1,13 +1,10 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { api } from "@/lib/api";
import { TokenExpiredError } from "@/lib/authenticatedFetch";
import { apiErrorText, fetchOk, jsonBody } from "@/lib/apiClient";
import { SectionCardHeader } from "@/components/SectionCardHeader";
import { DOCS_PATHS } from "@/lib/docsPaths";
import type { SubmitOffboardingFeedbackRequest } from "@/utils/wire-types.generated";
import { ModalPortal } from "@/components/ModalPortal";
import OffboardingFeedbackForm from "@/components/OffboardingFeedbackForm";
interface OffboardingInProgressInfo {
status: string; // "Queued" | "Initiated" | "InProgress" | "Completed" | "Failed"
@@ -39,13 +36,6 @@ interface OffboardingSectionProps {
getAccessToken: () => Promise<string | null>;
}
/** localStorage key prefix for "this user already submitted feedback for historyRowKey X".
* Tied to historyRowKey rather than tenantId so a tenant that offboards twice
* (re-onboard → offboard) gets a fresh prompt each time. */
const FEEDBACK_SUBMITTED_KEY_PREFIX = "offboard-feedback-submitted:";
const FEEDBACK_MAX_CHARS = 4096;
function formatRemaining(totalSeconds: number): string {
if (totalSeconds <= 0) return "0 min";
return `~${Math.ceil(totalSeconds / 60)} min`;
@@ -251,50 +241,6 @@ function OffboardingInProgressBanner({
const secondsLeft = useCountdown(info.earliestProcessingAt ?? null, onDrainBarrierElapsed);
const inDrainBarrier = (info.earliestProcessingAt && secondsLeft > 0) === true;
// Farewell-feedback widget state. Each historyRowKey gets its own localStorage flag so a
// refresh during the countdown remembers the submit. Re-offboarding later (new historyRowKey)
// shows a fresh prompt — exactly what we want for repeat-offboard scenarios.
const localStorageKey = `${FEEDBACK_SUBMITTED_KEY_PREFIX}${info.historyRowKey}`;
const [feedbackText, setFeedbackText] = useState("");
const [submitting, setSubmitting] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
const [alreadySubmitted, setAlreadySubmitted] = useState<boolean>(() => {
if (typeof window === "undefined") return false;
try {
return window.localStorage.getItem(localStorageKey) === "1";
} catch {
return false;
}
});
const handleSubmitFeedback = async () => {
const trimmed = feedbackText.trim();
if (trimmed.length === 0 || !tenantId) return;
setSubmitting(true);
setSubmitError(null);
try {
await fetchOk(api.tenants.offboardFeedback(tenantId), getAccessToken, {
method: "POST",
body: jsonBody<SubmitOffboardingFeedbackRequest>({ comment: trimmed.slice(0, FEEDBACK_MAX_CHARS) }),
});
try {
window.localStorage.setItem(localStorageKey, "1");
} catch {
// localStorage disabled — UI still flips via state below; the round trip already succeeded.
}
setAlreadySubmitted(true);
} catch (err) {
if (err instanceof TokenExpiredError) {
setSubmitError("Your session has expired. The offboarding will continue in the background.");
} else {
setSubmitError(apiErrorText(err, "Failed to submit feedback"));
}
} finally {
setSubmitting(false);
}
};
// Single 4-state visual; status text drives the badge color + label.
// "Preparing" is derived from earliestProcessingAt + countdown rather than from status,
// because the backend status stays "Queued" during the cache-drain window.
@@ -364,50 +310,14 @@ function OffboardingInProgressBanner({
{/* Farewell-feedback widget — only meaningful while the drain barrier is running.
Hidden once we transition to actual deletion / completion / failure: at that point
the user can no longer interact, and an admin re-clicking later would see the
banner in a non-feedback state. */}
banner in a non-feedback state. A reload during the barrier lands on the suspended
page (ProtectedRoute), which renders the same form. */}
{inDrainBarrier && tenantId && (
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4 space-y-3">
<div>
<h3 className="text-sm font-semibold text-gray-900">We&apos;re sorry to see you go</h3>
<p className="text-xs text-gray-600 mt-0.5">
What could we have done better? Anything specific that didn&apos;t fit your needs?
(optional, max {FEEDBACK_MAX_CHARS} characters)
</p>
</div>
{alreadySubmitted ? (
<div className="flex items-center gap-2 text-sm text-green-700 bg-green-50 border border-green-200 rounded-md px-3 py-2">
<svg className="w-4 h-4 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" clipRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" />
</svg>
<span>Thanks for the feedback — it really helps shape what we build next.</span>
</div>
) : (
<>
<textarea
value={feedbackText}
onChange={(e) => setFeedbackText(e.target.value.slice(0, FEEDBACK_MAX_CHARS))}
disabled={submitting}
rows={4}
placeholder="Pricing, missing features, bugs that bit us, alternatives we picked, …"
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 text-sm disabled:bg-gray-100"
/>
<div className="flex items-center justify-between text-xs text-gray-500">
<span>{feedbackText.length}/{FEEDBACK_MAX_CHARS}</span>
<button
onClick={handleSubmitFeedback}
disabled={submitting || feedbackText.trim().length === 0}
className="px-3 py-1.5 bg-green-600 text-white rounded-md hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors text-sm font-medium"
>
{submitting ? "Submitting…" : "Submit feedback"}
</button>
</div>
{submitError && (
<p className="text-xs text-red-600">{submitError}</p>
)}
</>
)}
</div>
<OffboardingFeedbackForm
tenantId={tenantId}
offboardingKey={info.historyRowKey || tenantId}
getAccessToken={getAccessToken}
/>
)}
<div className="text-xs text-gray-500 space-y-0.5">
@@ -0,0 +1,117 @@
"use client";
import { useState } from "react";
import { api } from "@/lib/api";
import { apiErrorText, fetchOk, jsonBody } from "@/lib/apiClient";
import type { SubmitOffboardingFeedbackRequest } from "@/utils/wire-types.generated";
/** localStorage key prefix for "this user already submitted feedback for offboarding X". */
const FEEDBACK_SUBMITTED_KEY_PREFIX = "offboard-feedback-submitted:";
export const OFFBOARDING_FEEDBACK_MAX_CHARS = 4096;
interface OffboardingFeedbackFormProps {
/** Tenant being offboarded — the feedback POST is scoped to it. */
tenantId: string;
/**
* Identity of THIS offboarding for the "already submitted" memory: the History row key when
* the caller has it (the banner right after the DELETE), else the tenant id (the suspended
* page after a reload, which only knows the tenant). Keyed per offboarding so a tenant that
* re-onboards and offboards again gets a fresh prompt.
*/
offboardingKey: string;
getAccessToken: () => Promise<string | null>;
}
/**
* The farewell feedback textarea a departing tenant admin sees while the offboarding is queued.
* Rendered twice: inside the drain-barrier banner right after the offboard click, and on the
* suspended page a reload lands on during the same window — the offboard endpoint tombstones
* the tenant before it answers, so any reload in those ~6 minutes would otherwise lose the form.
* The backend accepts the POST only while the offboarding marker is Initiated/InProgress and
* answers 404/409 afterwards; that error text is shown as-is.
*/
export default function OffboardingFeedbackForm({ tenantId, offboardingKey, getAccessToken }: OffboardingFeedbackFormProps) {
const localStorageKey = `${FEEDBACK_SUBMITTED_KEY_PREFIX}${offboardingKey}`;
const [feedbackText, setFeedbackText] = useState("");
const [submitting, setSubmitting] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
const [alreadySubmitted, setAlreadySubmitted] = useState<boolean>(() => {
if (typeof window === "undefined") return false;
try {
return window.localStorage.getItem(localStorageKey) === "1";
} catch {
return false;
}
});
const handleSubmitFeedback = async () => {
const trimmed = feedbackText.trim();
if (trimmed.length === 0) return;
setSubmitting(true);
setSubmitError(null);
try {
await fetchOk(api.tenants.offboardFeedback(tenantId), getAccessToken, {
method: "POST",
body: jsonBody<SubmitOffboardingFeedbackRequest>({ comment: trimmed.slice(0, OFFBOARDING_FEEDBACK_MAX_CHARS) }),
});
try {
window.localStorage.setItem(localStorageKey, "1");
} catch {
// localStorage disabled — UI still flips via state below; the round trip already succeeded.
}
setAlreadySubmitted(true);
} catch (err) {
// apiErrorText renders a token expiry centrally; the offboarding continues either way.
setSubmitError(apiErrorText(err, "Failed to submit feedback"));
} finally {
setSubmitting(false);
}
};
return (
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4 space-y-3 text-left">
<div>
<h3 className="text-sm font-semibold text-gray-900">We&apos;re sorry to see you go</h3>
<p className="text-xs text-gray-600 mt-0.5">
What could we have done better? Anything specific that didn&apos;t fit your needs?
(optional, max {OFFBOARDING_FEEDBACK_MAX_CHARS} characters)
</p>
</div>
{alreadySubmitted ? (
<div className="flex items-center gap-2 text-sm text-green-700 bg-green-50 border border-green-200 rounded-md px-3 py-2">
<svg className="w-4 h-4 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" clipRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" />
</svg>
<span>Thanks for the feedback — it really helps shape what we build next.</span>
</div>
) : (
<>
<textarea
value={feedbackText}
onChange={(e) => setFeedbackText(e.target.value.slice(0, OFFBOARDING_FEEDBACK_MAX_CHARS))}
disabled={submitting}
rows={4}
placeholder="Pricing, missing features, bugs that bit us, alternatives we picked, …"
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 text-sm disabled:bg-gray-100"
/>
<div className="flex items-center justify-between text-xs text-gray-500">
<span>{feedbackText.length}/{OFFBOARDING_FEEDBACK_MAX_CHARS}</span>
<button
onClick={handleSubmitFeedback}
disabled={submitting || feedbackText.trim().length === 0}
className="px-3 py-1.5 bg-green-600 text-white rounded-md hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors text-sm font-medium"
>
{submitting ? "Submitting…" : "Submit feedback"}
</button>
</div>
{submitError && (
<p className="text-xs text-red-600">{submitError}</p>
)}
</>
)}
</div>
);
}
@@ -10,6 +10,7 @@ import { activeAuthApp } from "../lib/msalConfig";
import { DOCS_PATHS } from "../lib/docsPaths";
import { DOCS_URL } from "../utils/config";
import { useAdminMode } from "../hooks/useAdminMode";
import OffboardingFeedbackForm from "./OffboardingFeedbackForm";
interface ProtectedRouteProps {
children: React.ReactNode;
@@ -30,12 +31,19 @@ interface ProtectedRouteProps {
requireFleetScope?: boolean;
}
/**
* The backend's DisabledReason on the offboarding tombstone (TenantOffboardFunction.OffboardingDisabledReason).
* A suspension carrying exactly this text is a tenant mid-offboarding, not an operator suspension.
*/
const OFFBOARDING_SUSPENSION_MESSAGE = "Offboarding in progress";
/**
* Protects routes by requiring authentication. Optionally requires Global Admin, platform scope
* (Global Admin or read-only Global Reader), or fleet scope (platform scope OR a delegated MSP admin).
* A suspended tenant (auth/me 403 TenantSuspended) gets the suspended page instead of the children.
*/
export function ProtectedRoute({ children, requireGlobalAdmin = false, requireGlobalScope = false, requireFleetScope = false }: ProtectedRouteProps) {
const { isAuthenticated, user, hasGlobalScope, hasFleetScope, isLoading, login } = useAuth();
const { isAuthenticated, user, hasGlobalScope, hasFleetScope, isLoading, login, logout, getAccessToken, isTenantSuspended, suspensionMessage } = useAuth();
const router = useRouter();
// Once authenticated, remember it so transient auth-state flips (e.g. MSAL
@@ -175,6 +183,57 @@ export function ProtectedRoute({ children, requireGlobalAdmin = false, requireGl
return null;
}
// Suspended tenant (auth/me 403 TenantSuspended): the portal is closed for this user, so none
// of the page's own API calls may start. An offboarding tombstone gets the farewell page with
// the feedback form — the offboard endpoint tombstones the tenant before it even answers, so
// a reload during the ~6-minute preparation window lands here instead of on the banner.
if (isTenantSuspended && user) {
const offboarding = suspensionMessage === OFFBOARDING_SUSPENSION_MESSAGE;
return (
<div className="min-h-screen bg-[var(--lp-bg)] flex items-center justify-center p-4">
<div className="bg-white rounded-lg shadow-xl p-8 max-w-md w-full text-center space-y-4">
<svg className="h-12 w-12 text-amber-500 mx-auto" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
{offboarding ? (
<>
<h2 className="text-xl font-semibold text-gray-900">Your organization is being offboarded</h2>
<p className="text-gray-600">
All Autopilot Monitor data for your tenant is being permanently deleted, including this
account&apos;s access. Nothing else is needed from you.
</p>
{user.tenantId && (
<OffboardingFeedbackForm
tenantId={user.tenantId}
offboardingKey={user.tenantId}
getAccessToken={getAccessToken}
/>
)}
</>
) : (
<>
<h2 className="text-xl font-semibold text-gray-900">Access suspended</h2>
<p className="text-gray-600">{suspensionMessage || "Your tenant has been suspended."}</p>
<p className="text-sm text-gray-500">
If you believe this is a mistake, contact me on{" "}
<a href="https://www.linkedin.com/in/oliver-kieselbach/" target="_blank" rel="noopener noreferrer" className="text-green-700 underline hover:text-green-800">LinkedIn</a>
{" "}or open a{" "}
<a href="https://github.com/okieselbach/AutopilotMonitor/issues" target="_blank" rel="noopener noreferrer" className="text-green-700 underline hover:text-green-800">GitHub issue</a>.
</p>
</>
)}
<p className="text-xs text-gray-400">Signed in as {user.upn}</p>
<button
onClick={() => { logout().catch(() => { /* MSAL already logged the error */ }); }}
className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors"
>
Sign out
</button>
</div>
</div>
);
}
// Show nothing if the route's platform requirement isn't met.
// requireGlobalAdmin → real Global Admin only; requireGlobalScope → Global Admin OR Global Reader.
const platformDenied =
@@ -201,6 +201,14 @@ interface AuthContextType {
isLoading: boolean;
isActivationPending: boolean;
activationMessage: string;
/**
* auth/me answered 403 TenantSuspended: the caller's tenant is suspended by an operator or is
* the offboarding tombstone (mid-wipe). The user stays signed in so ProtectedRoute can show the
* suspended page (and, for an offboarding, the farewell feedback form) instead of the portal.
*/
isTenantSuspended: boolean;
/** The backend's DisabledReason text (e.g. "Offboarding in progress"); empty when not suspended. */
suspensionMessage: string;
login: (options?: { auto?: boolean }) => Promise<void>;
logout: () => Promise<void>;
getAccessToken: (forceRefresh?: boolean) => Promise<string | null>;
@@ -269,6 +277,30 @@ function toUserInfo(data: AuthMeResponse, account: AccountInfo): UserInfo {
};
}
/**
* Auth state from the token claims alone, with every entitlement off. Used when auth/me could not
* confirm anything: the tenant is pending activation or suspended (the user stays signed in and
* sees the matching page), or the call failed for a non-auth reason.
*/
function claimsOnlyUserInfo(account: AccountInfo): UserInfo {
return {
displayName: account.name || '',
upn: account.username || '',
tenantId: account.tenantId || '',
objectId: account.homeAccountId || '',
isGlobalAdmin: false,
isGlobalReader: false,
isTenantAdmin: false,
isDelegated: false,
delegatedTenantIds: [],
role: null,
canManageBootstrapTokens: false,
hasMcpAccess: false,
bootstrapTokenEnabled: false,
unrestrictedModeEnabled: false,
};
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
/**
@@ -283,6 +315,8 @@ function AuthProviderInternal({ children }: { children: React.ReactNode }) {
const isLoadingRef = useRef(true);
const [isActivationPending, setActivationPending] = useState(false);
const [activationMessage, setActivationMessage] = useState('');
const [isTenantSuspended, setTenantSuspended] = useState(false);
const [suspensionMessage, setSuspensionMessage] = useState('');
/**
* Fetches user info from backend API
@@ -328,11 +362,15 @@ function AuthProviderInternal({ children }: { children: React.ReactNode }) {
if (!response.ok) {
if (response.status === 403) {
const errorData = await response.json();
// Suspended (operator kill-switch or the offboarding tombstone): keep the user signed
// in with claim-only info so ProtectedRoute renders the suspended page — an admin who
// just offboarded their tenant reloads into the farewell feedback form, not into a
// browser alert and a forced logout.
if (errorData.error === 'TenantSuspended') {
console.error('[Auth] Tenant suspended:', errorData.message);
alert(`Access Denied\n\n${errorData.message}`);
await instance.logoutRedirect({ account });
return null;
console.warn('[Auth] Tenant suspended:', errorData.message);
setTenantSuspended(true);
setSuspensionMessage(errorData.message || 'Your tenant has been suspended.');
return claimsOnlyUserInfo(account);
}
// 'PendingActivation' is the current backend code; 'PrivatePreview' is the legacy
// code kept accepted so web and backend can deploy in any order.
@@ -341,22 +379,7 @@ function AuthProviderInternal({ children }: { children: React.ReactNode }) {
setActivationPending(true);
setActivationMessage(errorData.message || 'Your organization is being activated.');
// Return basic user info so the user stays logged in but sees the activation page
return {
displayName: account.name || '',
upn: account.username || '',
tenantId: account.tenantId || '',
objectId: account.homeAccountId || '',
isGlobalAdmin: false,
isGlobalReader: false,
isTenantAdmin: false,
isDelegated: false,
delegatedTenantIds: [],
role: null,
canManageBootstrapTokens: false,
hasMcpAccess: false,
bootstrapTokenEnabled: false,
unrestrictedModeEnabled: false,
};
return claimsOnlyUserInfo(account);
}
}
throw new Error(`Failed to fetch user info: ${response.statusText}`);
@@ -364,10 +387,12 @@ function AuthProviderInternal({ children }: { children: React.ReactNode }) {
const data = (await response.json()) as AuthMeResponse;
// A successful auth/me means the tenant is (now) activated — clear any pending
// state so the activation page's poll can redirect into the portal.
// A successful auth/me means the tenant is (now) activated and not suspended — clear any
// pending state so the activation page's poll can redirect into the portal.
setActivationPending(false);
setActivationMessage('');
setTenantSuspended(false);
setSuspensionMessage('');
learnHomedAppFromAuthMe(data);
@@ -391,22 +416,7 @@ function AuthProviderInternal({ children }: { children: React.ReactNode }) {
// Fallback to token claims only for non-auth errors (network issues,
// backend cold starts, etc.) so the user can still see the app.
return {
displayName: account.name || '',
upn: account.username || '',
tenantId: account.tenantId || '',
objectId: account.homeAccountId || '',
isGlobalAdmin: false,
isGlobalReader: false,
isTenantAdmin: false,
isDelegated: false,
delegatedTenantIds: [],
role: null,
canManageBootstrapTokens: false,
hasMcpAccess: false,
bootstrapTokenEnabled: false,
unrestrictedModeEnabled: false,
};
return claimsOnlyUserInfo(account);
}
}, [instance]);
@@ -555,11 +565,13 @@ function AuthProviderInternal({ children }: { children: React.ReactNode }) {
isLoading,
isActivationPending,
activationMessage,
isTenantSuspended,
suspensionMessage,
login,
logout,
getAccessToken,
refreshUserInfo,
}), [isAuthenticated, user, isLoading, isActivationPending, activationMessage, login, logout, getAccessToken, refreshUserInfo]);
}), [isAuthenticated, user, isLoading, isActivationPending, activationMessage, isTenantSuspended, suspensionMessage, login, logout, getAccessToken, refreshUserInfo]);
return (
<AuthContext.Provider value={value}>
@@ -15,7 +15,6 @@ export const PERMANENT: Record<string, string> = {
"app/dashboard/hooks/useDeleteSession.ts": "bulk runner collects the expiry and toasts once after the batch",
"app/dashboard/hooks/useTenantSecurityConfig.ts": "fail-soft banner data; the expiry is the one failure the user must hear about",
"app/sessions/hooks/useSessionDetail.ts": "a network failure keeps the retry flag, an expiry must not",
"app/settings/components/OffboardingSection.tsx": "expiry message tells the user the offboarding continues in the background",
// Rule documents: the pages edit a LOCAL rule shape (app/*/types.ts) whose nested types diverge from the
// wire (RuleCondition.itemField optional vs required, suppressByEvent inline vs SuppressByEventConfig,
// GatherRule.triggerPhase nullable vs string) and the PUT accepts toggle partials the service merges.
@@ -56,5 +55,4 @@ export const TOKENEXPIRED_BASELINE: Record<string, number> = {
"app/dashboard/hooks/useTenantSecurityConfig.ts": 1,
"app/sessions/hooks/useSessionDetail.ts": 1,
"app/settings/TenantConfigContext.tsx": 2,
"app/settings/components/OffboardingSection.tsx": 1,
};
@@ -577,6 +577,9 @@ export const SHARED_MANIFEST = {
"WelcomeEmailSent",
"WelcomeEmailSkipped",
"WelcomeEmailFailed",
"FarewellEmailSent",
"FarewellEmailSkipped",
"FarewellEmailFailed",
"TenantTrialStarted",
"TenantTrialExpiring",
"TenantTrialExpired",
@@ -573,6 +573,9 @@
"WelcomeEmailSent",
"WelcomeEmailSkipped",
"WelcomeEmailFailed",
"FarewellEmailSent",
"FarewellEmailSkipped",
"FarewellEmailFailed",
"TenantTrialStarted",
"TenantTrialExpiring",
"TenantTrialExpired",