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
+235
View File
@@ -0,0 +1,235 @@
import Foundation
/// Der einzige Weg der App zum Backend.
///
/// Ein Actor, weil Adresse und Marke sich zur Laufzeit ändern (an- und
/// abmelden) und von überall gelesen werden.
///
/// Zwei Anmeldewege, und zwar nacheinander, nicht nebeneinander:
/// Beim Anmelden setzt das Backend ein Sitzungs-Cookie, das `URLSession`
/// von selbst mitführt. Noch in dieser Sitzung holt sich die App eine
/// Gerätemarke und benutzt danach nur noch den Header `X-Certain-Token`.
/// Das Cookie wird dann weggeworfen – siehe `vergissCookies()`.
actor CertainClient {
private(set) var basis: URL
private(set) var marke: String?
private let sitzung: URLSession
private let decoder = JSONDecoder()
private let encoder = JSONEncoder()
init(basis: URL, marke: String? = nil) {
self.basis = basis
self.marke = marke
let einstellungen = URLSessionConfiguration.ephemeral
einstellungen.httpCookieStorage = HTTPCookieStorage.shared
einstellungen.httpShouldSetCookies = true
einstellungen.httpCookieAcceptPolicy = .always
// Alle /api-Antworten tragen `Cache-Control: no-store`; den lokalen
// Zwischenspeicher führen wir selbst (siehe `Zwischenspeicher`), weil
// wir ihn auch offline und mit Datumsangabe brauchen.
einstellungen.requestCachePolicy = .reloadIgnoringLocalCacheData
einstellungen.timeoutIntervalForRequest = 30
einstellungen.timeoutIntervalForResource = 120
einstellungen.waitsForConnectivity = false
sitzung = URLSession(configuration: einstellungen)
}
// MARK: - Zustand
func setzeBasis(_ neue: URL) {
basis = neue
}
func setzeMarke(_ neue: String?) {
marke = neue
}
/// Nach dem Holen der Marke: Das Cookie wird nicht mehr gebraucht und soll
/// nicht länger auf dem Gerät liegen als nötig.
func vergissCookies() {
guard let speicher = sitzung.configuration.httpCookieStorage else { return }
for keks in speicher.cookies(for: basis) ?? [] {
speicher.deleteCookie(keks)
}
}
// MARK: - Aufrufe
enum Methode: String, Sendable {
case get = "GET"
case post = "POST"
case patch = "PATCH"
case put = "PUT"
case delete = "DELETE"
}
/// Lesen.
func hole<A: Decodable>(_ pfad: String, abfrage: [URLQueryItem] = []) async throws -> A {
let daten = try await fuehreAus(.get, pfad, abfrage: abfrage, koerper: nil)
return try entschluessele(daten)
}
/// Schreiben ohne Nutzdaten (etwa `POST /uebertraege/suchen`).
@discardableResult
func schicke<A: Decodable>(_ methode: Methode, _ pfad: String,
abfrage: [URLQueryItem] = []) async throws -> A {
let daten = try await fuehreAus(methode, pfad, abfrage: abfrage, koerper: nil)
return try entschluessele(daten)
}
/// Schreiben mit JSON-Körper.
@discardableResult
func schicke<A: Decodable, K: Encodable>(_ methode: Methode, _ pfad: String,
koerper: K,
abfrage: [URLQueryItem] = []) async throws -> A {
let roh = try encoder.encode(koerper)
let daten = try await fuehreAus(methode, pfad, abfrage: abfrage, koerper: roh)
return try entschluessele(daten)
}
/// Für 204-Antworten: Es kommt nichts zurück, und das ist richtig so.
func schickeStumm(_ methode: Methode, _ pfad: String,
abfrage: [URLQueryItem] = []) async throws {
_ = try await fuehreAus(methode, pfad, abfrage: abfrage, koerper: nil)
}
func schickeStumm<K: Encodable>(_ methode: Methode, _ pfad: String,
koerper: K,
abfrage: [URLQueryItem] = []) async throws {
let roh = try encoder.encode(koerper)
_ = try await fuehreAus(methode, pfad, abfrage: abfrage, koerper: roh)
}
/// Die Anmeldung geht als Formular, nicht als JSON – das Backend nutzt
/// `OAuth2PasswordRequestForm` (`backend/app/routers/auth.py`).
func schickeFormular<A: Decodable>(_ pfad: String,
felder: [String: String]) async throws -> A {
var teile = URLComponents()
teile.queryItems = felder.map { URLQueryItem(name: $0.key, value: $0.value) }
// `+` muss als Zeichen durch, sonst wird daraus beim Server ein Leerzeichen.
let text = (teile.percentEncodedQuery ?? "").replacingOccurrences(of: "+", with: "%2B")
let daten = try await fuehreAus(
.post, pfad, abfrage: [], koerper: Data(text.utf8),
inhaltstyp: "application/x-www-form-urlencoded")
return try entschluessele(daten)
}
/// Datei hochladen (Logos, Kontoauszüge, Belege).
func schickeDatei<A: Decodable>(_ pfad: String, feldname: String = "file",
dateiname: String, inhalt: Data,
typ: String = "application/octet-stream",
weitereFelder: [String: String] = [],
abfrage: [URLQueryItem] = []) async throws -> A {
let grenze = "Grenze-\(UUID().uuidString)"
var koerper = Data()
func anhaengen(_ text: String) { koerper.append(Data(text.utf8)) }
for (schluessel, wert) in weitereFelder {
anhaengen("--\(grenze)\r\n")
anhaengen("Content-Disposition: form-data; name=\"\(schluessel)\"\r\n\r\n")
anhaengen("\(wert)\r\n")
}
anhaengen("--\(grenze)\r\n")
anhaengen("Content-Disposition: form-data; name=\"\(feldname)\"; filename=\"\(dateiname)\"\r\n")
anhaengen("Content-Type: \(typ)\r\n\r\n")
koerper.append(inhalt)
anhaengen("\r\n--\(grenze)--\r\n")
let daten = try await fuehreAus(
.post, pfad, abfrage: abfrage, koerper: koerper,
inhaltstyp: "multipart/form-data; boundary=\(grenze)")
return try entschluessele(daten)
}
/// Rohe Bytes holen – für Bilder (Abo-Logos) und Dateien.
func holeBytes(_ pfad: String, abfrage: [URLQueryItem] = []) async throws -> Data {
try await fuehreAus(.get, pfad, abfrage: abfrage, koerper: nil)
}
// MARK: - Innenleben
private func baueAdresse(_ pfad: String, _ abfrage: [URLQueryItem]) throws -> URL {
let voll = basis.appendingPathComponent("api").appendingPathComponent(
pfad.hasPrefix("/") ? String(pfad.dropFirst()) : pfad)
guard var teile = URLComponents(url: voll, resolvingAgainstBaseURL: false) else {
throw APIFehler.antwortUnlesbar(pfad)
}
// Leere Werte gar nicht erst mitschicken – das Backend liest "" sonst
// als gesetzten Filter.
let gefiltert = abfrage.filter { ($0.value ?? "").isEmpty == false }
teile.queryItems = gefiltert.isEmpty ? nil : gefiltert
guard let fertig = teile.url else { throw APIFehler.antwortUnlesbar(pfad) }
return fertig
}
private func fuehreAus(_ methode: Methode, _ pfad: String,
abfrage: [URLQueryItem], koerper: Data?,
inhaltstyp: String = "application/json") async throws -> Data {
var anfrage = URLRequest(url: try baueAdresse(pfad, abfrage))
anfrage.httpMethod = methode.rawValue
anfrage.setValue("application/json", forHTTPHeaderField: "Accept")
if let marke {
anfrage.setValue(marke, forHTTPHeaderField: "X-Certain-Token")
}
if let koerper {
anfrage.httpBody = koerper
anfrage.setValue(inhaltstyp, forHTTPHeaderField: "Content-Type")
}
let daten: Data
let antwort: URLResponse
do {
(daten, antwort) = try await sitzung.data(for: anfrage)
} catch let fehler as URLError {
throw APIFehler.keineVerbindung(fehler.localizedDescription)
} catch {
throw APIFehler.keineVerbindung(error.localizedDescription)
}
guard let http = antwort as? HTTPURLResponse else {
throw APIFehler.antwortUnlesbar("keine HTTP-Antwort")
}
guard (200..<300).contains(http.statusCode) else {
throw deute(status: http.statusCode, daten: daten)
}
return daten
}
private func deute(status: Int, daten: Data) -> APIFehler {
let text = Fehlerantwort.ausDaten(daten)
switch status {
case 401:
return .nichtAngemeldet
case 403:
if let pflicht = APIFehler.Pflicht(rawValue: text) {
return .pflichtOffen(pflicht)
}
return .verboten(text)
case 404:
return .nichtGefunden
case 409:
return .konflikt(text)
case 400, 422:
return .ungueltig(text)
default:
return .server(status: status, text: text)
}
}
private func entschluessele<A: Decodable>(_ daten: Data) throws -> A {
// 204 und andere leere Antworten: Wer hier `Leer` erwartet, bekommt es.
if daten.isEmpty, let leer = Leer() as? A { return leer }
do {
return try decoder.decode(A.self, from: daten)
} catch {
throw APIFehler.antwortUnlesbar(String(describing: error))
}
}
}
/// Platzhalter für Antworten ohne Inhalt.
struct Leer: Codable, Sendable {
init() {}
}