Der Start-Tab spiegelte bisher fest die Web-Dashboards - dieselbe gespeicherte Anordnung, und Diagramme gab es nur den Hinweis "im Web". Das war der Punkt, der geaergert hat. Jetzt ist der Modus je Server umschaltbar: "Web-Uebersicht spiegeln" (weiter Standard, damit nach dem Update nichts ueberrascht) oder "Eigene App-Uebersicht". Die eigene Uebersicht liegt nur auf dem Geraet (pro Server), unabhaengig vom Web und nicht synchronisiert. Beim ersten Umschalten entsteht gleich eine sinnvolle Startanordnung. Sie funktioniert wie im Web, nur fuers Telefon: mehrere Dashboards, Karten einspaltig untereinander, im Bearbeiten-Modus hinzufuegen, per Ziehen sortieren und loeschen; Artikelkarten bekommen ihren Artikel zugewiesen. Anzeigen und Bearbeiten sind getrennt - so laufen Sortieren und Loeschen sauber, statt an Sektionsgrenzen zu haken. Die Diagramme sind nativ mit Swift Charts: Ablauf- und Kategorien-Anteile (Ring ab iOS 17, sonst ein 100-%-Balken - das Ziel ist iOS 16, deshalb kein stilles Anheben), Kategorien nach Zustand als gestapelte Balken, Bestands- und Artikelverlauf als Linie, Ein-/Auslagerungen als gruppierte Balken. Dieselben Endpunkte wie das Web-Dashboard, kein Backend-Eingriff. Der "gibt es nur im Web"-Hinweis faellt damit weg - auch beim Spiegeln werden die Diagramme jetzt nativ gezeichnet. Geprueft: Build; alle vier Diagramm-Modelle gegen echte Serverantworten dekodiert (auch der Zeitstempel mit Z und Sekundenbruchteilen); refactorte DashboardCardView ohne alte Aufrufstellen. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
247 lines
9.4 KiB
Swift
247 lines
9.4 KiB
Swift
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()
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|