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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
8a7545bf5b
commit
6b86a6bf91
@@ -0,0 +1,255 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct CertainApp: App {
|
||||
@State private var zustand = AppZustand()
|
||||
|
||||
var body: some Scene {
|
||||
// Das Erscheinungsbild kommt aus den Server-Einstellungen, damit App
|
||||
// und Weboberfläche gleich aussehen. „system" heisst: nichts vorgeben.
|
||||
let schema: ColorScheme?
|
||||
switch zustand.einstellungen.erscheinungsbild {
|
||||
case "light": schema = .light
|
||||
case "dark": schema = .dark
|
||||
default: schema = nil
|
||||
}
|
||||
|
||||
return WindowGroup {
|
||||
RootAnsicht()
|
||||
.environment(zustand)
|
||||
.task { await zustand.starte() }
|
||||
.preferredColorScheme(schema)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Welcher Bildschirm zu sehen ist, entscheidet allein `AppZustand.lage`.
|
||||
/// Jede Ansicht darunter darf davon ausgehen, dass es einen angemeldeten
|
||||
/// Client gibt.
|
||||
struct RootAnsicht: View {
|
||||
@Environment(AppZustand.self) private var zustand
|
||||
|
||||
var body: some View {
|
||||
switch zustand.lage {
|
||||
case .startet:
|
||||
ProgressView()
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
|
||||
case .brauchtServer:
|
||||
ServerAnsicht()
|
||||
|
||||
case .brauchtAnmeldung:
|
||||
AnmeldeAnsicht()
|
||||
|
||||
case .brauchtZweitenFaktor:
|
||||
ZweiFaktorAnsicht()
|
||||
|
||||
case .brauchtPasswortwechsel:
|
||||
PasswortWechselAnsicht()
|
||||
|
||||
case .brauchtZweitenFaktorEinrichtung:
|
||||
ZweitenFaktorEinrichtenHinweis()
|
||||
|
||||
case .bereit:
|
||||
Hauptnavigation()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Die Reiter. Auf dem iPhone unten, auf dem iPad als Seitenleiste – das
|
||||
/// erledigt `.sidebarAdaptable` von selbst.
|
||||
///
|
||||
/// Stufe 1 hat erst zwei Einträge. Die weiteren kommen hier dazu, die
|
||||
/// Anordnung bleibt.
|
||||
struct Hauptnavigation: View {
|
||||
var body: some View {
|
||||
TabView {
|
||||
Tab(String(localized: "reiter.konten"), systemImage: "wallet.bifold") {
|
||||
NavigationStack { KontenListe() }
|
||||
}
|
||||
Tab(String(localized: "reiter.einstellungen"), systemImage: "gearshape") {
|
||||
NavigationStack { EinstellungenAnsicht() }
|
||||
}
|
||||
}
|
||||
.tabViewStyle(.sidebarAdaptable)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user