Web: - platform-sync.ts: Added forgotPassword, resetPassword, changePassword, verifyEmail, resendVerification, deleteAccount API functions - auth-context.tsx: Added forgotPassword, resetPassword, changePassword, deleteAccount actions + successMessage state + 45min auto-refresh timer - settings/page.tsx: Added forgot password link, change password form, delete account form with confirmation - reset-password/page.tsx: New page for password reset via email token - verify-email/page.tsx: New page for email verification via token iOS: - AuthService.swift: Added forgotPassword, changePassword, deleteAccount methods - SettingsView.swift: Added change password, delete account, forgot password UI sections Android: - AuthService.kt: Added forgotPassword, changePassword, deleteAccount methods
359 lines
13 KiB
Swift
359 lines
13 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)
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
}
|