Files
SwiftForge/SwiftForge/Managers/AuthenticationManager.swift
2025-07-05 18:24:19 -06:00

338 lines
10 KiB
Swift

//
// AuthenticationManager.swift
// SwiftForge
//
// Created by Atridad Lahiji on 2025-07-04.
//
import Combine
import Foundation
import Security
@MainActor
class AuthenticationManager: ObservableObject {
@Published var isAuthenticated = false
@Published var currentUser: User?
@Published var serverURL: String = ""
@Published var isLoading = false
@Published var errorMessage: String?
@Published private var isUpdatingProfile = false
private let keychainService = "com.swiftforge.gitea"
private let tokenKey = "gitea_token"
private let serverURLKey = "gitea_server_url"
private let userDefaultsKey = "gitea_settings"
private var apiService: GiteaAPIService?
private var cancellables = Set<AnyCancellable>()
init() {
loadStoredCredentials()
}
// MARK: - Public Methods
func login(serverURL: String, token: String) async {
isLoading = true
errorMessage = nil
do {
// Validate inputs
let trimmedURL = serverURL.trimmingCharacters(in: .whitespacesAndNewlines)
let trimmedToken = token.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedToken.isEmpty else {
throw AuthenticationError.invalidCredentials
}
// Validate server URL format
guard let url = URL(string: trimmedURL),
url.scheme != nil
else {
throw AuthenticationError.invalidServerURL
}
// Create API service with provided credentials
let tempAPIService = GiteaAPIService(baseURL: trimmedURL, token: trimmedToken)
// Test the connection by fetching user info
let user = try await tempAPIService.getCurrentUser()
// If successful, store credentials and update state
try storeCredentials(serverURL: trimmedURL, token: trimmedToken)
self.serverURL = trimmedURL
self.currentUser = user
self.apiService = tempAPIService
self.isAuthenticated = true
} catch {
// Provide more specific error messages
if let apiError = error as? APIError {
switch apiError {
case .unauthorized:
self.errorMessage =
"Invalid access token. Please check your token and try again."
case .notFound:
self.errorMessage = "Server not found. Please check your server URL."
case .networkError:
self.errorMessage = "Network error. Please check your internet connection."
default:
self.errorMessage = error.localizedDescription
}
} else {
self.errorMessage = error.localizedDescription
}
}
isLoading = false
}
func logout() {
// Clear stored credentials
clearCredentials()
// Reset state
isAuthenticated = false
currentUser = nil
serverURL = ""
apiService = nil
errorMessage = nil
}
func checkAuthenticationStatus() {
guard let storedToken = getStoredToken(),
let storedServerURL = getStoredServerURL(),
!storedToken.isEmpty,
!storedServerURL.isEmpty
else {
isAuthenticated = false
return
}
serverURL = storedServerURL
apiService = GiteaAPIService(baseURL: storedServerURL, token: storedToken)
// Verify token is still valid
Task {
do {
let user = try await apiService?.getCurrentUser()
await MainActor.run {
self.currentUser = user
self.isAuthenticated = true
}
} catch {
await MainActor.run {
self.logout()
}
}
}
}
func refreshUser() async {
print("DEBUG: refreshUser called")
guard let apiService = apiService else {
print("DEBUG: refreshUser failed - no API service")
return
}
do {
print("DEBUG: refreshUser calling getCurrentUser")
let user = try await apiService.getCurrentUser()
print("DEBUG: refreshUser got user: \(user.login)")
await MainActor.run {
currentUser = user
}
} catch {
print("DEBUG: refreshUser failed with error: \(error)")
// Handle cancellation and other errors gracefully
if error is CancellationError {
print("DEBUG: refreshUser cancelled - not showing error")
// Don't show error message for cancellation
return
}
await MainActor.run {
errorMessage = error.localizedDescription
}
}
}
func updateUserProfile(
fullName: String?, email: String?, description: String?, website: String?, location: String?
) async throws {
print("DEBUG: *** updateUserProfile called ***")
print("DEBUG: Full call stack:")
for (index, symbol) in Thread.callStackSymbols.enumerated() {
print("DEBUG: \(index): \(symbol)")
}
print("DEBUG: isUpdatingProfile: \(isUpdatingProfile)")
guard let apiService = apiService else {
print("DEBUG: updateUserProfile failed - no API service")
throw AuthenticationError.invalidCredentials
}
// Prevent concurrent profile updates
guard !isUpdatingProfile else {
print("DEBUG: updateUserProfile blocked - operation already in progress")
throw AuthenticationError.operationInProgress
}
isUpdatingProfile = true
defer {
print("DEBUG: *** updateUserProfile finished ***")
isUpdatingProfile = false
}
let settings = UserSettingsOptions(
fullName: fullName,
email: email,
description: description,
website: website,
location: location,
hideEmail: nil,
hideActivity: nil,
language: nil,
theme: nil,
diffViewStyle: nil
)
do {
let updatedUser = try await apiService.updateUserSettings(settings: settings)
await MainActor.run {
currentUser = updatedUser
}
} catch {
await MainActor.run {
errorMessage = error.localizedDescription
}
throw error
}
}
func updateCurrentUser(_ user: User) {
Task { @MainActor in
currentUser = user
}
}
func getAPIService() -> GiteaAPIService? {
return apiService
}
// MARK: - Private Methods
private func loadStoredCredentials() {
if let storedServerURL = getStoredServerURL() {
serverURL = storedServerURL
}
}
private func storeCredentials(serverURL: String, token: String) throws {
// Store token in Keychain
try storeTokenInKeychain(token)
// Store server URL in UserDefaults
UserDefaults.standard.set(serverURL, forKey: serverURLKey)
}
private func clearCredentials() {
// Remove token from Keychain
deleteTokenFromKeychain()
// Remove server URL from UserDefaults
UserDefaults.standard.removeObject(forKey: serverURLKey)
}
private func getStoredToken() -> String? {
return getTokenFromKeychain()
}
private func getStoredServerURL() -> String? {
return UserDefaults.standard.string(forKey: serverURLKey)
}
// MARK: - Keychain Operations
private func storeTokenInKeychain(_ token: String) throws {
let tokenData = token.data(using: .utf8)!
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: keychainService,
kSecAttrAccount as String: tokenKey,
kSecValueData as String: tokenData,
]
// Delete existing item if it exists
SecItemDelete(query as CFDictionary)
// Add new item
let status = SecItemAdd(query as CFDictionary, nil)
if status != errSecSuccess {
throw AuthenticationError.keychainError
}
}
private func getTokenFromKeychain() -> String? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: keychainService,
kSecAttrAccount as String: tokenKey,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne,
]
var dataTypeRef: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &dataTypeRef)
if status == errSecSuccess,
let data = dataTypeRef as? Data,
let token = String(data: data, encoding: .utf8)
{
return token
}
return nil
}
private func deleteTokenFromKeychain() {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: keychainService,
kSecAttrAccount as String: tokenKey,
]
SecItemDelete(query as CFDictionary)
}
}
// MARK: - Authentication Errors
enum AuthenticationError: LocalizedError {
case invalidServerURL
case invalidCredentials
case networkError
case keychainError
case operationInProgress
case unknownError
var errorDescription: String? {
switch self {
case .invalidServerURL:
return "Invalid server URL. Please check the URL format."
case .invalidCredentials:
return "Invalid credentials. Please check your token."
case .networkError:
return "Network error. Please check your connection."
case .keychainError:
return "Failed to store credentials securely."
case .operationInProgress:
return "A profile update is already in progress. Please wait."
case .unknownError:
return "An unknown error occurred."
}
}
}