learning_ai_clock/ios/ChronoMind/Shared/Cloud/AuthService.swift
saravanakumardb1 6a41cc9f48 feat(mobile): add auth login/register flow for iOS and Android
- iOS: Add KeychainHelper.swift for secure token storage
- iOS: Add AuthService.swift (CMAuthService) with login/register/refresh/logout
- iOS: Add LoginView.swift (CMLoginView) with ChronoMind theme
- iOS: Wire auth gate in ChronoMindApp.swift (LoginView vs ContentView)
- iOS: Add Account section to SettingsView with email/plan/sign-out
- iOS: Add Cloud group + 3 files to Xcode project.pbxproj
- Android: Add AuthService.kt with Hilt @Singleton, login/register/refresh/logout
- Android: Add LoginScreen.kt with Compose login/register form
- Android: Wire auth gate in MainActivity via Hilt-injected AuthService
- Android: Add Account section to SettingsScreen via HiltViewModel
- Android: Add x-product-id header to PlatformApiClient
2026-02-28 03:22:23 -08:00

275 lines
9.1 KiB
Swift

// 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)
}
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
}
}
}