Files
boc/aamos-admin-upgrade/ios/AamosAdmin/ViewModels/AuthViewModel.swift
T
Bernt 6de2455917 v1.2.0: Add Global Markets footer, translated to 9 languages
- 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
2026-07-08 19:56:03 +00:00

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))." }
}
}