144 lines
5.5 KiB
Swift
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) ?? ""
|
|
}
|
|
}
|