Files
certain/ios/App/AppZustand.swift
T
ScarriffleandClaude Opus 5 6b86a6bf91 feat: Erste Stufe der iOS-App - Anmeldung und Kontenliste
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>
2026-10-20 16:20:03 +02:00

256 lines
9.0 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}
}