Files
certain/ios/Nebenraeume/ZweiterFaktorAnsicht.swift
T
GuidoandClaude Opus 5 d290930759 feat: Fuenfte Stufe der iOS-App - Kategorien, Schlagwoerter, Bank, zweiter Faktor
Die Nebenraeume haengen unter Einstellungen, nicht als eigene Reiter: Man
braucht sie selten, und eine Leiste mit zehn Reitern findet niemand mehr.

- Kategorien: anlegen, aendern, archivieren, loeschen - samt Gruppen. Eine
  Kategorie oeffnet ihre Erkennungswoerter, die sich dort hinzufuegen und
  entfernen lassen. Darunter steht, was sonst niemand merkt: Sie wirken nur
  auf kuenftige Importe.
- Woerter, die oft korrigiert werden, stehen als Pruefmeldung in den
  Einstellungen - behalten oder entfernen.
- Schlagwoerter: anlegen, umbenennen, umfaerben, loeschen. Neue bekommen
  reihum eine Farbe, damit nicht alle grau sind.
- Einstellungen: Erscheinungsbild, Hauptwaehrung und Budget-Starttag lassen
  sich jetzt aendern. Ohne Sichern-Knopf - es ist je Zeile genau ein Wert,
  und das Backend nimmt ihn einzeln entgegen. Dazu der Hinweis, dass sie dem
  Konto gehoeren und nicht dem Geraet.
- Bank: abrufen und Salden auffrischen. Verbinden bleibt am Rechner, die
  Bank leitet fest dorthin zurueck. Der letzte Fehler der Verbindung steht
  dabei, sonst ruft man vergeblich ab.
- Zweiter Faktor: einrichten in drei Schritten. Auf dem Telefon fuehrt der
  Weg ueber die otpauth-Adresse direkt in die Authenticator-App statt ueber
  einen QR-Code, den man sich selbst zeigen muesste. Die Rettungscodes
  erscheinen einmal; das Blatt laesst sich nicht wegwischen und erst
  schliessen, wenn man bestaetigt hat, sie gesichert zu haben.

Abschalten des zweiten Faktors bleibt bewusst draussen: Es verlangt das
Kontopasswort, und die App speichert keines. Ein Vollzugang auf diesem Geraet
soll den Schutz des Kontos nicht aufheben koennen.

59 Tests gruen. Texte in Deutsch und Englisch, zeilengleich.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-21 22:03:28 +02:00

191 lines
7.4 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
/// Den zweiten Faktor einrichten.
///
/// Bis Stufe 5 verwies die App dafür in den Browser. Drei Schritte:
/// Geheimnis anlegen, in die Authenticator-App übernehmen, mit einem Code
/// scharfschalten.
///
/// **Abschalten geht hier nicht**, und das ist Absicht: Es verlangt das
/// Kontopasswort, und die App speichert keines. Wer es abschalten will,
/// macht das am Rechner – ein Vollzugang auf diesem Gerät soll den Schutz
/// des Kontos nicht aufheben können.
struct ZweiterFaktorAnsicht: View {
@Environment(AppZustand.self) private var zustand
@State private var lader: Lader<ZweiterFaktorStand>?
@State private var vorgang = Speichervorgang()
@State private var einrichtung: ZweiterFaktorEinrichtung?
@State private var code = ""
@State private var rettungscodes: [String]?
var body: some View {
Group {
if let lader {
Inhalt(zustand: lader.zustand, nochmal: { Task { await lader.lade() } }) { stand in
inhalt(stand)
}
} else {
ProgressView()
}
}
.navigationTitle(String(localized: "zweifaktor.titel"))
.navigationBarTitleDisplayMode(.inline)
.task {
guard lader == nil, let client = zustand.client else { return }
let neuer = Lader<ZweiterFaktorStand>(schluessel: "auth/2fa/status") {
try await client.zweiterFaktorStand()
}
lader = neuer
await neuer.lade()
}
.sheet(item: Binding(get: { rettungscodes.map(Codeliste.init) },
set: { if $0 == nil { rettungscodes = nil } })) { liste in
RettungscodeBlatt(codes: liste.codes) {
rettungscodes = nil
Task { await lader?.aktualisiere() }
}
}
}
/// Nur da, um die Codes an `.sheet(item:)` zu geben.
private struct Codeliste: Identifiable {
let codes: [String]
var id: String { codes.joined() }
}
@ViewBuilder
private func inhalt(_ stand: ZweiterFaktorStand) -> some View {
Form {
Section {
LabeledContent(String(localized: "zweifaktor.stand")) {
Text(stand.enabled ? String(localized: "zweifaktor.aktiv")
: String(localized: "zweifaktor.inaktiv"))
.foregroundStyle(stand.enabled ? .green : .secondary)
}
if stand.enabled {
LabeledContent(String(localized: "zweifaktor.rettungscodes"),
value: "\(stand.recoveryCodesLeft)")
}
} footer: {
Text(stand.enabled ? String(localized: "zweifaktor.aktivErklaerung")
: String(localized: "zweifaktor.inaktivErklaerung"))
}
if !stand.enabled {
if let einrichtung {
Section {
// Auf dem Telefon selbst ist ein QR-Code sinnlos – die
// Authenticator-App liegt daneben. Der Link führt
// direkt hinein, das Geheimnis steht zum Abtippen da.
Link(destination: URL(string: einrichtung.otpauthUri)
?? URL(string: "https://example.invalid")!) {
Label(String(localized: "zweifaktor.inAppOeffnen"),
systemImage: "arrow.up.forward.app")
}
LabeledContent(String(localized: "zweifaktor.geheimnis")) {
Text(einrichtung.secret)
.font(.footnote.monospaced())
.textSelection(.enabled)
}
} header: {
Text(String(localized: "zweifaktor.schrittEins"))
} footer: {
Text(String(localized: "zweifaktor.geheimnisErklaerung"))
}
Section {
TextField(String(localized: "zweifaktor.code"), text: $code)
.keyboardType(.numberPad)
.textContentType(.oneTimeCode)
.monospaced()
Button(String(localized: "zweifaktor.aktivieren")) {
Task { await aktiviere() }
}
.disabled(code.count < 6 || vorgang.laeuft)
} header: {
Text(String(localized: "zweifaktor.schrittZwei"))
}
} else {
Section {
Button(String(localized: "zweifaktor.einrichten")) {
Task { await starte() }
}
.disabled(vorgang.laeuft)
}
}
}
if let fehler = vorgang.fehler {
Section { FehlerZeile(text: fehler) }
}
}
}
private func starte() async {
guard let client = zustand.client else { return }
var neu: ZweiterFaktorEinrichtung?
if await vorgang.fuehreAus({ neu = try await client.richteZweitenFaktorEin() }) {
einrichtung = neu
}
}
private func aktiviere() async {
guard let client = zustand.client else { return }
var codes: Rettungscodes?
let geglueckt = await vorgang.fuehreAus {
codes = try await client.aktiviereZweitenFaktor(code: code)
}
if geglueckt {
code = ""
einrichtung = nil
rettungscodes = codes?.codes
}
}
}
/// Die Rettungscodes – einmal zu sehen, danach nie wieder.
///
/// Deshalb lässt sich dieses Blatt nicht wegwischen: Wer es versehentlich
/// schliesst, bekommt neue nur mit dem Kontopasswort am Rechner.
private struct RettungscodeBlatt: View {
let codes: [String]
let fertig: () -> Void
@State private var abgehakt = false
var body: some View {
NavigationStack {
List {
Section {
ForEach(codes, id: \.self) { code in
Text(code)
.font(.body.monospaced())
.textSelection(.enabled)
}
} header: {
Text(String(localized: "zweifaktor.rettungscodes"))
} footer: {
Text(String(localized: "zweifaktor.rettungscodesErklaerung"))
}
Section {
ShareLink(item: codes.joined(separator: "\n")) {
Label(String(localized: "zweifaktor.codesSichern"),
systemImage: "square.and.arrow.up")
}
Toggle(String(localized: "zweifaktor.codesNotiert"), isOn: $abgehakt)
}
}
.navigationTitle(String(localized: "zweifaktor.aktiv"))
.navigationBarTitleDisplayMode(.inline)
.interactiveDismissDisabled()
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button(String(localized: "allgemein.fertig"), action: fertig)
.disabled(!abgehakt)
}
}
}
}
}