import Foundation import Combine // MARK: - AuthStore // Owns the authenticated user, the HMAC auth token, and user settings. // Persists the token to Keychain so the user stays logged in across launches. @MainActor final class AuthStore: ObservableObject { @Published var user: AppUser? @Published var settings: UserSettings = .default @Published var isLoading: Bool = false @Published var error: String? var isAuthenticated: Bool { user != nil } private let keychainKey = "libnovel_v2_auth_token" init() { if let token = loadToken() { Task { await validateToken(token) } } } // MARK: - Login / Register func login(username: String, password: String) async { isLoading = true error = nil do { let response = try await APIClient.shared.login(username: username, password: password) await APIClient.shared.setAuthCookie(response.token) saveToken(response.token) user = response.user await loadSettings() } catch { self.error = error.localizedDescription } isLoading = false } func register(username: String, password: String) async { isLoading = true error = nil do { let response = try await APIClient.shared.register(username: username, password: password) await APIClient.shared.setAuthCookie(response.token) saveToken(response.token) user = response.user await loadSettings() } catch { self.error = error.localizedDescription } isLoading = false } func logout() async { do { try await APIClient.shared.logout() } catch {} clearToken() user = nil settings = .default } // MARK: - Settings func loadSettings() async { do { settings = try await APIClient.shared.settings() } catch {} } func saveSettings(_ updated: UserSettings) async { do { try await APIClient.shared.updateSettings(updated) settings = updated } catch { self.error = error.localizedDescription } } // MARK: - Token validation func validateToken() async { guard let token = loadToken() else { return } await validateToken(token) } private func validateToken(_ token: String) async { await APIClient.shared.setAuthCookie(token) do { async let me: AppUser = APIClient.shared.fetch("/api/auth/me") async let s: UserSettings = APIClient.shared.settings() var (restoredUser, restoredSettings) = try await (me, s) // Exchange raw MinIO key for a presigned URL if needed. if let key = restoredUser.avatarURL, !key.hasPrefix("http") { if let presignedURL = try? await APIClient.shared.fetchAvatarPresignedURL() { restoredUser = AppUser( id: restoredUser.id, username: restoredUser.username, role: restoredUser.role, created: restoredUser.created, avatarURL: presignedURL ) } } user = restoredUser settings = restoredSettings } catch let e as APIError { if case .httpError(let code, _) = e, code == 401 { clearToken() } } catch {} } // MARK: - Keychain helpers private func saveToken(_ token: String) { let data = Data(token.utf8) let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrAccount as String: keychainKey, kSecValueData as String: data ] SecItemDelete(query as CFDictionary) SecItemAdd(query as CFDictionary, nil) } private func loadToken() -> String? { let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrAccount as String: keychainKey, kSecReturnData as String: true, kSecMatchLimit as String: kSecMatchLimitOne ] var item: CFTypeRef? guard SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess, let data = item as? Data else { return nil } return String(data: data, encoding: .utf8) } private func clearToken() { let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrAccount as String: keychainKey ] SecItemDelete(query as CFDictionary) } }