6de2455917
- Added GLOBAL_MARKETS_TITLE to all translation files - Updated footer with 12 markets (4 active + 8 upcoming) - Translated market section to: zh-cn, zh-tw, ja, ko, th, vi, id, ms, hi - Built and deployed to production - CloudFront invalidation: I3RTMXVFDJXWLG3SYX208OP1CC
250 lines
7.6 KiB
Swift
250 lines
7.6 KiB
Swift
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<AnyCancellable>()
|
|
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))." }
|
|
}
|
|
}
|