mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 04:32:32 +00:00
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:
+1
-1
Submodule .claude/commands updated: c1614df047...41038ca4aa
+1
-1
Submodule internal updated: c46d728015...b05e43328d
@@ -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);
|
||||
}
|
||||
|
||||
+16
@@ -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.
|
||||
|
||||
+6
-4
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+80
-4
@@ -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);
|
||||
}
|
||||
|
||||
+47
-16
@@ -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'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'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'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'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'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",
|
||||
|
||||
Reference in New Issue
Block a user