All checks were successful
CI / Scraper / Lint (push) Successful in 10s
CI / Scraper / Test (push) Successful in 14s
Release / Scraper / Test (push) Successful in 18s
CI / Scraper / Lint (pull_request) Successful in 18s
Release / UI / Build (push) Successful in 23s
CI / Scraper / Test (pull_request) Successful in 15s
CI / UI / Build (pull_request) Successful in 32s
Release / Scraper / Docker (push) Successful in 55s
CI / Scraper / Docker Push (pull_request) Has been skipped
CI / UI / Docker Push (pull_request) Has been skipped
CI / Scraper / Docker Push (push) Successful in 1m5s
Release / UI / Docker (push) Successful in 1m12s
iOS CI / Build (push) Successful in 4m18s
iOS CI / Build (pull_request) Successful in 4m25s
iOS CI / Test (push) Successful in 8m11s
iOS CI / Test (pull_request) Successful in 8m21s
145 lines
4.6 KiB
Swift
145 lines
4.6 KiB
Swift
import Foundation
|
|
import Combine
|
|
|
|
// MARK: - AuthStore
|
|
// Owns the authenticated user, the HMAC auth token, and user settings.
|
|
// Persists the token to Keychain so the user stays logged in across launches.
|
|
|
|
@MainActor
|
|
final class AuthStore: ObservableObject {
|
|
@Published var user: AppUser?
|
|
@Published var settings: UserSettings = .default
|
|
@Published var isLoading: Bool = false
|
|
@Published var error: String?
|
|
|
|
var isAuthenticated: Bool { user != nil }
|
|
|
|
private let keychainKey = "libnovel_v2_auth_token"
|
|
|
|
init() {
|
|
if let token = loadToken() {
|
|
Task { await validateToken(token) }
|
|
}
|
|
}
|
|
|
|
// MARK: - Login / Register
|
|
|
|
func login(username: String, password: String) async {
|
|
isLoading = true
|
|
error = nil
|
|
do {
|
|
let response = try await APIClient.shared.login(username: username, password: password)
|
|
await APIClient.shared.setAuthCookie(response.token)
|
|
saveToken(response.token)
|
|
user = response.user
|
|
await loadSettings()
|
|
} catch {
|
|
self.error = error.localizedDescription
|
|
}
|
|
isLoading = false
|
|
}
|
|
|
|
func register(username: String, password: String) async {
|
|
isLoading = true
|
|
error = nil
|
|
do {
|
|
let response = try await APIClient.shared.register(username: username, password: password)
|
|
await APIClient.shared.setAuthCookie(response.token)
|
|
saveToken(response.token)
|
|
user = response.user
|
|
await loadSettings()
|
|
} catch {
|
|
self.error = error.localizedDescription
|
|
}
|
|
isLoading = false
|
|
}
|
|
|
|
func logout() async {
|
|
do { try await APIClient.shared.logout() } catch {}
|
|
clearToken()
|
|
user = nil
|
|
settings = .default
|
|
}
|
|
|
|
// MARK: - Settings
|
|
|
|
func loadSettings() async {
|
|
do { settings = try await APIClient.shared.settings() } catch {}
|
|
}
|
|
|
|
func saveSettings(_ updated: UserSettings) async {
|
|
do {
|
|
try await APIClient.shared.updateSettings(updated)
|
|
settings = updated
|
|
} catch {
|
|
self.error = error.localizedDescription
|
|
}
|
|
}
|
|
|
|
// MARK: - Token validation
|
|
|
|
func validateToken() async {
|
|
guard let token = loadToken() else { return }
|
|
await validateToken(token)
|
|
}
|
|
|
|
private func validateToken(_ token: String) async {
|
|
await APIClient.shared.setAuthCookie(token)
|
|
do {
|
|
async let me: AppUser = APIClient.shared.fetch("/api/auth/me")
|
|
async let s: UserSettings = APIClient.shared.settings()
|
|
var (restoredUser, restoredSettings) = try await (me, s)
|
|
// Exchange raw MinIO key for a presigned URL if needed.
|
|
if let key = restoredUser.avatarURL, !key.hasPrefix("http") {
|
|
if let presignedURL = try? await APIClient.shared.fetchAvatarPresignedURL() {
|
|
restoredUser = AppUser(
|
|
id: restoredUser.id,
|
|
username: restoredUser.username,
|
|
role: restoredUser.role,
|
|
created: restoredUser.created,
|
|
avatarURL: presignedURL
|
|
)
|
|
}
|
|
}
|
|
user = restoredUser
|
|
settings = restoredSettings
|
|
} catch let e as APIError {
|
|
if case .httpError(let code, _) = e, code == 401 { clearToken() }
|
|
} catch {}
|
|
}
|
|
|
|
// MARK: - Keychain helpers
|
|
|
|
private func saveToken(_ token: String) {
|
|
let data = Data(token.utf8)
|
|
let query: [String: Any] = [
|
|
kSecClass as String: kSecClassGenericPassword,
|
|
kSecAttrAccount as String: keychainKey,
|
|
kSecValueData as String: data
|
|
]
|
|
SecItemDelete(query as CFDictionary)
|
|
SecItemAdd(query as CFDictionary, nil)
|
|
}
|
|
|
|
private func loadToken() -> String? {
|
|
let query: [String: Any] = [
|
|
kSecClass as String: kSecClassGenericPassword,
|
|
kSecAttrAccount as String: keychainKey,
|
|
kSecReturnData as String: true,
|
|
kSecMatchLimit as String: kSecMatchLimitOne
|
|
]
|
|
var item: CFTypeRef?
|
|
guard SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess,
|
|
let data = item as? Data else { return nil }
|
|
return String(data: data, encoding: .utf8)
|
|
}
|
|
|
|
private func clearToken() {
|
|
let query: [String: Any] = [
|
|
kSecClass as String: kSecClassGenericPassword,
|
|
kSecAttrAccount as String: keychainKey
|
|
]
|
|
SecItemDelete(query as CFDictionary)
|
|
}
|
|
}
|