Neuer Ordner ios/ mit dem Geruest: Netzwerkschicht, Modelle, Offline-Ablage, Texte in Deutsch und Englisch, Navigation, der komplette Anmeldeweg (Server waehlen, anmelden, zweiter Faktor, Pflicht-Passwortwechsel) und als erste Fachansicht die Kontenliste. Die Anmeldung laeuft einmal ueber das Sitzungs-Cookie, holt sich dabei eine App-Marke und legt sie in den Schluesselbund. Danach traegt nur noch der Header. Kein Passwort auf dem Geraet, kein Ablauf nach 30 Tagen. Der Code ist nie uebersetzt worden - hier gibt es kein Xcode. Geprueft ist dafuer jedes Modellfeld gegen echte Antworten des laufenden Backends; dabei kam heraus, dass created_at ohne Zeitzone und mit Mikrosekunden kommt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
256 lines
9.0 KiB
Swift
256 lines
9.0 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?
|
||
|
||
/// 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()
|
||
einstellungen = (try? await client.einstellungen()) ?? Einstellungen()
|
||
lage = .bereit(ich)
|
||
} 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)
|
||
} 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 }
|
||
}
|
||
|
||
// 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
|
||
}
|
||
}
|