Files
certain/ios/Anmeldung/ZweiFaktorAnsicht.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

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