import Foundation @MainActor final class AuthStore: ObservableObject { @Published private(set) var user: AuthUser? @Published private(set) var token: String? @Published private(set) var isBusy = false @Published private(set) var error: String? var disconnectAccount: (() async -> Void)? let config: ServerConfig private let client: AuthClient private let tokens: TokenStore private var generation = 0 private var operation: Task? private var loginOperation: Task? let fixture: Bool init(config: ServerConfig, client: AuthClient? = nil, tokens: TokenStore = KeychainTokenStore(), fixture: Bool = false) { self.config = config self.client = client ?? AuthClient(config: config) self.tokens = tokens self.fixture = fixture if fixture { user = AuthUser(id: "offline-fixture", username: "fixture", displayName: "Offline fixture", role: "user", status: "ready") token = "offline-fixture-not-a-server-token" } } func restore() async { guard !fixture, !isBusy, user == nil else { return } generation += 1 let current = generation isBusy = true do { guard let saved = try tokens.token(origin: config.baseURL.absoluteString) else { isBusy = false; return } let task = Task { try await client.me(token: saved) } operation = task let verified = try await task.value guard current == generation, !Task.isCancelled else { return } guard ["ready", "provisioning"].contains(verified.status) else { throw AuthError.status(403) } user = verified token = saved } catch { guard current == generation else { return } if let authError = error as? AuthError, case .status(let status) = authError, status == 401 || status == 403 { try? tokens.save(nil, origin: config.baseURL.absoluteString) self.error = "Please sign in to continue." } else { // An outage is not revocation. Preserve the origin-scoped bearer // for a subsequent launch/retry, but expose no unverified user UI. self.error = "Could not verify your saved sign-in. Check the connection or sign in again." } } guard current == generation else { return } isBusy = false operation = nil } func login(username: String, password: String) async { guard !isBusy else { return } if fixture { user = AuthUser(id: "offline-" + username, username: username, displayName: username, role: "user", status: "ready") token = "offline-fixture-not-a-server-token" return } generation += 1 let current = generation isBusy = true error = nil do { let task = Task { try await client.login(username: username, password: password) } loginOperation = task let response = try await task.value guard current == generation, !Task.isCancelled else { return } guard !response.token.isEmpty, ["ready", "provisioning"].contains(response.user.status), response.expiresAt > Date().timeIntervalSince1970 else { throw AuthError.status(403) } try tokens.save(response.token, origin: config.baseURL.absoluteString) token = response.token user = response.user } catch { guard current == generation else { return } self.error = error.localizedDescription } guard current == generation else { return } isBusy = false loginOperation = nil } func logout(disconnect: () async -> Void) async { generation += 1 loginOperation?.cancel() loginOperation = nil operation?.cancel() operation = nil let previous = token token = nil // immediately disables bootstrap; account UI stays until transport closes isBusy = true await disconnect() try? tokens.save(nil, origin: config.baseURL.absoluteString) user = nil disconnectAccount = nil error = nil if let previous, !fixture { try? await client.logout(token: previous) } isBusy = false } }