AppZustand faellt beim Start ohne Verbindung auf den gemerkten Benutzer zurueck - nur schrieb diesen Schluessel niemand je in den Zwischenspeicher. Der Zweig konnte also nie greifen, und die App verlangte im Flugmodus eine neue Anmeldung, obwohl die Geraetemarke laengst da war. Gefunden beim Durchsehen fuer Stufe 2: Nur Lader und das Nachschlagewerk schreiben in den Speicher, und auth/me geht an beiden vorbei. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
281 lines
10 KiB
Swift
281 lines
10 KiB
Swift
import Foundation
|
||
import Observation
|
||
#if canImport(UIKit)
|
||
import UIKit
|
||
#endif
|
||
|
||
/// Wo die App gerade steht – und damit, welcher Bildschirm zu sehen ist.
|
||
@MainActor
|
||
@Observable
|
||
final class AppZustand {
|
||
|
||
enum Lage: Equatable {
|
||
/// Beim Start: Gibt es eine Adresse, gibt es eine Marke, gilt sie noch?
|
||
case startet
|
||
/// Keine Server-Adresse hinterlegt.
|
||
case brauchtServer
|
||
/// Adresse steht, aber keine gültige Marke.
|
||
case brauchtAnmeldung
|
||
/// Passwort stimmt, der Code fehlt noch.
|
||
case brauchtZweitenFaktor
|
||
/// Das Konto muss erst sein Passwort wechseln.
|
||
case brauchtPasswortwechsel
|
||
/// Die Richtlinie verlangt einen zweiten Faktor, das Konto hat keinen.
|
||
/// Einrichten geht in Stufe 1 noch nicht in der App.
|
||
case brauchtZweitenFaktorEinrichtung
|
||
/// Alles gut.
|
||
case bereit(Benutzer)
|
||
}
|
||
|
||
private(set) var lage: Lage = .startet
|
||
private(set) var einstellungen = Einstellungen()
|
||
private(set) var client: CertainClient?
|
||
|
||
/// Kategorien, Schlagwörter und Konten – einmal geladen, von allen
|
||
/// Listen benutzt. Jede Zeile will zu einer ID den Namen, das Symbol und
|
||
/// die Farbe; das je Ansicht neu zu holen wäre genau das N+1, das das
|
||
/// Backend sich selbst verbietet.
|
||
private(set) var nachschlag = Nachschlag.leer
|
||
|
||
/// Die letzte Meldung für den Anmeldebildschirm. Keine Fehleransicht,
|
||
/// sondern eine Zeile unter dem Formular.
|
||
var meldung: String?
|
||
/// Läuft gerade ein Anmeldeschritt? Sperrt die Knöpfe.
|
||
private(set) var beschaeftigt = false
|
||
|
||
var serverAdresse: URL? { Ablage.serverAdresse }
|
||
|
||
// MARK: - Start
|
||
|
||
func starte() async {
|
||
guard let adresse = Ablage.serverAdresse else {
|
||
lage = .brauchtServer
|
||
return
|
||
}
|
||
let neuer = CertainClient(basis: adresse, marke: Ablage.marke)
|
||
client = neuer
|
||
guard Ablage.marke != nil else {
|
||
lage = .brauchtAnmeldung
|
||
return
|
||
}
|
||
await ladeEigenenZustand()
|
||
}
|
||
|
||
/// Fragt den Server, wer wir sind. Das ist zugleich die Prüfung, ob die
|
||
/// Marke noch gilt.
|
||
private func ladeEigenenZustand() async {
|
||
guard let client else { return }
|
||
do {
|
||
let ich = try await client.ichSelbst()
|
||
// Muss gemerkt werden, sonst greift der Offline-Zweig unten nie:
|
||
// Niemand sonst schreibt unter diesem Schlüssel.
|
||
Zwischenspeicher.lege(ich, unter: "auth/me")
|
||
einstellungen = (try? await client.einstellungen()) ?? Einstellungen()
|
||
lage = .bereit(ich)
|
||
await ladeNachschlag()
|
||
} catch APIFehler.nichtAngemeldet {
|
||
// Marke widerrufen oder gelöscht – neu anmelden, Adresse behalten.
|
||
Ablage.marke = nil
|
||
await client.setzeMarke(nil)
|
||
lage = .brauchtAnmeldung
|
||
} catch APIFehler.pflichtOffen(.passwortWechseln) {
|
||
lage = .brauchtPasswortwechsel
|
||
} catch APIFehler.pflichtOffen(.zweiterFaktor) {
|
||
lage = .brauchtZweitenFaktorEinrichtung
|
||
} catch {
|
||
// Offline beim Start: Wir lassen die App herein und zeigen in den
|
||
// Ansichten den letzten Stand. Die Marke ist ja da.
|
||
if let gemerkt = Zwischenspeicher.hole(Benutzer.self, unter: "auth/me") {
|
||
lage = .bereit(gemerkt.wert)
|
||
await ladeNachschlag()
|
||
} else {
|
||
meldung = error.localizedDescription
|
||
lage = .brauchtAnmeldung
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Server festlegen
|
||
|
||
/// Nimmt, was der Nutzer tippt, und macht daraus eine brauchbare Adresse.
|
||
/// `budget.example.com` wird zu `https://budget.example.com`.
|
||
///
|
||
/// `nonisolated`, weil es reine Textarbeit ist – so lässt es sich ohne
|
||
/// Hauptthread testen.
|
||
nonisolated static func deuteAdresse(_ eingabe: String) -> URL? {
|
||
var text = eingabe.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
guard !text.isEmpty else { return nil }
|
||
if !text.contains("://") { text = "https://" + text }
|
||
// Ein angehängter Pfad hilft nicht – die API hängt immer unter /api.
|
||
guard var teile = URLComponents(string: text), teile.host?.isEmpty == false else {
|
||
return nil
|
||
}
|
||
teile.path = ""
|
||
teile.query = nil
|
||
teile.fragment = nil
|
||
return teile.url
|
||
}
|
||
|
||
func pruefeUndMerkeServer(_ eingabe: String) async -> Bool {
|
||
guard let adresse = Self.deuteAdresse(eingabe) else {
|
||
meldung = String(localized: "server.keineAdresse")
|
||
return false
|
||
}
|
||
beschaeftigt = true
|
||
defer { beschaeftigt = false }
|
||
meldung = nil
|
||
|
||
let versuch = CertainClient(basis: adresse)
|
||
do {
|
||
_ = try await versuch.serverkennung()
|
||
Ablage.serverAdresse = adresse
|
||
client = versuch
|
||
lage = .brauchtAnmeldung
|
||
return true
|
||
} catch {
|
||
meldung = (error as? APIFehler)?.errorDescription ?? error.localizedDescription
|
||
return false
|
||
}
|
||
}
|
||
|
||
// MARK: - Anmelden
|
||
|
||
func melde(benutzer: String, passwort: String) async {
|
||
guard let client else { return }
|
||
beschaeftigt = true
|
||
defer { beschaeftigt = false }
|
||
meldung = nil
|
||
do {
|
||
let antwort = try await client.melde(benutzer: benutzer, passwort: passwort)
|
||
Ablage.benutzername = benutzer
|
||
if antwort.brauchtZweitenFaktor {
|
||
lage = .brauchtZweitenFaktor
|
||
return
|
||
}
|
||
await nachDerAnmeldung(antwort.user)
|
||
} catch {
|
||
meldung = (error as? APIFehler)?.errorDescription ?? error.localizedDescription
|
||
}
|
||
}
|
||
|
||
func pruefeZweitenFaktor(code: String, wiederherstellung: Bool) async {
|
||
guard let client else { return }
|
||
beschaeftigt = true
|
||
defer { beschaeftigt = false }
|
||
meldung = nil
|
||
do {
|
||
let antwort = try await client.pruefeZweitenFaktor(
|
||
code: code, wiederherstellung: wiederherstellung)
|
||
await nachDerAnmeldung(antwort.user)
|
||
} catch APIFehler.nichtAngemeldet {
|
||
// Zu viele Fehlversuche: Das Zwischen-Cookie ist weg.
|
||
meldung = String(localized: "zweifaktor.verbraucht")
|
||
lage = .brauchtAnmeldung
|
||
} catch {
|
||
meldung = (error as? APIFehler)?.errorDescription ?? error.localizedDescription
|
||
}
|
||
}
|
||
|
||
/// Nach geglückter Anmeldung: Marke holen und das Cookie wegwerfen.
|
||
private func nachDerAnmeldung(_ benutzer: Benutzer?) async {
|
||
guard let client else { return }
|
||
|
||
// Offene Pflichten zuerst – mit ihnen lässt sich keine Marke anlegen,
|
||
// das Backend sperrt den Endpunkt (`enforce_account_state`).
|
||
if let benutzer, benutzer.mustChangePassword {
|
||
lage = .brauchtPasswortwechsel
|
||
return
|
||
}
|
||
if let benutzer, benutzer.mfaSetupRequired {
|
||
lage = .brauchtZweitenFaktorEinrichtung
|
||
return
|
||
}
|
||
|
||
do {
|
||
let marke = try await client.legeAppMarkeAn(name: Self.geraetename)
|
||
guard let klartext = marke.token else {
|
||
meldung = String(localized: "anmeldung.markeOhneSchluessel")
|
||
return
|
||
}
|
||
Ablage.marke = klartext
|
||
await client.setzeMarke(klartext)
|
||
// Ab hier trägt die Marke. Das Cookie hat seinen Zweck erfüllt.
|
||
await client.vergissCookies()
|
||
await ladeEigenenZustand()
|
||
} catch {
|
||
meldung = (error as? APIFehler)?.errorDescription ?? error.localizedDescription
|
||
}
|
||
}
|
||
|
||
func wechslePasswort(alt: String, neu: String) async {
|
||
guard let client else { return }
|
||
beschaeftigt = true
|
||
defer { beschaeftigt = false }
|
||
meldung = nil
|
||
do {
|
||
let ich = try await client.wechslePasswort(alt: alt, neu: neu)
|
||
await nachDerAnmeldung(ich)
|
||
} catch {
|
||
meldung = (error as? APIFehler)?.errorDescription ?? error.localizedDescription
|
||
}
|
||
}
|
||
|
||
// MARK: - Abmelden
|
||
|
||
/// Meldet ab und räumt auf. Die Marke wird dabei am Server widerrufen –
|
||
/// sonst bliebe sie dort stehen, bis jemand sie von Hand entfernt.
|
||
func meldeAb(adresseBehalten: Bool = true) async {
|
||
if let client, Ablage.marke != nil {
|
||
// Welche der Marken unsere ist, lässt sich nicht am Klartext
|
||
// ablesen – am Server liegt nur der Hash. Also über den Namen,
|
||
// unter dem wir sie angelegt haben.
|
||
let meine = (try? await client.marken())?.first {
|
||
$0.scope == "app" && $0.name == Self.geraetename
|
||
}
|
||
if let meine { try? await client.widerrufeMarke(id: meine.id) }
|
||
}
|
||
let adresse = adresseBehalten ? Ablage.serverAdresse : nil
|
||
Ablage.raeumeAuf()
|
||
Ablage.serverAdresse = adresse
|
||
if let adresse {
|
||
client = CertainClient(basis: adresse)
|
||
lage = .brauchtAnmeldung
|
||
} else {
|
||
client = nil
|
||
lage = .brauchtServer
|
||
}
|
||
}
|
||
|
||
func aktualisiereEinstellungen() async {
|
||
guard let client else { return }
|
||
if let frisch = try? await client.einstellungen() { einstellungen = frisch }
|
||
}
|
||
|
||
/// Holt das Nachschlagewerk. Geht das schief, bleibt der letzte Stand –
|
||
/// eine Buchungsliste ohne Kategorienamen wäre schlechter als eine mit
|
||
/// alten.
|
||
func ladeNachschlag() async {
|
||
guard let client else { return }
|
||
if let frisch = try? await client.nachschlag() {
|
||
Zwischenspeicher.lege(frisch, unter: "nachschlag")
|
||
nachschlag = frisch
|
||
} else if nachschlag.gruppen.isEmpty,
|
||
let gemerkt = Zwischenspeicher.hole(Nachschlag.self, unter: "nachschlag") {
|
||
nachschlag = gemerkt.wert
|
||
}
|
||
}
|
||
|
||
// MARK: - Kleinkram
|
||
|
||
/// Der Name, unter dem die Marke am Rechner auftaucht. Soll erkennbar
|
||
/// machen, welches Gerät man widerruft.
|
||
static var geraetename: String {
|
||
#if canImport(UIKit)
|
||
let roh = UIDevice.current.name
|
||
return roh.isEmpty ? "iPhone" : roh
|
||
#else
|
||
return "iOS-Gerät"
|
||
#endif
|
||
}
|
||
}
|