338 lines
10 KiB
Swift
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."
|
|
}
|
|
}
|
|
}
|