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,79 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Benutzername und Passwort – einmal. Danach trägt die Gerätemarke, und
|
||||
/// das Passwort wird nirgends gespeichert.
|
||||
struct AnmeldeAnsicht: View {
|
||||
@Environment(AppZustand.self) private var zustand
|
||||
@State private var benutzer = Ablage.benutzername ?? ""
|
||||
@State private var passwort = ""
|
||||
@FocusState private var feld: Feld?
|
||||
|
||||
private enum Feld { case benutzer, passwort }
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section {
|
||||
TextField(String(localized: "anmeldung.benutzer"), text: $benutzer)
|
||||
.textContentType(.username)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
.focused($feld, equals: .benutzer)
|
||||
.submitLabel(.next)
|
||||
.onSubmit { feld = .passwort }
|
||||
|
||||
SecureField(String(localized: "anmeldung.passwort"), text: $passwort)
|
||||
.textContentType(.password)
|
||||
.focused($feld, equals: .passwort)
|
||||
.submitLabel(.go)
|
||||
.onSubmit { melde() }
|
||||
} footer: {
|
||||
Text(String(localized: "anmeldung.erklaerung"))
|
||||
}
|
||||
|
||||
if let meldung = zustand.meldung {
|
||||
Section {
|
||||
Label(meldung, systemImage: "exclamationmark.triangle")
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
Button(action: melde) {
|
||||
if zustand.beschaeftigt {
|
||||
ProgressView()
|
||||
} else {
|
||||
Text(String(localized: "anmeldung.anmelden"))
|
||||
}
|
||||
}
|
||||
.disabled(bereit == false || zustand.beschaeftigt)
|
||||
}
|
||||
|
||||
Section {
|
||||
Button(String(localized: "anmeldung.andererServer"), role: .destructive) {
|
||||
Task { await zustand.meldeAb(adresseBehalten: false) }
|
||||
}
|
||||
} footer: {
|
||||
if let adresse = zustand.serverAdresse {
|
||||
Text(String(format: String(localized: "anmeldung.aktuellerServer"),
|
||||
adresse.host() ?? adresse.absoluteString))
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle(String(localized: "anmeldung.titel"))
|
||||
.onAppear { feld = benutzer.isEmpty ? .benutzer : .passwort }
|
||||
}
|
||||
}
|
||||
|
||||
private var bereit: Bool {
|
||||
!benutzer.trimmingCharacters(in: .whitespaces).isEmpty && !passwort.isEmpty
|
||||
}
|
||||
|
||||
private func melde() {
|
||||
guard bereit else { return }
|
||||
let name = benutzer.trimmingCharacters(in: .whitespaces)
|
||||
let geheim = passwort
|
||||
passwort = ""
|
||||
Task { await zustand.melde(benutzer: name, passwort: geheim) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Pflicht-Passwortwechsel. Solange der offen ist, sperrt das Backend jeden
|
||||
/// fachlichen Endpunkt mit 403 – auch das Anlegen der Gerätemarke. Deshalb
|
||||
/// steht dieser Bildschirm vor allem anderen.
|
||||
///
|
||||
/// Die Richtlinie des Servers (Mindestlänge, Zeichenarten) prüfen wir hier
|
||||
/// nicht nach. Das Backend kennt sie, und sie doppelt zu pflegen hiesse, sie
|
||||
/// irgendwann unterschiedlich zu haben. Es meldet den Grund im Klartext.
|
||||
struct PasswortWechselAnsicht: View {
|
||||
@Environment(AppZustand.self) private var zustand
|
||||
@State private var alt = ""
|
||||
@State private var neu = ""
|
||||
@State private var wiederholung = ""
|
||||
@FocusState private var feld: Feld?
|
||||
|
||||
private enum Feld { case alt, neu, wiederholung }
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section {
|
||||
SecureField(String(localized: "passwort.bisher"), text: $alt)
|
||||
.textContentType(.password)
|
||||
.focused($feld, equals: .alt)
|
||||
.submitLabel(.next)
|
||||
.onSubmit { feld = .neu }
|
||||
} footer: {
|
||||
Text(String(localized: "passwort.erklaerung"))
|
||||
}
|
||||
|
||||
Section {
|
||||
SecureField(String(localized: "passwort.neu"), text: $neu)
|
||||
.textContentType(.newPassword)
|
||||
.focused($feld, equals: .neu)
|
||||
.submitLabel(.next)
|
||||
.onSubmit { feld = .wiederholung }
|
||||
|
||||
SecureField(String(localized: "passwort.wiederholen"), text: $wiederholung)
|
||||
.textContentType(.newPassword)
|
||||
.focused($feld, equals: .wiederholung)
|
||||
.submitLabel(.go)
|
||||
.onSubmit { wechsle() }
|
||||
} footer: {
|
||||
if !wiederholung.isEmpty, neu != wiederholung {
|
||||
Text(String(localized: "passwort.stimmtNichtUeberein"))
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
}
|
||||
|
||||
if let meldung = zustand.meldung {
|
||||
Section {
|
||||
Label(meldung, systemImage: "exclamationmark.triangle")
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
Button(action: wechsle) {
|
||||
if zustand.beschaeftigt {
|
||||
ProgressView()
|
||||
} else {
|
||||
Text(String(localized: "passwort.speichern"))
|
||||
}
|
||||
}
|
||||
.disabled(bereit == false || zustand.beschaeftigt)
|
||||
}
|
||||
|
||||
Section {
|
||||
Button(String(localized: "allgemein.abmelden"), role: .destructive) {
|
||||
Task { await zustand.meldeAb() }
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle(String(localized: "passwort.titel"))
|
||||
.onAppear { feld = .alt }
|
||||
}
|
||||
}
|
||||
|
||||
private var bereit: Bool {
|
||||
!alt.isEmpty && !neu.isEmpty && neu == wiederholung
|
||||
}
|
||||
|
||||
private func wechsle() {
|
||||
guard bereit else { return }
|
||||
let bisher = alt
|
||||
let frisch = neu
|
||||
Task {
|
||||
await zustand.wechslePasswort(alt: bisher, neu: frisch)
|
||||
alt = ""; neu = ""; wiederholung = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Der allererste Bildschirm: Wo steht dein Certain?
|
||||
///
|
||||
/// Die Adresse wird nicht blind übernommen, sondern mit `GET /api/version`
|
||||
/// geprüft. Das erspart die Sorte Fehlersuche, bei der man sich eine Stunde
|
||||
/// fragt, warum die Anmeldung nicht geht, und am Ende ein Buchstabe fehlte.
|
||||
struct ServerAnsicht: View {
|
||||
@Environment(AppZustand.self) private var zustand
|
||||
@State private var eingabe = ""
|
||||
@FocusState private var imFeld: Bool
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section {
|
||||
TextField(String(localized: "server.platzhalter"), text: $eingabe)
|
||||
.textContentType(.URL)
|
||||
.keyboardType(.URL)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
.focused($imFeld)
|
||||
.submitLabel(.go)
|
||||
.onSubmit { pruefe() }
|
||||
} header: {
|
||||
Text(String(localized: "server.ueberschrift"))
|
||||
} footer: {
|
||||
Text(String(localized: "server.erklaerung"))
|
||||
}
|
||||
|
||||
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: "server.verbinden"))
|
||||
}
|
||||
}
|
||||
.disabled(eingabe.trimmingCharacters(in: .whitespaces).isEmpty
|
||||
|| zustand.beschaeftigt)
|
||||
}
|
||||
}
|
||||
.navigationTitle(String(localized: "server.titel"))
|
||||
.onAppear { imFeld = true }
|
||||
}
|
||||
}
|
||||
|
||||
private func pruefe() {
|
||||
Task { _ = await zustand.pruefeUndMerkeServer(eingabe) }
|
||||
}
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user