// ── Auth Service ────────────────────────────────────────────── // Login, register, refresh, logout via platform-service /auth/* endpoints. // Stores tokens in Keychain; wires into PlatformSyncManager. import Foundation import SwiftUI struct AuthUser: Codable { let id: String let email: String let name: String let plan: String let role: String enum CodingKeys: String, CodingKey { case id, email, plan, role case name = "displayName" } init(id: String, email: String, name: String, plan: String, role: String = "user") { self.id = id self.email = email self.name = name self.plan = plan self.role = role } init(from decoder: Decoder) throws { let c = try decoder.container(keyedBy: CodingKeys.self) id = try c.decode(String.self, forKey: .id) email = try c.decode(String.self, forKey: .email) name = try c.decode(String.self, forKey: .name) plan = try c.decodeIfPresent(String.self, forKey: .plan) ?? "free" role = try c.decodeIfPresent(String.self, forKey: .role) ?? "user" } } private struct TokenResponse: Codable { let accessToken: String let refreshToken: String let user: AuthUser } private struct RefreshResponse: Codable { let accessToken: String let refreshToken: String } enum CMAuthState { case loading case loggedOut case loggedIn(AuthUser) case error(String) } @MainActor final class CMAuthService: ObservableObject { static let shared = CMAuthService() @Published var state: CMAuthState = .loading @AppStorage("cm_user_email") private var userEmail = "" @AppStorage("cm_user_name") private var userName = "" @AppStorage("cm_user_plan") private var userPlan = "free" private var accessToken: String { get { KeychainHelper.read(key: "cm_access_token") ?? "" } set { if newValue.isEmpty { KeychainHelper.delete(key: "cm_access_token") } else { KeychainHelper.save(key: "cm_access_token", value: newValue) } } } private var refreshToken: String { get { KeychainHelper.read(key: "cm_refresh_token") ?? "" } set { if newValue.isEmpty { KeychainHelper.delete(key: "cm_refresh_token") } else { KeychainHelper.save(key: "cm_refresh_token", value: newValue) } } } private var refreshTimer: Timer? private var baseURL: String { Bundle.main.object(forInfoDictionaryKey: "PLATFORM_SERVICE_URL") as? String ?? "https://api.chronomind.app" } private let productId = "chronomind" private init() { checkExistingSession() } private func checkExistingSession() { if !accessToken.isEmpty, !userEmail.isEmpty { state = .loggedIn(AuthUser(id: "", email: userEmail, name: userName, plan: userPlan)) wireSyncToken() startRefreshTimer() Task { await fetchCurrentUser() } } else if !accessToken.isEmpty { Task { await fetchCurrentUser() } } else { state = .loggedOut } } // MARK: - Public API func login(email: String, password: String) async { state = .loading let body: [String: String] = [ "email": email, "password": password, "productId": productId, ] guard let result = await postAuth(path: "/auth/login", body: body) else { state = .error("Invalid email or password") return } saveSession(result) } func register(name: String, email: String, password: String) async { state = .loading let body: [String: String] = [ "email": email, "displayName": name, "password": password, "productId": productId, ] guard let result = await postAuth(path: "/auth/register", body: body) else { state = .error("Registration failed") return } saveSession(result) } func logout() { stopRefreshTimer() accessToken = "" refreshToken = "" userEmail = "" userName = "" state = .loggedOut PlatformSyncManager.shared.setAuthToken(nil) } func forgotPassword(email: String) async -> String? { guard let url = URL(string: "\(baseURL)/auth/forgot-password"), let jsonData = try? JSONSerialization.data(withJSONObject: [ "email": email, "productId": productId ]) else { return "Invalid request" } var request = URLRequest(url: url) request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.setValue(productId, forHTTPHeaderField: "X-Product-Id") request.setValue(UUID().uuidString, forHTTPHeaderField: "X-Request-Id") request.httpBody = jsonData request.timeoutInterval = 15 do { let (_, response) = try await URLSession.shared.data(for: request) guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { return "Failed to send reset email" } return nil } catch { return error.localizedDescription } } func changePassword(currentPassword: String, newPassword: String) async -> String? { guard !accessToken.isEmpty, let url = URL(string: "\(baseURL)/auth/change-password"), let jsonData = try? JSONSerialization.data(withJSONObject: [ "currentPassword": currentPassword, "newPassword": newPassword ]) else { return "Not authenticated" } var request = URLRequest(url: url) request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") request.setValue(productId, forHTTPHeaderField: "X-Product-Id") request.setValue(UUID().uuidString, forHTTPHeaderField: "X-Request-Id") request.httpBody = jsonData request.timeoutInterval = 15 do { let (data, response) = try await URLSession.shared.data(for: request) guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { if let body = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let msg = body["message"] as? String { return msg } return "Failed to change password" } return nil } catch { return error.localizedDescription } } func deleteAccount(password: String) async -> String? { guard !accessToken.isEmpty, let url = URL(string: "\(baseURL)/auth/account"), let jsonData = try? JSONSerialization.data(withJSONObject: [ "password": password ]) else { return "Not authenticated" } var request = URLRequest(url: url) request.httpMethod = "DELETE" request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") request.setValue(productId, forHTTPHeaderField: "X-Product-Id") request.setValue(UUID().uuidString, forHTTPHeaderField: "X-Request-Id") request.httpBody = jsonData request.timeoutInterval = 15 do { let (data, response) = try await URLSession.shared.data(for: request) guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { if let body = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let msg = body["message"] as? String { return msg } return "Failed to delete account" } logout() return nil } catch { return error.localizedDescription } } var isLoggedIn: Bool { if case .loggedIn = state { return true } return false } var currentUser: AuthUser? { if case .loggedIn(let user) = state { return user } return nil } func getAccessToken() -> String? { let t = accessToken return t.isEmpty ? nil : t } // MARK: - Token Refresh func refreshAccessToken() async -> Bool { guard !refreshToken.isEmpty else { return false } let body: [String: String] = ["refreshToken": refreshToken] guard let url = URL(string: "\(baseURL)/auth/refresh"), let jsonData = try? JSONSerialization.data(withJSONObject: body) else { return false } var request = URLRequest(url: url) request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.setValue(productId, forHTTPHeaderField: "X-Product-Id") request.httpBody = jsonData request.timeoutInterval = 10 do { let (data, response) = try await URLSession.shared.data(for: request) guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { if let http = response as? HTTPURLResponse, http.statusCode == 401 { logout() } return false } let r = try JSONDecoder().decode(RefreshResponse.self, from: data) accessToken = r.accessToken refreshToken = r.refreshToken wireSyncToken() return true } catch { return false } } private func startRefreshTimer() { stopRefreshTimer() refreshTimer = Timer.scheduledTimer(withTimeInterval: 45 * 60, repeats: true) { [weak self] _ in guard let self else { return } Task { @MainActor in _ = await self.refreshAccessToken() } } } private func stopRefreshTimer() { refreshTimer?.invalidate() refreshTimer = nil } // MARK: - Helpers private func wireSyncToken() { PlatformSyncManager.shared.setAuthToken(accessToken.isEmpty ? nil : accessToken) } private func postAuth(path: String, body: [String: String]) async -> TokenResponse? { guard let url = URL(string: "\(baseURL)\(path)"), let jsonData = try? JSONSerialization.data(withJSONObject: body) else { return nil } var request = URLRequest(url: url) request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.setValue(productId, forHTTPHeaderField: "X-Product-Id") request.setValue(UUID().uuidString, forHTTPHeaderField: "X-Request-Id") request.httpBody = jsonData request.timeoutInterval = 15 do { let (data, response) = try await URLSession.shared.data(for: request) guard let http = response as? HTTPURLResponse, (200...201).contains(http.statusCode) else { return nil } return try JSONDecoder().decode(TokenResponse.self, from: data) } catch { return nil } } private func saveSession(_ resp: TokenResponse) { accessToken = resp.accessToken refreshToken = resp.refreshToken userEmail = resp.user.email userName = resp.user.name userPlan = resp.user.plan state = .loggedIn(resp.user) wireSyncToken() startRefreshTimer() } func fetchCurrentUser() async { guard !accessToken.isEmpty, let url = URL(string: "\(baseURL)/auth/me") else { return } var request = URLRequest(url: url) request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") request.setValue(productId, forHTTPHeaderField: "X-Product-Id") request.timeoutInterval = 10 do { let (data, response) = try await URLSession.shared.data(for: request) guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { if let http = response as? HTTPURLResponse, http.statusCode == 401 { let ok = await refreshAccessToken() if ok { await fetchCurrentUser() } else { state = .loggedOut } } return } struct MeResponse: Codable { let id: String; let email: String; let displayName: String let plan: String?; let role: String? } let info = try JSONDecoder().decode(MeResponse.self, from: data) let plan = info.plan ?? "free" userPlan = plan; userEmail = info.email; userName = info.displayName state = .loggedIn(AuthUser(id: info.id, email: info.email, name: info.displayName, plan: plan, role: info.role ?? "user")) wireSyncToken() } catch { // Keep existing state } } }