Files

144 lines
5.5 KiB
Swift

// Auth/AuthManager.swift
import Foundation
import SwiftUI
import Combine
struct EmptyDecodable: Decodable, Sendable {
nonisolated init(from decoder: any Decoder) throws {}
}
@MainActor
class AuthManager: ObservableObject {
@Published var isAuthenticated = false
@Published var isLoading = false
@Published var errorMessage: String?
@Published var currentUserId: Int = 0
@Published var currentUsername: String = ""
@Published var currentUserRole: String = ""
@Published var currentDisplayName: String = ""
static let shared = AuthManager()
private init() {}
func restoreSession() async {
guard KeychainHelper.get(Constants.Keychain.accessToken) != nil else {
isAuthenticated = false
return
}
isLoading = true
defer { isLoading = false }
do {
let response: MeResponseData = try await APIClient.shared.request("/api/v1/auth/me")
applyUser(response.user)
reconcileSessionScope(userId: response.user.id)
isAuthenticated = true
} catch APIError.notAuthenticated {
KeychainHelper.clearAll()
isAuthenticated = false
} catch {
restoreUserFromKeychain()
isAuthenticated = true
}
}
func login(username: String, password: String) async {
isLoading = true
errorMessage = nil
defer { isLoading = false }
do {
let response: LoginResponseData = try await APIClient.shared.post(
"/api/v1/auth/login",
body: ["username": username, "password": password]
)
KeychainHelper.set(response.accessToken, forKey: Constants.Keychain.accessToken)
KeychainHelper.set(response.refreshToken, forKey: Constants.Keychain.refreshToken)
applyUser(response.user)
// BEFORE isAuthenticated flips, so DashboardView is never rendered
// holding the previous inspector's data.
reconcileSessionScope(userId: response.user.id)
isAuthenticated = true
// Register device immediately after login — the .task {} and
// .onChange(scenePhase) paths both miss this case because they run
// before login completes.
Task { await APIClient.shared.registerDevice() }
} catch APIError.serverError(let msg) {
errorMessage = msg
} catch APIError.networkError {
errorMessage = "Cannot reach the server. Please check your connection."
} catch {
errorMessage = error.localizedDescription
}
}
func logout() async {
if let refreshToken = KeychainHelper.get(Constants.Keychain.refreshToken) {
_ = try? await APIClient.shared.post(
"/api/v1/auth/logout",
body: ["refresh_token": refreshToken]
) as EmptyDecodable
}
KeychainHelper.clearAll()
isAuthenticated = false
currentUserId = 0
currentUsername = ""
currentUserRole = ""
currentDisplayName = ""
}
private func applyUser(_ user: APIUser) {
currentUserId = user.id
currentUsername = user.username
currentUserRole = user.role
currentDisplayName = user.displayName
KeychainHelper.set(String(user.id), forKey: Constants.Keychain.userId)
KeychainHelper.set(user.role, forKey: Constants.Keychain.userRole)
KeychainHelper.set(user.username, forKey: Constants.Keychain.username)
KeychainHelper.set(user.displayName, forKey: Constants.Keychain.displayName)
}
/// Purge local data when this session is scoped to a different
/// `(server, user)` pair than the database currently holds.
///
/// This is the check that was missing: logout deliberately keeps the cache
/// so the same inspector can work offline after signing back in, but
/// nothing verified that the next sign-in *was* the same inspector. A
/// different one inherited their issues; see `SessionScope` and rule 88.
///
/// Runs on both `login()` and `restoreSession()` — a session can also be
/// restored into a changed scope after a server switch.
private func reconcileSessionScope(userId: Int) {
let server = ServerConfig.current
let stored = SessionScope.stored
// No marker: either a genuinely fresh install, or an upgrade from a
// build that predates this. Those are indistinguishable, and purging on
// the guess would delete an in-progress draft belonging to the user
// signing in right now — so adopt the existing data and start tracking
// from here. Every subsequent identity change is then covered.
guard let stored else {
SessionScope.record(userId: userId)
return
}
guard stored.server != server || stored.userId != userId else { return }
SyncManager.shared.purgeSessionScopedData(
keepingUserId: userId,
sameServer: stored.server == server
)
SessionScope.record(userId: userId)
}
private func restoreUserFromKeychain() {
currentUserId = Int(KeychainHelper.get(Constants.Keychain.userId) ?? "0") ?? 0
currentUserRole = KeychainHelper.get(Constants.Keychain.userRole) ?? ""
currentUsername = KeychainHelper.get(Constants.Keychain.username) ?? ""
currentDisplayName = KeychainHelper.get(Constants.Keychain.displayName) ?? ""
}
}