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
+98
View File
@@ -0,0 +1,98 @@
import Foundation
// Alle Pfade an einer Stelle. An der Aufrufstelle steht nie eine Zeichenkette –
// wer einen Endpunkt umbenennt, bricht genau hier und nicht in zwölf Ansichten.
//
// Stufe 1: Server prüfen, anmelden, Marke holen, Konten lesen.
extension CertainClient {
// MARK: - Server
/// Prüft, ob unter der Adresse überhaupt ein Certain läuft.
///
/// Wichtig: Das Backend liefert für jeden unbekannten Pfad ausserhalb von
/// `/api` die Weboberfläche mit Status 200 aus. `/api/version` kann das
/// nicht – nur ein echter Server antwortet hier mit dem erwarteten JSON.
func serverkennung() async throws -> Serverkennung {
let kennung: Serverkennung
do {
kennung = try await hole("version")
} catch APIFehler.antwortUnlesbar {
throw APIFehler.keinCertainServer
}
guard kennung.istCertain else { throw APIFehler.keinCertainServer }
return kennung
}
// MARK: - Anmeldung
/// Meldet an und bekommt ein Sitzungs-Cookie. Steht der zweite Faktor
/// aus, ist `status == "2fa_required"` und das Cookie gilt nur zehn
/// Minuten für genau diesen Schritt.
func melde(benutzer: String, passwort: String) async throws -> AnmeldeAntwort {
try await schickeFormular("auth/login",
felder: ["username": benutzer, "password": passwort])
}
func pruefeZweitenFaktor(code: String, wiederherstellung: Bool = false) async throws -> AnmeldeAntwort {
try await schicke(.post, "auth/2fa/verify",
koerper: ZweiterFaktorEingabe(code: code, recovery: wiederherstellung))
}
func ichSelbst() async throws -> Benutzer {
try await hole("auth/me")
}
/// Wechselt das Passwort. Beendet dabei alle anderen Sitzungen des Kontos
/// und setzt für den Aufrufer ein frisches Cookie.
@discardableResult
func wechslePasswort(alt: String, neu: String) async throws -> Benutzer {
try await schicke(.post, "auth/change-password",
koerper: PasswortWechsel(currentPassword: alt, newPassword: neu))
}
func meldeAb() async throws {
try await schickeStumm(.post, "auth/logout")
}
// MARK: - Gerätemarken
/// Holt die Marke, mit der die App von da an arbeitet. Geht nur aus einer
/// echten Anmeldung heraus – eine Marke darf keine weitere ausstellen.
func legeAppMarkeAn(name: String) async throws -> Marke {
try await schicke(.post, "inbox/tokens", koerper: MarkeAnlegen.fuerApp(name: name))
}
func marken() async throws -> [Marke] {
try await hole("inbox/tokens")
}
func widerrufeMarke(id: Int) async throws {
try await schickeStumm(.delete, "inbox/tokens/\(id)")
}
// MARK: - Einstellungen
func einstellungen() async throws -> Einstellungen {
let roh: [Einstellung] = try await hole("settings")
return Einstellungen(roh)
}
@discardableResult
func setzeEinstellung(_ schluessel: String, _ wert: String) async throws -> Einstellung {
// Query statt Körper – so will es das Backend (`routers/settings.py`).
try await schicke(.put, "settings/\(schluessel)",
abfrage: [URLQueryItem(name: "value", value: wert)])
}
// MARK: - Konten
func konten() async throws -> [Konto] {
try await hole("accounts")
}
func konto(_ id: Int) async throws -> Konto {
try await hole("accounts/\(id)")
}
}