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>
124 lines
4.9 KiB
Swift
124 lines
4.9 KiB
Swift
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"))
|
||
}
|
||
}
|
||
}
|