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
+126
View File
@@ -0,0 +1,126 @@
import Foundation
import Security
/// Wo die App ihre Einstellungen und ihr Geheimnis hinlegt.
///
/// Zwei getrennte Orte, mit Absicht: Die Server-Adresse ist keine geheime
/// Angabe und liegt in den Voreinstellungen. Die Gerätemarke ist ein
/// Vollzugang und gehört in den Schlüsselbund – dort überlebt sie kein
/// unverschlüsseltes Backup und keine Gerätesicherung ohne Passcode.
enum Ablage {
/// Setze das hier auf deine App-Gruppe, sobald es Erweiterungen gibt
/// (`group.<deine-bundle-id>`). Solange `nil`, benutzt die App ihren
/// eigenen Bereich – das genügt, bis Teilen-Menü und Kurzbefehle dazukommen.
static let gruppe: String? = nil
/// Dieselbe Gruppe für den Schlüsselbund. Erst nötig, wenn eine
/// Erweiterung an dieselbe Marke muss.
static let schluesselbundGruppe: String? = nil
private static var voreinstellungen: UserDefaults {
if let gruppe, let geteilt = UserDefaults(suiteName: gruppe) { return geteilt }
return .standard
}
private enum Schluessel {
static let serverAdresse = "certain.serverAdresse"
static let marke = "certain.marke"
static let benutzername = "certain.benutzername"
}
// MARK: - Server-Adresse
static var serverAdresse: URL? {
get {
guard let text = voreinstellungen.string(forKey: Schluessel.serverAdresse) else {
return nil
}
return URL(string: text)
}
set {
if let neue = newValue {
voreinstellungen.set(neue.absoluteString, forKey: Schluessel.serverAdresse)
} else {
voreinstellungen.removeObject(forKey: Schluessel.serverAdresse)
}
}
}
/// Nur zur Anzeige („angemeldet als …"), nicht zur Prüfung.
static var benutzername: String? {
get { voreinstellungen.string(forKey: Schluessel.benutzername) }
set {
if let neuer = newValue {
voreinstellungen.set(neuer, forKey: Schluessel.benutzername)
} else {
voreinstellungen.removeObject(forKey: Schluessel.benutzername)
}
}
}
// MARK: - Gerätemarke im Schlüsselbund
static var marke: String? {
get { schluesselbundLesen(Schluessel.marke) }
set {
if let neue = newValue {
schluesselbundSchreiben(Schluessel.marke, neue)
} else {
schluesselbundLoeschen(Schluessel.marke)
}
}
}
/// Alles vergessen – beim Abmelden und wenn der Server die Marke ablehnt.
static func raeumeAuf() {
marke = nil
benutzername = nil
serverAdresse = nil
Zwischenspeicher.leere()
}
// MARK: - Schlüsselbund, schmucklos
private static func grundlage(_ konto: String) -> [String: Any] {
var abfrage: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: "com.certain.app",
kSecAttrAccount as String: konto,
]
if let schluesselbundGruppe {
abfrage[kSecAttrAccessGroup as String] = schluesselbundGruppe
}
return abfrage
}
private static func schluesselbundLesen(_ konto: String) -> String? {
var abfrage = grundlage(konto)
abfrage[kSecReturnData as String] = true
abfrage[kSecMatchLimit as String] = kSecMatchLimitOne
var ergebnis: CFTypeRef?
let status = SecItemCopyMatching(abfrage as CFDictionary, &ergebnis)
guard status == errSecSuccess, let daten = ergebnis as? Data else { return nil }
return String(data: daten, encoding: .utf8)
}
private static func schluesselbundSchreiben(_ konto: String, _ wert: String) {
let abfrage = grundlage(konto)
let inhalt: [String: Any] = [
kSecValueData as String: Data(wert.utf8),
// Nicht in ein Backup und nicht auf ein anderes Gerät: Die Marke
// gehoert genau diesem Telefon.
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
]
let status = SecItemUpdate(abfrage as CFDictionary, inhalt as CFDictionary)
if status == errSecItemNotFound {
var neu = abfrage
neu.merge(inhalt) { _, zweites in zweites }
SecItemAdd(neu as CFDictionary, nil)
}
}
private static func schluesselbundLoeschen(_ konto: String) {
SecItemDelete(grundlage(konto) as CFDictionary)
}
}