import Foundation import Security /// Haelt die bekannten Server und die Anmeldung am gerade aktiven. /// /// Jedes Profil hat ein eigenes Token im Keychain. Dadurch bleibt man an /// mehreren Servern gleichzeitig angemeldet und der Wechsel ist ein Tipp. /// Die Adressen liegen in den UserDefaults (nicht geheim), die Token im /// Keychain. final class Session: ObservableObject { static let shared = Session() private let stayKey = "stay_logged_in" // Schluessel aus der Zeit mit genau einem Server. Werden beim ersten Start // nach dem Update in ein Profil ueberfuehrt und danach entfernt. private let legacyURLKey = "server_url" private let legacyUsernameKey = "username" private let legacyAccount = "vorrania-token" @Published private(set) var profiles: [ServerProfile] = [] @Published private(set) var activeProfileID: UUID? @Published private(set) var token: String? /// Merkt die letzte Wahl, damit der Haken im Login richtig steht. @Published private(set) var stayLoggedIn: Bool = true var activeProfile: ServerProfile? { profiles.first { $0.id == activeProfileID } } var baseURL: URL? { activeProfile?.url } var username: String { activeProfile?.username ?? "" } var isAdmin: Bool { activeProfile?.isAdmin ?? false } var isLoggedIn: Bool { token != nil && baseURL != nil } private init() { // Ohne bisherige Wahl bleibt man angemeldet - das ist der Alltagsfall. stayLoggedIn = UserDefaults.standard.object(forKey: stayKey) as? Bool ?? true profiles = ProfileStore.load() migrateSingleServerIfNeeded() activeProfileID = ProfileStore.loadActiveID() ?? profiles.first?.id loadTokenForActiveProfile() } /// Uebernimmt eine Anmeldung aus der Zeit vor den Profilen. Ohne das waere /// man nach dem Update abgemeldet und muesste die Serveradresse neu tippen. private func migrateSingleServerIfNeeded() { guard profiles.isEmpty, let stored = UserDefaults.standard.string(forKey: legacyURLKey), !stored.isEmpty else { return } let name = ServerProfile.suggestedName(for: stored) let profile = ServerProfile( name: name, urlString: stored, username: UserDefaults.standard.string(forKey: legacyUsernameKey) ?? "" ) profiles = [profile] ProfileStore.save(profiles) ProfileStore.saveActiveID(profile.id) // Token auf das profilbezogene Konto umziehen. if let old = Keychain.read(account: legacyAccount) { Keychain.write(old, account: Session.keychainAccount(for: profile.id)) Keychain.delete(account: legacyAccount) } UserDefaults.standard.removeObject(forKey: legacyURLKey) UserDefaults.standard.removeObject(forKey: legacyUsernameKey) } static func keychainAccount(for id: UUID) -> String { "vorrania-token-\(id.uuidString)" } private func loadTokenForActiveProfile() { guard let id = activeProfileID else { token = nil; return } token = Keychain.read(account: Session.keychainAccount(for: id)) } /// Sorgt für eine URL mit Schema und abschließendem "/", damit relative /// Pfade ("api/...") korrekt aufgelöst werden. static func normalize(_ raw: String) -> URL? { var text = raw.trimmingCharacters(in: .whitespacesAndNewlines) guard !text.isEmpty else { return nil } if !text.contains("://") { text = "http://" + text } if !text.hasSuffix("/") { text += "/" } return URL(string: text) } // MARK: - Profile /// Legt ein Profil an und macht es zum aktiven. Ohne Namen wird der /// Rechnername genommen. @discardableResult func addProfile(name: String = "", urlString: String) -> ServerProfile? { guard Session.normalize(urlString) != nil else { return nil } let title = name.trimmingCharacters(in: .whitespacesAndNewlines) let profile = ServerProfile( name: title.isEmpty ? ServerProfile.suggestedName(for: urlString) : title, urlString: urlString ) profiles.append(profile) ProfileStore.save(profiles) switchTo(profile.id) return profile } func updateProfile(id: UUID, name: String? = nil, urlString: String? = nil) { guard let index = profiles.firstIndex(where: { $0.id == id }) else { return } if let name { let title = name.trimmingCharacters(in: .whitespacesAndNewlines) if !title.isEmpty { profiles[index].name = title } } if let urlString, Session.normalize(urlString) != nil { profiles[index].urlString = urlString } ProfileStore.save(profiles) } /// Entfernt das Profil samt Token. Das Geheimnis darf nicht zurueckbleiben, /// wenn der Server aus der Liste verschwindet. func removeProfile(id: UUID) { Keychain.delete(account: Session.keychainAccount(for: id)) NotificationStore.remove(for: id) AppDashboardStore.remove(for: id) profiles.removeAll { $0.id == id } ProfileStore.save(profiles) if activeProfileID == id { switchTo(profiles.first?.id) } } /// Wechselt den Server. Ist fuer das Ziel ein Token hinterlegt, ist man /// sofort angemeldet, sonst erscheint die Anmeldung fuer dieses Profil. func switchTo(_ id: UUID?) { activeProfileID = id ProfileStore.saveActiveID(id) loadTokenForActiveProfile() } /// Wechselt (falls vorhanden und noetig) auf das Profil, dessen Adresse zum /// Host eines Links passt – damit ein gescannter QR den richtigen Server /// abfragt, auch wenn gerade ein anderer aktiv ist. func switchToProfile(matching url: URL) { guard let host = url.host?.lowercased() else { return } if activeProfile?.url?.host?.lowercased() == host { return } if let match = profiles.first(where: { $0.url?.host?.lowercased() == host }) { switchTo(match.id) } } // MARK: - Anmeldung /// Legt die Adresse fuer die Anmeldung fest: aktualisiert das aktive Profil /// oder legt das erste an, wenn die App noch keinen Server kennt. func setServer(_ raw: String) { guard Session.normalize(raw) != nil else { return } if let id = activeProfileID, profiles.contains(where: { $0.id == id }) { updateProfile(id: id, urlString: raw) } else { addProfile(urlString: raw) } } /// `persist == false` heisst: die Anmeldung gilt nur, solange die App laeuft. /// Auf dem Geraet bleibt dann nichts zurueck. func store(token newToken: String, username name: String, isAdmin admin: Bool, persist: Bool) { token = newToken stayLoggedIn = persist UserDefaults.standard.set(persist, forKey: stayKey) guard let id = activeProfileID, let index = profiles.firstIndex(where: { $0.id == id }) else { return } profiles[index].isAdmin = admin profiles[index].username = persist ? name : "" ProfileStore.save(profiles) if persist { Keychain.write(newToken, account: Session.keychainAccount(for: id)) } else { Keychain.delete(account: Session.keychainAccount(for: id)) } } /// Meldet nur vom aktiven Server ab. Andere Profile behalten ihr Token. func logout() { token = nil guard let id = activeProfileID else { return } Keychain.delete(account: Session.keychainAccount(for: id)) if let index = profiles.firstIndex(where: { $0.id == id }) { profiles[index].username = "" profiles[index].isAdmin = false ProfileStore.save(profiles) } } /// Prueft nach einem Wechsel, ob das hinterlegte Token noch gilt, und holt /// die aktuelle Rolle. Ein abgelaufenes Token fuehrt zur Anmeldung. @MainActor func refreshMe() async { guard isLoggedIn, let id = activeProfileID else { return } do { let me = try await APIClient.shared.me() guard let index = profiles.firstIndex(where: { $0.id == id }) else { return } profiles[index].username = me.username profiles[index].isAdmin = me.role == "admin" ProfileStore.save(profiles) } catch APIError.unauthorized { logout() } catch { // Server gerade nicht erreichbar: angemeldet bleiben, damit ein // kurzer Netzausfall einen nicht aus der App wirft. } } } /// Minimaler Keychain-Zugriff für ein einzelnes Token. enum Keychain { private static let service = "com.scarriffle.vorrania" static func write(_ value: String, account: String) { delete(account: account) let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, kSecAttrAccount as String: account, kSecValueData as String: Data(value.utf8), kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock, ] SecItemAdd(query as CFDictionary, nil) } static func read(account: String) -> String? { let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, kSecAttrAccount as String: account, 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) } static func delete(account: String) { let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, kSecAttrAccount as String: account, ] SecItemDelete(query as CFDictionary) } }