From bf67d60df3b9a30f17e210a93d1ff1487ce86f0f Mon Sep 17 00:00:00 2001 From: frodehus Date: Thu, 24 Sep 2026 06:55:44 +0200 Subject: [PATCH 1/3] Drop activations whose end has passed, even when a refresh cannot reach the tenant A background refresh that cannot renew a sign-in silently keeps the tenant's known rows. After a long sleep those rows outlived their activations and stayed in Active now with a Deactivate button that could only fail. The clock tick now drops active and scheduled rows a minute past their end. --- macos/Sources/ElevateApp/App/AppModel.swift | 19 +++++++++++ macos/Sources/ElevateCore/Models/Roles.swift | 8 +++++ .../LapsedAssignmentTests.swift | 34 +++++++++++++++++++ 3 files changed, 61 insertions(+) create mode 100644 macos/Tests/ElevateCoreTests/LapsedAssignmentTests.swift diff --git a/macos/Sources/ElevateApp/App/AppModel.swift b/macos/Sources/ElevateApp/App/AppModel.swift index ecd40701..ff47b719 100644 --- a/macos/Sources/ElevateApp/App/AppModel.swift +++ b/macos/Sources/ElevateApp/App/AppModel.swift @@ -601,10 +601,29 @@ final class AppModel { try? await Task.sleep(for: .seconds(30)) guard let self else { return } self.clock = .now + await self.dropLapsedAssignments() } } } + /// How long past its end an activation is kept, so the "expired" notification — due a few + /// seconds after the end — fires before the reschedule below would withdraw it. + private static let lapseGrace: TimeInterval = 60 + + /// 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. + private func dropLapsedAssignments() async { + let cutoff = Date.now.addingTimeInterval(-Self.lapseGrace) + let lapsed = active.values.filter { $0.hasLapsed(at: cutoff) }.map(\.roleKey) + guard !lapsed.isEmpty else { return } + for key in lapsed { + active[key] = nil + stopWatchingPropagation(key) + } + await rescheduleNotifications() + } + private func startTimer() { startClock() refreshTimer?.cancel() diff --git a/macos/Sources/ElevateCore/Models/Roles.swift b/macos/Sources/ElevateCore/Models/Roles.swift index 591c56fd..f2c2253e 100644 --- a/macos/Sources/ElevateCore/Models/Roles.swift +++ b/macos/Sources/ElevateCore/Models/Roles.swift @@ -75,6 +75,14 @@ public struct ActiveAssignment: Codable, Hashable, Sendable, Identifiable { self.endDateTime = endDateTime self.status = status } + + /// Whether this activation's window is over by `now`, whatever the last read said. A refresh + /// that cannot reach a tenant keeps its known rows; this is what lets the app drop the ones + /// that have ended meanwhile. Requests and failures have no window of their own and never lapse. + public func hasLapsed(at now: Date) -> Bool { + guard status == .active || status == .scheduled, let endDateTime else { return false } + return endDateTime <= now + } } public struct TicketInfo: Codable, Hashable, Sendable { diff --git a/macos/Tests/ElevateCoreTests/LapsedAssignmentTests.swift b/macos/Tests/ElevateCoreTests/LapsedAssignmentTests.swift new file mode 100644 index 00000000..5adbdc96 --- /dev/null +++ b/macos/Tests/ElevateCoreTests/LapsedAssignmentTests.swift @@ -0,0 +1,34 @@ +import Testing +import Foundation +@testable import ElevateCore + +@Suite struct LapsedAssignmentTests { + let now = Date(timeIntervalSince1970: 1_000_000) + func assignment(_ status: ActiveAssignment.Status, endsIn: TimeInterval?) -> ActiveAssignment { + ActiveAssignment(roleKey: RoleKey(identityId: "i", tenantId: "t", scope: .entraDirectory(roleDefinitionId: "r", directoryScopeId: "/")), + assignmentId: "a", startDateTime: now.addingTimeInterval(-7200), + endDateTime: endsIn.map { now.addingTimeInterval($0) }, status: status) + } + + @Test func activeLapsesOnceItsEndHasPassed() { + #expect(assignment(.active, endsIn: -1).hasLapsed(at: now)) + #expect(assignment(.active, endsIn: 0).hasLapsed(at: now)) + #expect(!assignment(.active, endsIn: 1).hasLapsed(at: now)) + } + + @Test func scheduledLapsesOnceItsEndHasPassed() { + #expect(assignment(.scheduled, endsIn: -60).hasLapsed(at: now)) + #expect(!assignment(.scheduled, endsIn: 60).hasLapsed(at: now)) + } + + @Test func withoutAnEndNothingLapses() { + #expect(!assignment(.active, endsIn: nil).hasLapsed(at: now)) + } + + /// Requests and failures carry no activation window of their own; the service settles them. + @Test func requestsAndFailuresNeverLapse() { + #expect(!assignment(.pendingApproval, endsIn: -60).hasLapsed(at: now)) + #expect(!assignment(.pendingProvisioning, endsIn: -60).hasLapsed(at: now)) + #expect(!assignment(.failed("x"), endsIn: -60).hasLapsed(at: now)) + } +} From 1c24981fe24b2cce21a4e849471c6efc1ba86ec9 Mon Sep 17 00:00:00 2001 From: frodehus Date: Thu, 24 Sep 2026 06:59:07 +0200 Subject: [PATCH 2/3] Show a tenant that needs a sign-in to refresh with an orange warning The hint shared the grey info glyph with plain limitations, so a tenant whose rows had gone stale looked fine. --- macos/Sources/ElevateApp/Views/TenantSection.swift | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/macos/Sources/ElevateApp/Views/TenantSection.swift b/macos/Sources/ElevateApp/Views/TenantSection.swift index 410d3fb6..e9bb006e 100644 --- a/macos/Sources/ElevateApp/Views/TenantSection.swift +++ b/macos/Sources/ElevateApp/Views/TenantSection.swift @@ -149,6 +149,8 @@ struct TenantPills: View { return out } private var hasError: Bool { (model.tenantErrors[tenant.id] ?? tenant.lastDiscoveryError) != nil } + /// Not an error, but the rows shown may be stale until the user signs in, so it must not read as a quiet limitation. + private var needsSignIn: Bool { model.tenantsAwaitingSignIn.contains(tenant.id) } var body: some View { if tenant.discoveryMode == .manualRoles { @@ -160,14 +162,15 @@ struct TenantPills: View { let issues = issues if !issues.isEmpty { Button { showingIssues.toggle() } label: { - Image(systemName: hasError ? "exclamationmark.triangle.fill" : "info.circle") - .font(.caption).foregroundStyle(hasError ? .red : .secondary) + Image(systemName: hasError || needsSignIn ? "exclamationmark.triangle.fill" : "info.circle") + .font(.caption).foregroundStyle(hasError ? .red : needsSignIn ? .orange : .secondary) .frame(width: 16, height: 16).contentShape(Rectangle()) } .buttonStyle(.plain) .help(issues.map(\.title).joined(separator: "\n")) .accessibilityLabel(hasError ? (issues.count == 1 ? "1 error" : "\(issues.count) errors") + : needsSignIn ? "Sign-in needed" : (issues.count == 1 ? "1 limitation" : "\(issues.count) limitations")) .popover(isPresented: $showingIssues, arrowEdge: .bottom) { VStack(alignment: .leading, spacing: 10) { From cba7b0d7eab49c5876ff9c1e605fbf9b2d3598b2 Mon Sep 17 00:00:00 2001 From: frodehus Date: Thu, 24 Sep 2026 06:59:38 +0200 Subject: [PATCH 3/3] Changelog: expired activations after wake, orange sign-in hint --- CHANGELOG.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 449ed2b9..4bc1f71a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- macOS: **expired activations no longer linger after the Mac wakes.** When a background refresh + could not renew a sign-in silently — a browser sign-in account after a long sleep, for example — + the tenant kept its last-known rows, and activations that had ended meanwhile stayed in **Active + now** with a Deactivate that could only fail. 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 an orange warning on its header instead of the grey info glyph that plain + limitations use; press Refresh to sign in. + ## [1.8.0] - 2026-09-19 ### Added