import Foundation import Combine // MARK: - Models struct AdminUser: Identifiable, Codable { let id: UUID let email: String let displayName: String let role: AdminRole let avatarURL: URL? enum AdminRole: String, Codable { case superAdmin = "super_admin" case admin case viewer } } struct AuthToken: Codable { let accessToken: String let refreshToken: String let expiresAt: Date } // MARK: - Errors enum AuthError: LocalizedError { case invalidCredentials case networkError(Error) case tokenExpired case notAuthenticated var errorDescription: String? { switch self { case .invalidCredentials: return "Felaktig e-post eller lösenord." case .networkError(let e): return e.localizedDescription case .tokenExpired: return "Sessionen har gått ut. Logga in igen." case .notAuthenticated: return "Inte inloggad." } } } // MARK: - ViewModel @MainActor final class AuthViewModel: ObservableObject { @Published private(set) var isAuthenticated: Bool = false @Published private(set) var currentUser: AdminUser? @Published private(set) var isLoading: Bool = false @Published private(set) var error: AuthError? private var cancellables = Set() private let authService: AuthServiceProtocol private let tokenStore: TokenStoreProtocol init( authService: AuthServiceProtocol = AuthService.shared, tokenStore: TokenStoreProtocol = KeychainTokenStore.shared ) { self.authService = authService self.tokenStore = tokenStore restoreSession() } // MARK: - Public func login(email: String, password: String) async { guard !isLoading else { return } isLoading = true error = nil do { let (token, user) = try await authService.login(email: email, password: password) try tokenStore.save(token) currentUser = user isAuthenticated = true } catch let authErr as AuthError { error = authErr } catch { self.error = .networkError(error) } isLoading = false } func logout() { tokenStore.delete() currentUser = nil isAuthenticated = false error = nil } func refreshSessionIfNeeded() async { guard let token = tokenStore.load(), token.expiresAt < Date().addingTimeInterval(300) else { return } do { let newToken = try await authService.refresh(refreshToken: token.refreshToken) try tokenStore.save(newToken) } catch { logout() } } // MARK: - Private private func restoreSession() { guard let token = tokenStore.load() else { return } guard token.expiresAt > Date() else { tokenStore.delete() return } Task { do { let user = try await authService.fetchCurrentUser(accessToken: token.accessToken) currentUser = user isAuthenticated = true } catch { tokenStore.delete() } } } } // MARK: - Auth service protocol protocol AuthServiceProtocol { func login(email: String, password: String) async throws -> (AuthToken, AdminUser) func refresh(refreshToken: String) async throws -> AuthToken func fetchCurrentUser(accessToken: String) async throws -> AdminUser } final class AuthService: AuthServiceProtocol { static let shared = AuthService() private init() {} private let baseURL = URL(string: "https://api.aamos.internal/v1/auth")! private var decoder: JSONDecoder { let d = JSONDecoder() d.dateDecodingStrategy = .iso8601 return d } func login(email: String, password: String) async throws -> (AuthToken, AdminUser) { let url = baseURL.appendingPathComponent("login") var request = URLRequest(url: url) request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.httpBody = try JSONEncoder().encode(["email": email, "password": password]) let (data, response) = try await URLSession.shared.data(for: request) try validate(response) struct LoginResponse: Codable { let token: AuthToken let user: AdminUser } let result = try decoder.decode(LoginResponse.self, from: data) return (result.token, result.user) } func refresh(refreshToken: String) async throws -> AuthToken { let url = baseURL.appendingPathComponent("refresh") var request = URLRequest(url: url) request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.httpBody = try JSONEncoder().encode(["refresh_token": refreshToken]) let (data, response) = try await URLSession.shared.data(for: request) try validate(response) return try decoder.decode(AuthToken.self, from: data) } func fetchCurrentUser(accessToken: String) async throws -> AdminUser { let url = baseURL.appendingPathComponent("me") var request = URLRequest(url: url) request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") let (data, response) = try await URLSession.shared.data(for: request) try validate(response) return try decoder.decode(AdminUser.self, from: data) } private func validate(_ response: URLResponse) throws { guard let http = response as? HTTPURLResponse else { return } switch http.statusCode { case 200..<300: return case 401: throw AuthError.invalidCredentials default: throw URLError(.badServerResponse) } } } // MARK: - Token store protocol TokenStoreProtocol { func save(_ token: AuthToken) throws func load() -> AuthToken? func delete() } final class KeychainTokenStore: TokenStoreProtocol { static let shared = KeychainTokenStore() private init() {} private let key = "aamos.auth.token" func save(_ token: AuthToken) throws { let data = try JSONEncoder().encode(token) let query: [CFString: Any] = [ kSecClass: kSecClassGenericPassword, kSecAttrAccount: key, kSecValueData: data, kSecAttrAccessible: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly ] SecItemDelete(query as CFDictionary) let status = SecItemAdd(query as CFDictionary, nil) if status != errSecSuccess { throw KeychainError.saveFailed(status) } } func load() -> AuthToken? { let query: [CFString: Any] = [ kSecClass: kSecClassGenericPassword, kSecAttrAccount: key, kSecReturnData: true, kSecMatchLimit: kSecMatchLimitOne ] var result: AnyObject? let status = SecItemCopyMatching(query as CFDictionary, &result) guard status == errSecSuccess, let data = result as? Data else { return nil } return try? JSONDecoder().decode(AuthToken.self, from: data) } func delete() { let query: [CFString: Any] = [ kSecClass: kSecClassGenericPassword, kSecAttrAccount: key ] SecItemDelete(query as CFDictionary) } enum KeychainError: LocalizedError { case saveFailed(OSStatus) var errorDescription: String? { "Keychain save failed (OSStatus \(self))." } } }