Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 19 additions & 0 deletions macos/Sources/ElevateApp/App/AppModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
7 changes: 5 additions & 2 deletions macos/Sources/ElevateApp/Views/TenantSection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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) {
Expand Down
8 changes: 8 additions & 0 deletions macos/Sources/ElevateCore/Models/Roles.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
34 changes: 34 additions & 0 deletions macos/Tests/ElevateCoreTests/LapsedAssignmentTests.swift
Original file line number Diff line number Diff line change
@@ -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))
}
}
Loading