// Auth/AuthManager.swift // ---------------------- // Observable class that manages the entire authentication lifecycle: // - Login (POST /api/v1/auth/login) // - Logout (POST /api/v1/auth/logout) // - Session restoration on app launch (GET /api/v1/auth/me) // - Keychain persistence of tokens and user info import Foundation import SwiftUI import Combine @MainActor class AuthManager: ObservableObject { // ── Published State ─────────────────────────────────────────────────── @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 = "" // ── Singleton ───────────────────────────────────────────────────────── static let shared = AuthManager() private init() {} // ── App Launch: Restore Session ─────────────────────────────────────── /// Called once on app launch. Checks if a valid session exists in Keychain. /// If an access token is present, verifies it with /api/v1/auth/me. /// If expired, attempts a refresh. If all fails, shows the login screen. 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) isAuthenticated = true } catch APIError.notAuthenticated { // Refresh failed (APIClient tried automatically) — need fresh login KeychainHelper.clearAll() isAuthenticated = false } catch { // Network error — still show as authenticated using cached Keychain data // so the inspector can work offline restoreUserFromKeychain() isAuthenticated = true } } // ── Login ───────────────────────────────────────────────────────────── 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] ) // Store tokens in Keychain KeychainHelper.set(response.accessToken, forKey: Constants.Keychain.accessToken) KeychainHelper.set(response.refreshToken, forKey: Constants.Keychain.refreshToken) applyUser(response.user) isAuthenticated = true } catch APIError.serverError(let msg) { errorMessage = msg } catch APIError.networkError { errorMessage = "Cannot reach the server. Please check your connection." } catch { errorMessage = error.localizedDescription } } // ── Logout ──────────────────────────────────────────────────────────── func logout() async { // Best-effort server-side logout (revokes refresh token) 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 = "" } // ── Helpers ─────────────────────────────────────────────────────────── 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) } 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) ?? "" } } // Used for logout response decoding (empty data field) struct EmptyDecodable: Decodable {}