This repository has been archived on 2026-10-04. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
hope-voice-ios/Sources/App/AuthStore.swift
T

104 lines
4.3 KiB
Swift

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<AuthUser, Error>?
private var loginOperation: Task<AuthLoginResponse, Error>?
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
}
}