From 43c6eb535473f2eff0dcad28a31900d1c58e6a74 Mon Sep 17 00:00:00 2001 From: frodehus Date: Thu, 24 Sep 2026 07:02:07 +0200 Subject: [PATCH 1/6] Core and CLI: drop activations whose end has passed Port of the macOS fix in #201. ActiveAssignment.HasLapsed in the C# Core; ElevateSession.DropLapsedAssignments, called by 'elevate watch' before every redraw, so a failed re-read no longer leaves ended activations in the table. --- CHANGELOG.md | 7 +++ cli/src/Elevate.Cli/Commands/RoleCommands.cs | 3 ++ .../Session/ElevateSession.Refresh.cs | 19 ++++++++ cli/tests/Elevate.Cli.Tests/SessionTests.cs | 17 ++++++++ windows/src/Elevate.Core/Models/Roles.cs | 10 +++++ .../LapsedAssignmentTests.cs | 43 +++++++++++++++++++ 6 files changed, 99 insertions(+) create mode 100644 windows/tests/Elevate.Core.Tests/LapsedAssignmentTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 449ed2b9..125cf3b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- CLI: **`elevate watch` no longer keeps listing activations that have ended.** When a re-read + failed — a sign-in it could not renew silently, a dropped network — the table kept the last rows + it had, including activations whose end time had since passed. `watch` now drops a row as soon as + its end has passed, whether or not the last read got through. + ## [1.8.0] - 2026-09-19 ### Added diff --git a/cli/src/Elevate.Cli/Commands/RoleCommands.cs b/cli/src/Elevate.Cli/Commands/RoleCommands.cs index 5e8a22ee..4ea37910 100644 --- a/cli/src/Elevate.Cli/Commands/RoleCommands.cs +++ b/cli/src/Elevate.Cli/Commands/RoleCommands.cs @@ -151,6 +151,9 @@ await console.Live(Render(session, lastRead)).AutoClear(false).StartAsync(async lastRead = DateTimeOffset.UtcNow; } + // A read that failed (a sign-in it could not renew, a dropped network) + // leaves the last rows in place; one whose end has passed is over regardless. + session.DropLapsedAssignments(DateTimeOffset.UtcNow); live.UpdateTarget(Render(session, lastRead)); } } diff --git a/cli/src/Elevate.Cli/Session/ElevateSession.Refresh.cs b/cli/src/Elevate.Cli/Session/ElevateSession.Refresh.cs index 6f9ba1c3..f14e6496 100644 --- a/cli/src/Elevate.Cli/Session/ElevateSession.Refresh.cs +++ b/cli/src/Elevate.Cli/Session/ElevateSession.Refresh.cs @@ -18,6 +18,25 @@ public async Task RefreshAsync(IEnumerable keys, CancellationToken ct public Task RefreshAllAsync(CancellationToken ct = default) => RefreshAsync(State.Tenants.Select(t => t.Key).ToList(), ct); + /// + /// Drops every row whose activation window has ended by . A refresh that + /// cannot read a tenant keeps its known rows, so a long-running view (watch) would + /// otherwise go on listing activations that are already over. Returns whether anything went. + /// + public bool DropLapsedAssignments(DateTimeOffset now) + { + var dropped = false; + Mutate(() => + { + foreach (var key in Active.Where(p => p.Value.HasLapsed(now)).Select(p => p.Key).ToList()) + { + Active.Remove(key); + dropped = true; + } + }); + return dropped; + } + public async Task RefreshTenantAsync(TenantKey key, CancellationToken ct = default, IReadOnlySet? requestedKinds = null) { if (Identity(key.IdentityId) is not { } identity || Tenant(key) is not { } tenant) diff --git a/cli/tests/Elevate.Cli.Tests/SessionTests.cs b/cli/tests/Elevate.Cli.Tests/SessionTests.cs index 0664500c..191a279e 100644 --- a/cli/tests/Elevate.Cli.Tests/SessionTests.cs +++ b/cli/tests/Elevate.Cli.Tests/SessionTests.cs @@ -26,6 +26,23 @@ public async Task RefreshLoadsRolesAndAssignments() t.Session.TenantErrors.Should().BeEmpty(); } + [Fact] + public async Task DropLapsedAssignmentsRemovesOnlyRowsWhoseEndHasPassed() + { + using var t = new TestSession(); + var now = DateTimeOffset.UtcNow; + t.Entra.Assignments.Add(new ActiveAssignment(TestSession.EntraKey("r1"), "a", now, now.AddHours(1), AssignmentStatus.Active)); + t.Entra.Assignments.Add(new ActiveAssignment(TestSession.EntraKey("r2"), "b", now, now.AddHours(3), AssignmentStatus.Active)); + t.Entra.Assignments.Add(new ActiveAssignment(TestSession.EntraKey("r3"), "p", now, null, AssignmentStatus.PendingApproval)); + await t.Session.RefreshAllAsync(); + + // Two hours on, with no read since (the one a sleep or a lost sign-in would have skipped). + t.Session.DropLapsedAssignments(now.AddHours(2)).Should().BeTrue(); + + t.Session.Active.Keys.Should().BeEquivalentTo([TestSession.EntraKey("r2"), TestSession.EntraKey("r3")]); + t.Session.DropLapsedAssignments(now.AddHours(2)).Should().BeFalse("nothing is left to drop"); + } + [Fact] public async Task ConsentRefusalSwitchesTenantToManualRoles() { diff --git a/windows/src/Elevate.Core/Models/Roles.cs b/windows/src/Elevate.Core/Models/Roles.cs index 97bbfb3d..e54ab7ba 100644 --- a/windows/src/Elevate.Core/Models/Roles.cs +++ b/windows/src/Elevate.Core/Models/Roles.cs @@ -89,6 +89,16 @@ public ActiveAssignment( : this(roleKey, assignmentId, startDateTime, endDateTime, status, scheduleId, activationRequestId) { } + + /// + /// Whether this activation's window is over by , whatever the last read + /// said. A refresh that cannot reach a tenant keeps its known rows; this is what lets a caller + /// drop the ones that have ended meanwhile. Requests and failures have no window of their own + /// and never lapse. + /// + public bool HasLapsed(DateTimeOffset now) => + Status.Kind is AssignmentStatusKind.Active or AssignmentStatusKind.Scheduled + && EndDateTime is { } end && end <= now; } public sealed record TicketInfo(string Number, string System); diff --git a/windows/tests/Elevate.Core.Tests/LapsedAssignmentTests.cs b/windows/tests/Elevate.Core.Tests/LapsedAssignmentTests.cs new file mode 100644 index 00000000..753ff028 --- /dev/null +++ b/windows/tests/Elevate.Core.Tests/LapsedAssignmentTests.cs @@ -0,0 +1,43 @@ +using Elevate.Core.Models; +using FluentAssertions; + +namespace Elevate.Core.Tests; + +public class LapsedAssignmentTests +{ + private static readonly DateTimeOffset Now = DateTimeOffset.FromUnixTimeSeconds(1_000_000); + + private static ActiveAssignment Assignment(AssignmentStatus status, double? endsInSeconds) => + new(new RoleKey("i", "t", new EntraDirectoryScope("r", "/")), "a", Now.AddHours(-2), + endsInSeconds is { } s ? Now.AddSeconds(s) : null, status); + + [Fact] + public void ActiveLapsesOnceItsEndHasPassed() + { + Assignment(AssignmentStatus.Active, -1).HasLapsed(Now).Should().BeTrue(); + Assignment(AssignmentStatus.Active, 0).HasLapsed(Now).Should().BeTrue(); + Assignment(AssignmentStatus.Active, 1).HasLapsed(Now).Should().BeFalse(); + } + + [Fact] + public void ScheduledLapsesOnceItsEndHasPassed() + { + Assignment(AssignmentStatus.Scheduled, -60).HasLapsed(Now).Should().BeTrue(); + Assignment(AssignmentStatus.Scheduled, 60).HasLapsed(Now).Should().BeFalse(); + } + + [Fact] + public void WithoutAnEndNothingLapses() + { + Assignment(AssignmentStatus.Active, null).HasLapsed(Now).Should().BeFalse(); + } + + /// Requests and failures carry no activation window of their own; the service settles them. + [Fact] + public void RequestsAndFailuresNeverLapse() + { + Assignment(AssignmentStatus.PendingApproval, -60).HasLapsed(Now).Should().BeFalse(); + Assignment(AssignmentStatus.PendingProvisioning, -60).HasLapsed(Now).Should().BeFalse(); + Assignment(AssignmentStatus.Failed("x"), -60).HasLapsed(Now).Should().BeFalse(); + } +} From 46449ba386bb0c6618108950170da5eee690116a Mon Sep 17 00:00:00 2001 From: frodehus Date: Thu, 24 Sep 2026 07:09:47 +0200 Subject: [PATCH 2/6] Windows: drop activations whose end has passed on the clock tick Port of the macOS fix in #201, using ActiveAssignment.HasLapsed from #203. A refresh that cannot read a tenant keeps its known rows; the 30 s clock tick now drops rows a minute past their end, stops their propagation watches and reschedules the toasts. --- CHANGELOG.md | 5 ++ .../Elevate.App.Model/ViewModels/AppModel.cs | 31 ++++++++++++ .../AppModelLapsedAssignmentTests.cs | 49 +++++++++++++++++++ 3 files changed, 85 insertions(+) create mode 100644 windows/tests/Elevate.App.Tests/AppModelLapsedAssignmentTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index c57c7481..1f10b8ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 also marked with an orange warning on its header instead of the grey info glyph that plain limitations use; press Refresh to sign in. +- Windows: **expired activations no longer linger after the PC wakes.** As on macOS, a background + refresh that could not renew a sign-in silently kept the tenant's last-known rows, including + activations that had ended meanwhile. Elevate now drops an activation a minute after its end + time, whether or not the tenant could be read. + - CLI: **`elevate watch` no longer keeps listing activations that have ended.** When a re-read failed — a sign-in it could not renew silently, a dropped network — the table kept the last rows it had, including activations whose end time had since passed. `watch` now drops a row as soon as diff --git a/windows/src/Elevate.App.Model/ViewModels/AppModel.cs b/windows/src/Elevate.App.Model/ViewModels/AppModel.cs index 05b51984..ccd23edf 100644 --- a/windows/src/Elevate.App.Model/ViewModels/AppModel.cs +++ b/windows/src/Elevate.App.Model/ViewModels/AppModel.cs @@ -604,6 +604,7 @@ private async Task RunClockAsync(CancellationToken ct) { Clock = DateTimeOffset.UtcNow; Touch(); + await DropLapsedAssignmentsAsync(Clock); } } catch (OperationCanceledException) @@ -611,6 +612,36 @@ private async Task RunClockAsync(CancellationToken ct) } } + /// + /// How long past its end an activation is kept, so the "expired" toast — due a few seconds + /// after the end — fires before the reschedule below would withdraw it. + /// + private static readonly TimeSpan LapseGrace = TimeSpan.FromMinutes(1); + + /// + /// A refresh that cannot read a tenant (a sign-in it cannot renew silently, a failed request) + /// keeps that tenant's known rows. The end times are known, though: once one has passed, the + /// row is gone whatever the service would say, and must not linger with a Deactivate button. + /// + internal async Task DropLapsedAssignmentsAsync(DateTimeOffset now) + { + var cutoff = now - LapseGrace; + var lapsed = Active.Values.Where(a => a.HasLapsed(cutoff)).Select(a => a.RoleKey).ToList(); + if (lapsed.Count == 0) + { + return; + } + + foreach (var key in lapsed) + { + Active.Remove(key); + StopWatchingPropagation(key); + } + + Touch(); + await RescheduleNotificationsAsync(); + } + private async Task RunRefreshTimerAsync(CancellationToken ct) { using var timer = new PeriodicTimer(TimeSpan.FromSeconds(60)); diff --git a/windows/tests/Elevate.App.Tests/AppModelLapsedAssignmentTests.cs b/windows/tests/Elevate.App.Tests/AppModelLapsedAssignmentTests.cs new file mode 100644 index 00000000..87be8d7a --- /dev/null +++ b/windows/tests/Elevate.App.Tests/AppModelLapsedAssignmentTests.cs @@ -0,0 +1,49 @@ +using Elevate.App.Tests.Support; +using Elevate.Core.Models; +using Elevate.Core.Storage; +using FluentAssertions; + +namespace Elevate.App.Tests; + +/// +/// Port of the macOS fix in #201. A background refresh that cannot read a tenant keeps its known +/// rows; the clock tick drops the ones whose end has passed, so an activation that ended while the +/// machine slept does not linger in Active now with a Deactivate that can only fail. +/// +public class AppModelLapsedAssignmentTests +{ + private static AppState StateWithTenant() => new() + { + Identities = [Sample.Identity()], + Tenants = [Sample.Tenant()], + }; + + [Fact] + public async Task DropsActiveRowsAMinutePastTheirEnd() + { + using var test = await TestModel.BootstrappedAsync(StateWithTenant(), online: false); + var model = test.Model; + var now = DateTimeOffset.UtcNow; + model.Active[Sample.EntraKey] = Sample.Assignment(Sample.EntraKey, ends: now.AddMinutes(-2)); + model.Active[Sample.AzureKey] = Sample.Assignment(Sample.AzureKey, ends: now.AddHours(1)); + model.Active[Sample.GroupKey] = new ActiveAssignment(Sample.GroupKey, "p", now.AddHours(-3), null, AssignmentStatus.PendingApproval); + + await model.DropLapsedAssignmentsAsync(now); + + model.Active.Keys.Should().BeEquivalentTo([Sample.AzureKey, Sample.GroupKey]); + } + + /// The "expired" toast is due five seconds after the end; dropping the row sooner would withdraw it. + [Fact] + public async Task KeepsARowThatEndedLessThanAMinuteAgo() + { + using var test = await TestModel.BootstrappedAsync(StateWithTenant(), online: false); + var model = test.Model; + var now = DateTimeOffset.UtcNow; + model.Active[Sample.EntraKey] = Sample.Assignment(Sample.EntraKey, ends: now.AddSeconds(-30)); + + await model.DropLapsedAssignmentsAsync(now); + + model.Active.Should().ContainKey(Sample.EntraKey); + } +} From bf3fe5885e9232a6cd5c27792a21d6be499c3a7d Mon Sep 17 00:00:00 2001 From: frodehus Date: Thu, 24 Sep 2026 07:11:25 +0200 Subject: [PATCH 3/6] Windows: show a tenant that needs a sign-in to refresh with a caution warning Port of the macOS change in #201. The hint shared the grey info glyph with plain limitations, so a tenant whose rows had gone stale looked fine; it now shows the warning triangle in the caution colour, and the screen reader says "Sign-in needed". --- windows/src/Elevate.App/Views/PanelItems.cs | 24 +++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/windows/src/Elevate.App/Views/PanelItems.cs b/windows/src/Elevate.App/Views/PanelItems.cs index 9ffa57ef..28064009 100644 --- a/windows/src/Elevate.App/Views/PanelItems.cs +++ b/windows/src/Elevate.App/Views/PanelItems.cs @@ -518,6 +518,7 @@ public PanelGroup(string key, GroupKind kind) private bool _busy; private string? _signInHelp; private bool _needsSignIn; + private bool _awaitingSignIn; private bool _signInEnabled; private string? _signInTooltip; @@ -559,9 +560,16 @@ public IReadOnlyList Issues } } - /// A failed discovery or refresh turns the glyph red; limitations alone leave it orange. + /// A failed discovery or refresh turns the glyph red; limitations alone leave it grey. public bool HasError { get => _hasError; set => Set(ref _hasError, value); } + /// + /// A background refresh could not renew the tenant's sign-in silently, so the rows shown may be + /// stale until the user presses Refresh. Not an error, but it must not read as a quiet limitation: + /// the glyph turns to a caution-coloured warning. + /// + public bool AwaitingSignIn { get => _awaitingSignIn; set => Set(ref _awaitingSignIn, value); } + public bool Busy { get => _busy; set => Set(ref _busy, value); } /// @@ -609,14 +617,16 @@ public IReadOnlyList Issues public Visibility IssuesVisibility => Issues.Count == 0 ? Visibility.Collapsed : Visibility.Visible; - /// A filled warning triangle for a failure, an info glyph for mere limitations. - public string IssuesGlyph => HasError ? "" : ""; + /// A filled warning triangle for a failure or a pending sign-in, an info glyph for mere limitations. + public string IssuesGlyph => HasError || AwaitingSignIn ? "" : ""; - public Brush IssuesBrush => (Brush)Application.Current.Resources[HasError ? "SystemFillColorCriticalBrush" : "TextFillColorSecondaryBrush"]; + public Brush IssuesBrush => (Brush)Application.Current.Resources[ + HasError ? "SystemFillColorCriticalBrush" : AwaitingSignIn ? "SystemFillColorCautionBrush" : "TextFillColorSecondaryBrush"]; public string IssuesTooltip => string.Join("\n", Issues.Select(i => i.Title)); - public string IssuesLabel => Issues.Count == 1 ? "1 limitation" : $"{Issues.Count} limitations"; + public string IssuesLabel => !HasError && AwaitingSignIn ? "Sign-in needed" + : Issues.Count == 1 ? "1 limitation" : $"{Issues.Count} limitations"; public Visibility BusyVisibility => Busy ? Visibility.Visible : Visibility.Collapsed; @@ -642,6 +652,7 @@ public void CopyFrom(PanelGroup other) Manual = other.Manual; Issues = other.Issues; HasError = other.HasError; + AwaitingSignIn = other.AwaitingSignIn; Busy = other.Busy; NeedsSignIn = other.NeedsSignIn; SignInEnabled = other.SignInEnabled; @@ -905,7 +916,8 @@ private static void ApplyStatus(AppModel model, PanelGroup group, TenantContext issues.Add(new TenantIssue("Discovery or refresh failed", error)); } - if (model.TenantsAwaitingSignIn.Contains(tenant.Key)) + group.AwaitingSignIn = model.TenantsAwaitingSignIn.Contains(tenant.Key); + if (group.AwaitingSignIn) { issues.Add(new TenantIssue( "Sign-in needed to refresh", From 8964f6952c921afb6523238cb202a285e219db43 Mon Sep 17 00:00:00 2001 From: frodehus Date: Thu, 24 Sep 2026 07:17:46 +0200 Subject: [PATCH 4/6] Changelog: Windows sign-in hint --- CHANGELOG.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f10b8ba..90e23d61 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,7 +20,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Windows: **expired activations no longer linger after the PC wakes.** As on macOS, a background refresh that could not renew a sign-in silently kept the tenant's last-known rows, including activations that had ended meanwhile. Elevate now drops an activation a minute after its end - time, whether or not the tenant could be read. + time, whether or not the tenant could be read. A tenant waiting for a sign-in to refresh is also + marked with a warning in the caution colour on its header instead of the grey info glyph that + plain limitations use; press Refresh to sign in. - CLI: **`elevate watch` no longer keeps listing activations that have ended.** When a re-read failed — a sign-in it could not renew silently, a dropped network — the table kept the last rows From 6fcae70b5df721fe7ef96bd947de0578db4f2d1b Mon Sep 17 00:00:00 2001 From: frodehus Date: Thu, 24 Sep 2026 07:19:09 +0200 Subject: [PATCH 5/6] Windows: use the macOS orange for the sign-in hint Fluent's caution colour turns yellow in dark mode; an app theme brush with the macOS system orange keeps the warning the same on both platforms. --- CHANGELOG.md | 2 +- windows/src/Elevate.App/App.xaml | 12 ++++++++++++ windows/src/Elevate.App/Views/PanelItems.cs | 4 ++-- 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 90e23d61..4f6f8c13 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,7 +21,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 refresh that could not renew a sign-in silently kept the tenant's last-known rows, including activations that had ended meanwhile. Elevate now drops an activation a minute after its end time, whether or not the tenant could be read. A tenant waiting for a sign-in to refresh is also - marked with a warning in the caution colour on its header instead of the grey info glyph that + marked with an orange warning on its header instead of the grey info glyph that plain limitations use; press Refresh to sign in. - CLI: **`elevate watch` no longer keeps listing activations that have ended.** When a re-read diff --git a/windows/src/Elevate.App/App.xaml b/windows/src/Elevate.App/App.xaml index d067f1bc..c206c4a8 100644 --- a/windows/src/Elevate.App/App.xaml +++ b/windows/src/Elevate.App/App.xaml @@ -8,6 +8,18 @@ + + + + + + + + + + + +