104 lines
4.3 KiB
Swift
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
|
|
}
|
|
}
|