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:
Scarriffle
2026-10-20 16:20:03 +02:00
co-authored by Claude Opus 5
parent 8a7545bf5b
commit 6b86a6bf91
25 changed files with 2908 additions and 0 deletions
+123
View File
@@ -0,0 +1,123 @@
import SwiftUI
/// Der zweite Faktor. Entweder sechs Ziffern aus der Authenticator-App oder
/// ein Wiederherstellungscode der Form `ABCDE-FGHIJ`.
///
/// Fünf Fehlversuche, dann ist die Zwischensitzung verbraucht und es geht
/// zurück zur Anmeldung – so hält es das Backend
/// (`backend/app/services/sessions.py`, `MAX_2FA_ATTEMPTS`).
struct ZweiFaktorAnsicht: View {
@Environment(AppZustand.self) private var zustand
@State private var code = ""
@State private var mitWiederherstellung = false
@FocusState private var imFeld: Bool
var body: some View {
NavigationStack {
Form {
Section {
TextField(platzhalter, text: $code)
.textContentType(mitWiederherstellung ? .none : .oneTimeCode)
.keyboardType(mitWiederherstellung ? .asciiCapable : .numberPad)
.textInputAutocapitalization(.characters)
.autocorrectionDisabled()
.monospaced()
.focused($imFeld)
.submitLabel(.go)
.onSubmit { pruefe() }
.onChange(of: code) { _, neu in
// Sechs Ziffern aus der App: direkt absenden, das
// spart den Griff zur Tastatur.
if !mitWiederherstellung, neu.count == 6, neu.allSatisfy(\.isNumber) {
pruefe()
}
}
} header: {
Text(String(localized: "zweifaktor.ueberschrift"))
} footer: {
Text(mitWiederherstellung
? String(localized: "zweifaktor.erklaerungWiederherstellung")
: String(localized: "zweifaktor.erklaerungCode"))
}
if let meldung = zustand.meldung {
Section {
Label(meldung, systemImage: "exclamationmark.triangle")
.foregroundStyle(.red)
}
}
Section {
Button(action: pruefe) {
if zustand.beschaeftigt {
ProgressView()
} else {
Text(String(localized: "zweifaktor.bestaetigen"))
}
}
.disabled(code.isEmpty || zustand.beschaeftigt)
Button(mitWiederherstellung
? String(localized: "zweifaktor.dochCode")
: String(localized: "zweifaktor.dochWiederherstellung")) {
mitWiederherstellung.toggle()
code = ""
imFeld = true
}
}
Section {
Button(String(localized: "zweifaktor.abbrechen"), role: .destructive) {
Task { await zustand.meldeAb() }
}
}
}
.navigationTitle(String(localized: "zweifaktor.titel"))
.onAppear { imFeld = true }
}
}
private var platzhalter: String {
mitWiederherstellung
? String(localized: "zweifaktor.platzhalterWiederherstellung")
: String(localized: "zweifaktor.platzhalterCode")
}
private func pruefe() {
let eingabe = code.trimmingCharacters(in: .whitespaces)
guard !eingabe.isEmpty else { return }
Task {
await zustand.pruefeZweitenFaktor(
code: eingabe, wiederherstellung: mitWiederherstellung)
code = ""
}
}
}
/// Die Richtlinie verlangt einen zweiten Faktor, das Konto hat keinen.
/// Einrichten geht in Stufe 1 noch nicht in der App – also sagen wir das
/// klar, statt den Nutzer gegen eine Wand laufen zu lassen.
struct ZweitenFaktorEinrichtenHinweis: View {
@Environment(AppZustand.self) private var zustand
var body: some View {
NavigationStack {
ContentUnavailableView {
Label(String(localized: "zweifaktorEinrichten.titel"),
systemImage: "lock.shield")
} description: {
Text(String(localized: "zweifaktorEinrichten.erklaerung"))
} actions: {
if let adresse = zustand.serverAdresse {
Link(String(localized: "zweifaktorEinrichten.imBrowser"),
destination: adresse)
.buttonStyle(.borderedProminent)
}
Button(String(localized: "allgemein.abmelden"), role: .destructive) {
Task { await zustand.meldeAb() }
}
}
.navigationTitle(String(localized: "zweifaktorEinrichten.titel"))
}
}
}