Compare commits

...
2 Commits
Author SHA1 Message Date
ScarriffleandClaude Opus 5 6b86a6bf91 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>
2026-10-20 16:20:03 +02:00
ScarriffleandClaude Opus 5 8a7545bf5b feat: Eine Geraetemarke kann jetzt auch als Anmeldung fuer eine App gelten
Die API kannte zwei Zustaende: angemeldet per Sitzungs-Cookie, oder eine enge
Kurzbefehl-Marke, die nur erfassen darf. Eine native App passt in keinen davon
- sie kann kein Cookie-Formular ausfuellen und soll das Passwort nicht auf dem
Geraet halten.

Neu ist die Reichweite "app": Sie gilt im Header X-Certain-Token wie eine
Anmeldung, laeuft nicht nach 30 Tagen ab und laesst sich einzeln widerrufen.

Die enge Marke bleibt eng - das ist der Punkt, an dem die Tests am genauesten
hinsehen. Und eine App-Marke darf keine weitere ausstellen: Sonst stellte sich
ein Dieb Ersatzschluessel aus und das Widerrufen liefe ins Leere.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-20 16:06:41 +02:00
30 changed files with 3146 additions and 8 deletions
+57 -1
View File
@@ -1,9 +1,15 @@
"""Gemeinsame FastAPI-Abhängigkeiten: aktueller Benutzer (Cookie), Admin-Pflicht.
"""Gemeinsame FastAPI-Abhängigkeiten: aktueller Benutzer, Admin-Pflicht.
Die Anmeldung läuft über eine server-seitige Sitzung im Cookie (siehe
``services/sessions``). ``get_current_user`` liest das Cookie, prüft die Sitzung
und liefert den Benutzer – oder 401.
Zweiter Weg für die native App: eine Gerätemarke der Reichweite ``app`` im
Header ``X-Certain-Token``. Eine App kann kein Cookie-Formular ausfüllen und
soll nicht das Passwort auf dem Gerät halten. Die enge ``quick_add``-Marke
bleibt davon unberührt – sie gilt weiterhin nur dort, wo
``require_quick_token`` sie ausdrücklich verlangt.
``enforce_account_state`` hängt am zentralen ``/api``-Router und sperrt Benutzer
aus, die erst noch etwas erledigen müssen (Passwort wechseln, zweiten Faktor
einrichten). Bisher wurde das nur im Frontend umgeleitet – wer die API direkt
@@ -54,7 +60,57 @@ def _session_user(request: Request, db: Session) -> User | None:
return request.state.session_user
token = request.cookies.get(get_settings().cookie_name, "")
user = sessions.validate(db, token)
herkunft = "cookie"
if user is None:
# Die native App meldet sich mit ihrer Marke statt mit einem Cookie.
# Hier und nicht erst in ``get_current_user``, damit auch
# ``enforce_account_state`` sie sieht – sonst käme eine App an der
# Pflicht zum Passwortwechsel vorbei.
user = _app_token_user(request, db)
herkunft = "marke"
request.state.session_user = user
request.state.auth_via = herkunft if user is not None else None
return user
def _app_token_user(request: Request, db: Session) -> User | None:
"""Benutzer hinter einer App-Marke im Header ``X-Certain-Token``.
Nur die Reichweite ``app`` zählt hier. Eine ``quick_add``-Marke darf auf
diesem Weg NICHT zur Anmeldung werden – sonst wäre die bewusst enge Marke
still zu einem Vollzugang geworden.
"""
roh = (request.headers.get("X-Certain-Token") or "").strip()
if not roh:
return None
marke = (
db.query(ApiToken)
.filter(ApiToken.token_hash == hash_session_token(roh),
ApiToken.scope == TokenScope.app)
.first()
)
if marke is None:
return None
besitzer = db.get(User, marke.owner_id)
if besitzer is None or not besitzer.is_active:
return None
marke.last_used_at = datetime.now(timezone.utc)
db.commit()
return besitzer
def require_cookie_session(request: Request, db: Session = Depends(get_db)) -> User:
"""Wie ``get_current_user``, aber NUR mit echter Anmeldung.
Für Schritte, die eine Marke nicht auslösen darf – allen voran das
Ausstellen weiterer Marken.
"""
user = get_current_user(request, db)
if getattr(request.state, "auth_via", None) != "cookie":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Dafür ist eine richtige Anmeldung nötig, keine Gerätemarke",
)
return user
+14 -4
View File
@@ -813,14 +813,24 @@ class CategorizationRule(Base):
class TokenScope(str, enum.Enum):
"""Was eine Gerätemarke darf. Bewusst eng gehalten.
"""Was eine Gerätemarke darf.
Eine Marke liegt auf dem Telefon, oft in einem Kurzbefehl, den man teilt.
Sie darf deshalb nur erfassen, niemals lesen: Geht das Gerät verloren, ist
kein Datenabfluss möglich.
``quick_add`` ist die enge Form und die Voreinstellung. Eine solche Marke
liegt auf dem Telefon, oft in einem Kurzbefehl, den man teilt. Sie darf
deshalb nur erfassen, niemals lesen: Geht das Gerät verloren, ist kein
Datenabfluss möglich.
``app`` ist die Marke der nativen App und gilt wie eine Anmeldung – eine
App muss alles können, was die Weboberfläche kann, und die API kennt
sonst nur das Sitzungs-Cookie. Der Klartext liegt allein im Schlüsselbund
des Geräts; widerrufen wird die Marke in den Einstellungen. Vergeben darf
sie nur eine echte Anmeldung, nie eine Marke selbst: Sonst hätte ein Dieb
sich beliebig viele Ersatzschlüssel ausgestellt und das Widerrufen wäre
wirkungslos.
"""
quick_add = "quick_add"
app = "app"
class ApiToken(Base):
+9 -3
View File
@@ -10,7 +10,7 @@ from pydantic import BaseModel
from sqlalchemy.orm import Session
from ..database import get_db
from ..deps import get_current_user, get_owned
from ..deps import get_current_user, get_owned, require_cookie_session
from ..models import (
Account,
ApiToken,
@@ -45,9 +45,14 @@ def list_tokens(db: Session = Depends(get_db), user: User = Depends(get_current_
def create_token(
payload: ApiTokenCreate,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
user: User = Depends(require_cookie_session),
):
"""Legt eine Marke an. Der Klartext ist NUR in dieser Antwort zu sehen."""
"""Legt eine Marke an. Der Klartext ist NUR in dieser Antwort zu sehen.
Bewusst ``require_cookie_session``: Eine App-Marke darf keine weitere
ausstellen. Sonst stellte ein Dieb sich Ersatzschlüssel aus und das
Widerrufen der gestohlenen Marke liefe ins Leere.
"""
if payload.default_account_id is not None:
get_owned(db, Account, payload.default_account_id, user)
klartext = generate_session_token()
@@ -55,6 +60,7 @@ def create_token(
owner_id=user.id,
name=payload.name.strip(),
token_hash=hash_session_token(klartext),
scope=payload.scope,
default_account_id=payload.default_account_id,
)
db.add(marke)
+5
View File
@@ -15,6 +15,7 @@ from .models import (
IdentifierKind,
ImportStatus,
Role,
TokenScope,
TxnType,
)
@@ -687,11 +688,15 @@ class FlaggedKeywordOut(BaseModel):
class ApiTokenCreate(BaseModel):
name: str = Field(min_length=1, max_length=80)
default_account_id: int | None = None
# Voreinstellung bleibt die enge Marke: Wer nichts angibt, bekommt keinen
# Vollzugang.
scope: TokenScope = TokenScope.quick_add
class ApiTokenOut(BaseModel):
id: int
name: str
scope: TokenScope
default_account_id: int | None
last_used_at: datetime | None
created_at: datetime | None
+153
View File
@@ -0,0 +1,153 @@
"""Die Geraetemarke der nativen App.
Die API kannte bisher nur zwei Zustaende: angemeldet per Sitzungs-Cookie, oder
eine enge Kurzbefehl-Marke, die ausschliesslich erfassen darf. Eine native App
passt in keinen davon - sie kann kein Cookie-Formular ausfuellen und soll das
Passwort nicht auf dem Geraet halten.
Deshalb die Reichweite ``app``: gilt wie eine Anmeldung, liegt im
Schluesselbund, ist einzeln widerrufbar. Die enge Marke bleibt eng - das ist
der Punkt, an dem diese Datei am genauesten hinsieht.
"""
import secrets
def _marke(client, name=None, scope=None):
koerper = {"name": name or f"Geraet {secrets.token_hex(3)}"}
if scope is not None:
koerper["scope"] = scope
r = client.post("/api/inbox/tokens", json=koerper)
assert r.status_code == 201, r.text
return r.json()
def _mit(token):
return {"X-Certain-Token": token}
def test_app_marke_darf_lesen_und_schreiben(admin):
"""Der ganze Zweck: Mit der Marke allein, ohne Cookie, muss die App
arbeiten koennen."""
from app.main import app as fastapi_app
from fastapi.testclient import TestClient
marke = _marke(admin, scope="app")
assert marke["scope"] == "app"
konto = admin.post("/api/accounts", json={
"name": f"Marke {secrets.token_hex(3)}", "type": "girokonto",
"currency": "CHF", "opening_balance_cents": 0}).json()
# Ein frischer Client OHNE Cookie - nur der Header.
roh = TestClient(fastapi_app)
r = roh.get("/api/accounts", headers=_mit(marke["token"]))
assert r.status_code == 200, r.text
assert konto["id"] in {a["id"] for a in r.json()}
# Lesen reicht nicht, schreiben muss auch gehen.
r = roh.post("/api/transactions", headers=_mit(marke["token"]), json={
"type": "expense", "date": "2026-07-14", "account_id": konto["id"],
"amount_cents": 1234, "payee": "Mit Marke gebucht"})
assert r.status_code == 201, r.text
# Und die Budgetansicht, der eigentliche Grund fuer die App.
assert roh.get("/api/budget/current", headers=_mit(marke["token"])).status_code == 200
def test_kurzbefehl_marke_bleibt_eng(admin):
"""Der wichtigste Test hier. Die alte Marke darf durch die neue Pruefung
NICHT still zu einem Vollzugang geworden sein."""
from app.main import app as fastapi_app
from fastapi.testclient import TestClient
marke = _marke(admin) # ohne scope -> quick_add
assert marke["scope"] == "quick_add"
roh = TestClient(fastapi_app)
# Ihr eigener Endpunkt geht weiterhin.
assert roh.get("/api/quick/accounts", headers=_mit(marke["token"])).status_code == 200
# Alles andere nicht.
for pfad in ("/api/accounts", "/api/transactions", "/api/budget/current",
"/api/subscriptions", "/api/categories"):
r = roh.get(pfad, headers=_mit(marke["token"]))
assert r.status_code == 401, f"{pfad} war mit einer quick_add-Marke erreichbar"
def test_ohne_marke_und_mit_falscher_marke_kommt_niemand_herein(admin):
from app.main import app as fastapi_app
from fastapi.testclient import TestClient
roh = TestClient(fastapi_app)
assert roh.get("/api/accounts").status_code == 401
assert roh.get("/api/accounts", headers=_mit("gibtesnicht")).status_code == 401
assert roh.get("/api/accounts", headers=_mit("")).status_code == 401
def test_fremde_marke_sieht_nur_ihre_eigenen_daten(admin):
"""Mandanten-Trennung gilt auch auf dem Marken-Weg."""
from conftest import login_ready, new_user
from app.main import app as fastapi_app
from fastapi.testclient import TestClient
meins = admin.post("/api/accounts", json={
"name": f"Admin-Konto {secrets.token_hex(3)}", "type": "girokonto",
"currency": "CHF", "opening_balance_cents": 0}).json()
anderer, _ = login_ready(*new_user(admin))
fremde_marke = _marke(anderer, scope="app")
roh = TestClient(fastapi_app)
r = roh.get("/api/accounts", headers=_mit(fremde_marke["token"]))
assert r.status_code == 200
assert meins["id"] not in {a["id"] for a in r.json()}
assert roh.get(f"/api/accounts/{meins['id']}",
headers=_mit(fremde_marke["token"])).status_code == 404
def test_eine_app_marke_stellt_keine_weitere_aus(admin):
"""Sonst stellte sich ein Dieb Ersatzschluessel aus und das Widerrufen der
gestohlenen Marke liefe ins Leere."""
from app.main import app as fastapi_app
from fastapi.testclient import TestClient
marke = _marke(admin, scope="app")
roh = TestClient(fastapi_app)
r = roh.post("/api/inbox/tokens", headers=_mit(marke["token"]),
json={"name": "Ersatzschluessel", "scope": "app"})
assert r.status_code == 403, r.text
# Auch keine enge Marke.
assert roh.post("/api/inbox/tokens", headers=_mit(marke["token"]),
json={"name": "Auch nicht"}).status_code == 403
# Lesen der eigenen Markenliste bleibt erlaubt.
assert roh.get("/api/inbox/tokens", headers=_mit(marke["token"])).status_code == 200
def test_widerrufen_sperrt_sofort(admin):
from app.main import app as fastapi_app
from fastapi.testclient import TestClient
marke = _marke(admin, scope="app")
roh = TestClient(fastapi_app)
assert roh.get("/api/accounts", headers=_mit(marke["token"])).status_code == 200
assert admin.delete(f"/api/inbox/tokens/{marke['id']}").status_code == 204
assert roh.get("/api/accounts", headers=_mit(marke["token"])).status_code == 401
def test_marke_merkt_sich_wann_sie_zuletzt_benutzt_wurde(admin):
"""Damit man im Nachhinein sieht, welches Geraet noch laeuft - und welches
man bedenkenlos widerrufen kann."""
from app.main import app as fastapi_app
from fastapi.testclient import TestClient
marke = _marke(admin, scope="app")
assert marke["last_used_at"] is None
TestClient(fastapi_app).get("/api/accounts", headers=_mit(marke["token"]))
danach = next(t for t in admin.get("/api/inbox/tokens").json() if t["id"] == marke["id"])
assert danach["last_used_at"] is not None
+79
View File
@@ -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 = ""
}
}
}
+58
View File
@@ -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) }
}
}
+123
View File
@@ -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"))
}
}
}
+255
View File
@@ -0,0 +1,255 @@
import Foundation
import Observation
#if canImport(UIKit)
import UIKit
#endif
/// Wo die App gerade steht – und damit, welcher Bildschirm zu sehen ist.
@MainActor
@Observable
final class AppZustand {
enum Lage: Equatable {
/// Beim Start: Gibt es eine Adresse, gibt es eine Marke, gilt sie noch?
case startet
/// Keine Server-Adresse hinterlegt.
case brauchtServer
/// Adresse steht, aber keine gültige Marke.
case brauchtAnmeldung
/// Passwort stimmt, der Code fehlt noch.
case brauchtZweitenFaktor
/// Das Konto muss erst sein Passwort wechseln.
case brauchtPasswortwechsel
/// Die Richtlinie verlangt einen zweiten Faktor, das Konto hat keinen.
/// Einrichten geht in Stufe 1 noch nicht in der App.
case brauchtZweitenFaktorEinrichtung
/// Alles gut.
case bereit(Benutzer)
}
private(set) var lage: Lage = .startet
private(set) var einstellungen = Einstellungen()
private(set) var client: CertainClient?
/// Die letzte Meldung für den Anmeldebildschirm. Keine Fehleransicht,
/// sondern eine Zeile unter dem Formular.
var meldung: String?
/// Läuft gerade ein Anmeldeschritt? Sperrt die Knöpfe.
private(set) var beschaeftigt = false
var serverAdresse: URL? { Ablage.serverAdresse }
// MARK: - Start
func starte() async {
guard let adresse = Ablage.serverAdresse else {
lage = .brauchtServer
return
}
let neuer = CertainClient(basis: adresse, marke: Ablage.marke)
client = neuer
guard Ablage.marke != nil else {
lage = .brauchtAnmeldung
return
}
await ladeEigenenZustand()
}
/// Fragt den Server, wer wir sind. Das ist zugleich die Prüfung, ob die
/// Marke noch gilt.
private func ladeEigenenZustand() async {
guard let client else { return }
do {
let ich = try await client.ichSelbst()
einstellungen = (try? await client.einstellungen()) ?? Einstellungen()
lage = .bereit(ich)
} catch APIFehler.nichtAngemeldet {
// Marke widerrufen oder gelöscht – neu anmelden, Adresse behalten.
Ablage.marke = nil
await client.setzeMarke(nil)
lage = .brauchtAnmeldung
} catch APIFehler.pflichtOffen(.passwortWechseln) {
lage = .brauchtPasswortwechsel
} catch APIFehler.pflichtOffen(.zweiterFaktor) {
lage = .brauchtZweitenFaktorEinrichtung
} catch {
// Offline beim Start: Wir lassen die App herein und zeigen in den
// Ansichten den letzten Stand. Die Marke ist ja da.
if let gemerkt = Zwischenspeicher.hole(Benutzer.self, unter: "auth/me") {
lage = .bereit(gemerkt.wert)
} else {
meldung = error.localizedDescription
lage = .brauchtAnmeldung
}
}
}
// MARK: - Server festlegen
/// Nimmt, was der Nutzer tippt, und macht daraus eine brauchbare Adresse.
/// `budget.example.com` wird zu `https://budget.example.com`.
///
/// `nonisolated`, weil es reine Textarbeit ist – so lässt es sich ohne
/// Hauptthread testen.
nonisolated static func deuteAdresse(_ eingabe: String) -> URL? {
var text = eingabe.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty else { return nil }
if !text.contains("://") { text = "https://" + text }
// Ein angehängter Pfad hilft nicht – die API hängt immer unter /api.
guard var teile = URLComponents(string: text), teile.host?.isEmpty == false else {
return nil
}
teile.path = ""
teile.query = nil
teile.fragment = nil
return teile.url
}
func pruefeUndMerkeServer(_ eingabe: String) async -> Bool {
guard let adresse = Self.deuteAdresse(eingabe) else {
meldung = String(localized: "server.keineAdresse")
return false
}
beschaeftigt = true
defer { beschaeftigt = false }
meldung = nil
let versuch = CertainClient(basis: adresse)
do {
_ = try await versuch.serverkennung()
Ablage.serverAdresse = adresse
client = versuch
lage = .brauchtAnmeldung
return true
} catch {
meldung = (error as? APIFehler)?.errorDescription ?? error.localizedDescription
return false
}
}
// MARK: - Anmelden
func melde(benutzer: String, passwort: String) async {
guard let client else { return }
beschaeftigt = true
defer { beschaeftigt = false }
meldung = nil
do {
let antwort = try await client.melde(benutzer: benutzer, passwort: passwort)
Ablage.benutzername = benutzer
if antwort.brauchtZweitenFaktor {
lage = .brauchtZweitenFaktor
return
}
await nachDerAnmeldung(antwort.user)
} catch {
meldung = (error as? APIFehler)?.errorDescription ?? error.localizedDescription
}
}
func pruefeZweitenFaktor(code: String, wiederherstellung: Bool) async {
guard let client else { return }
beschaeftigt = true
defer { beschaeftigt = false }
meldung = nil
do {
let antwort = try await client.pruefeZweitenFaktor(
code: code, wiederherstellung: wiederherstellung)
await nachDerAnmeldung(antwort.user)
} catch APIFehler.nichtAngemeldet {
// Zu viele Fehlversuche: Das Zwischen-Cookie ist weg.
meldung = String(localized: "zweifaktor.verbraucht")
lage = .brauchtAnmeldung
} catch {
meldung = (error as? APIFehler)?.errorDescription ?? error.localizedDescription
}
}
/// Nach geglückter Anmeldung: Marke holen und das Cookie wegwerfen.
private func nachDerAnmeldung(_ benutzer: Benutzer?) async {
guard let client else { return }
// Offene Pflichten zuerst – mit ihnen lässt sich keine Marke anlegen,
// das Backend sperrt den Endpunkt (`enforce_account_state`).
if let benutzer, benutzer.mustChangePassword {
lage = .brauchtPasswortwechsel
return
}
if let benutzer, benutzer.mfaSetupRequired {
lage = .brauchtZweitenFaktorEinrichtung
return
}
do {
let marke = try await client.legeAppMarkeAn(name: Self.geraetename)
guard let klartext = marke.token else {
meldung = String(localized: "anmeldung.markeOhneSchluessel")
return
}
Ablage.marke = klartext
await client.setzeMarke(klartext)
// Ab hier trägt die Marke. Das Cookie hat seinen Zweck erfüllt.
await client.vergissCookies()
await ladeEigenenZustand()
} catch {
meldung = (error as? APIFehler)?.errorDescription ?? error.localizedDescription
}
}
func wechslePasswort(alt: String, neu: String) async {
guard let client else { return }
beschaeftigt = true
defer { beschaeftigt = false }
meldung = nil
do {
let ich = try await client.wechslePasswort(alt: alt, neu: neu)
await nachDerAnmeldung(ich)
} catch {
meldung = (error as? APIFehler)?.errorDescription ?? error.localizedDescription
}
}
// MARK: - Abmelden
/// Meldet ab und räumt auf. Die Marke wird dabei am Server widerrufen –
/// sonst bliebe sie dort stehen, bis jemand sie von Hand entfernt.
func meldeAb(adresseBehalten: Bool = true) async {
if let client, Ablage.marke != nil {
// Welche der Marken unsere ist, lässt sich nicht am Klartext
// ablesen – am Server liegt nur der Hash. Also über den Namen,
// unter dem wir sie angelegt haben.
let meine = (try? await client.marken())?.first {
$0.scope == "app" && $0.name == Self.geraetename
}
if let meine { try? await client.widerrufeMarke(id: meine.id) }
}
let adresse = adresseBehalten ? Ablage.serverAdresse : nil
Ablage.raeumeAuf()
Ablage.serverAdresse = adresse
if let adresse {
client = CertainClient(basis: adresse)
lage = .brauchtAnmeldung
} else {
client = nil
lage = .brauchtServer
}
}
func aktualisiereEinstellungen() async {
guard let client else { return }
if let frisch = try? await client.einstellungen() { einstellungen = frisch }
}
// MARK: - Kleinkram
/// Der Name, unter dem die Marke am Rechner auftaucht. Soll erkennbar
/// machen, welches Gerät man widerruft.
static var geraetename: String {
#if canImport(UIKit)
let roh = UIDevice.current.name
return roh.isEmpty ? "iPhone" : roh
#else
return "iOS-Gerät"
#endif
}
}
+24
View File
@@ -0,0 +1,24 @@
import SwiftUI
@main
struct CertainApp: App {
@State private var zustand = AppZustand()
var body: some Scene {
// Das Erscheinungsbild kommt aus den Server-Einstellungen, damit App
// und Weboberfläche gleich aussehen. „system" heisst: nichts vorgeben.
let schema: ColorScheme?
switch zustand.einstellungen.erscheinungsbild {
case "light": schema = .light
case "dark": schema = .dark
default: schema = nil
}
return WindowGroup {
RootAnsicht()
.environment(zustand)
.task { await zustand.starte() }
.preferredColorScheme(schema)
}
}
}
+53
View File
@@ -0,0 +1,53 @@
import SwiftUI
/// Welcher Bildschirm zu sehen ist, entscheidet allein `AppZustand.lage`.
/// Jede Ansicht darunter darf davon ausgehen, dass es einen angemeldeten
/// Client gibt.
struct RootAnsicht: View {
@Environment(AppZustand.self) private var zustand
var body: some View {
switch zustand.lage {
case .startet:
ProgressView()
.frame(maxWidth: .infinity, maxHeight: .infinity)
case .brauchtServer:
ServerAnsicht()
case .brauchtAnmeldung:
AnmeldeAnsicht()
case .brauchtZweitenFaktor:
ZweiFaktorAnsicht()
case .brauchtPasswortwechsel:
PasswortWechselAnsicht()
case .brauchtZweitenFaktorEinrichtung:
ZweitenFaktorEinrichtenHinweis()
case .bereit:
Hauptnavigation()
}
}
}
/// Die Reiter. Auf dem iPhone unten, auf dem iPad als Seitenleiste – das
/// erledigt `.sidebarAdaptable` von selbst.
///
/// Stufe 1 hat erst zwei Einträge. Die weiteren kommen hier dazu, die
/// Anordnung bleibt.
struct Hauptnavigation: View {
var body: some View {
TabView {
Tab(String(localized: "reiter.konten"), systemImage: "wallet.bifold") {
NavigationStack { KontenListe() }
}
Tab(String(localized: "reiter.einstellungen"), systemImage: "gearshape") {
NavigationStack { EinstellungenAnsicht() }
}
}
.tabViewStyle(.sidebarAdaptable)
}
}
+109
View File
@@ -0,0 +1,109 @@
import SwiftUI
extension Color {
/// Die Farben aus der Datenbank sind Hex-Zeichenketten wie `#3f51b5`.
/// Alles, was nicht passt, wird zum neutralen Grau – eine kaputte Farbe
/// darf keine Ansicht leer lassen.
init(hex: String) {
var text = hex.trimmingCharacters(in: .whitespacesAndNewlines)
if text.hasPrefix("#") { text.removeFirst() }
// Das Backend erlaubt acht Stellen (#rrggbbaa); die Durchsicht nehmen
// wir mit, der Rest wird ignoriert.
guard text.count == 6 || text.count == 8,
let zahl = UInt64(text, radix: 16) else {
self = Color(red: 0.42, green: 0.45, blue: 0.50)
return
}
let hatAlpha = text.count == 8
let r = Double((zahl >> (hatAlpha ? 24 : 16)) & 0xFF) / 255
let g = Double((zahl >> (hatAlpha ? 16 : 8)) & 0xFF) / 255
let b = Double((zahl >> (hatAlpha ? 8 : 0)) & 0xFF) / 255
let a = hatAlpha ? Double(zahl & 0xFF) / 255 : 1
self = Color(.sRGB, red: r, green: g, blue: b, opacity: a)
}
}
/// Übersetzt die Symbolnamen aus der Datenbank in SF Symbols.
///
/// Die Weboberfläche benutzt Lucide-Namen und hält dafür ein eigenes
/// SVG-Verzeichnis (`web/src/components/Icon.jsx`, knapp 600 Zeilen). Nativ
/// wäre das Unsinn: iOS bringt die Symbole mit, sie passen zur Systemschrift
/// und können fett, farbig und in jeder Grösse. Nur die Namen muss jemand
/// zuordnen – das ist diese Tabelle.
///
/// Die Liste deckt `ACCOUNT_ICONS` und `CATEGORY_ICONS` aus
/// `web/src/components/IconPicker.jsx` ab. Unbekanntes wird zum Platzhalter,
/// nie zu einer leeren Stelle.
enum Symbole {
static let platzhalter = "circle.dashed"
private static let tabelle: [String: String] = [
// Konten
"wallet": "wallet.bifold", "landmark": "building.columns",
"piggy-bank": "banknote", "vault": "lock.square.stack",
"credit-card": "creditcard", "trending-up": "chart.line.uptrend.xyaxis",
"banknote": "banknote", "coins": "centsign.circle",
// Wohnen
"home": "house", "bed": "bed.double", "zap": "bolt",
"droplet": "drop", "flame": "flame", "wifi": "wifi",
"monitor": "display", "cloud": "cloud",
// Alltag
"shopping-cart": "cart", "shopping-bag": "bag", "store": "storefront",
"utensils": "fork.knife", "coffee": "cup.and.saucer",
"wine": "wineglass", "shirt": "tshirt", "scissors": "scissors",
"sparkle": "sparkles",
// Unterwegs
"car": "car", "fuel": "fuelpump", "bus": "bus", "bike": "bicycle",
"plane": "airplane", "ticket": "ticket",
// Gesundheit
"heart-pulse": "heart.text.square", "pill": "pills",
"dumbbell": "figure.strengthtraining.traditional",
// Freizeit
"film": "film", "music": "music.note", "book-open": "book",
"newspaper": "newspaper", "graduation-cap": "graduationcap",
"paw-print": "pawprint",
// Familie
"sprout": "leaf", "cake": "birthday.cake", "gift": "gift",
"baby": "figure.and.child.holdinghands", "heart": "heart",
// Vorsorge
"umbrella": "umbrella", "life-buoy": "lifepreserver",
"shield": "shield", "scale": "scalemass",
// Sonstiges
"briefcase": "briefcase", "wrench": "wrench.adjustable",
"hammer": "hammer", "phone": "phone", "refresh-ccw": "arrow.triangle.2.circlepath",
"percent": "percent", "receipt": "receipt",
"list": "list.bullet", "more-horizontal": "ellipsis",
// In der Oberfläche benutzt, nicht in der Auswahl
"tags": "tag", "inbox": "tray", "settings": "gearshape",
"arrow-left-right": "arrow.left.arrow.right", "pie-chart": "chart.pie",
"dashboard": "square.grid.2x2", "download": "square.and.arrow.down",
"calendar": "calendar", "search": "magnifyingglass",
"edit": "pencil", "trash": "trash", "plus": "plus", "check": "checkmark",
"close": "xmark", "chevron-down": "chevron.down",
]
static func sf(_ name: String?) -> String {
guard let name, let treffer = tabelle[name] else { return platzhalter }
return treffer
}
}
/// Ein farbiges Symbolplättchen – das native Gegenstück zum `IconChip` im Web.
struct Symbolplakette: View {
let symbol: String?
let farbe: String
var groesse: CGFloat = 34
var body: some View {
let farbwert = Color(hex: farbe)
RoundedRectangle(cornerRadius: groesse * 0.28, style: .continuous)
.fill(farbwert.opacity(0.16))
.frame(width: groesse, height: groesse)
.overlay {
Image(systemName: Symbole.sf(symbol))
.font(.system(size: groesse * 0.46, weight: .medium))
.foregroundStyle(farbwert)
}
.accessibilityHidden(true)
}
}
+118
View File
@@ -0,0 +1,118 @@
import Foundation
/// Geld, so wie das Backend es führt: ganze Cent plus Währungscode.
///
/// Nirgends Fliesskomma. Die API kennt ausschliesslich Cent-Ganzzahlen
/// (einzige Ausnahme ist der Kurzbefehl-Endpunkt), und das bleibt hier so –
/// ein `Double` hätte bei Summen über viele Buchungen Rundungsfehler.
struct Geldbetrag: Hashable, Sendable {
let cents: Int
let waehrung: String
init(cents: Int, waehrung: String) {
self.cents = cents
self.waehrung = waehrung.uppercased()
}
var dezimal: Decimal { Decimal(cents) / Decimal(100) }
var istNull: Bool { cents == 0 }
var istNegativ: Bool { cents < 0 }
/// Für die Anzeige, in der Sprache des Geräts.
///
/// `vorzeichen: .immer` schreibt auch bei positiven Beträgen ein Plus –
/// das braucht die Buchungsliste, wo Einnahme und Ausgabe nebeneinander
/// stehen und das Vorzeichen die einzige Unterscheidung ist.
func formatiert(vorzeichen: Vorzeichenstil = .nurMinus) -> String {
let wert = vorzeichen == .ohne ? abs(dezimal) : dezimal
var text = wert.formatted(.currency(code: waehrung).locale(.autoupdatingCurrent))
if vorzeichen == .immer, cents > 0 {
text = "+" + text
}
return text
}
enum Vorzeichenstil: Sendable {
/// Minus bei negativen Beträgen, sonst nichts. Der Normalfall.
case nurMinus
/// Auch bei positiven ein Plus.
case immer
/// Immer ohne – wenn die Richtung schon woanders steht.
case ohne
}
/// Ohne Nachkommastellen, für enge Stellen wie Diagramm-Achsen.
var gerundet: String {
(dezimal as NSDecimalNumber).doubleValue.formatted(
.currency(code: waehrung).precision(.fractionLength(0)))
}
static func + (links: Geldbetrag, rechts: Geldbetrag) -> Geldbetrag {
precondition(links.waehrung == rechts.waehrung,
"Betraege verschiedener Waehrungen lassen sich nicht addieren")
return Geldbetrag(cents: links.cents + rechts.cents, waehrung: links.waehrung)
}
static prefix func - (betrag: Geldbetrag) -> Geldbetrag {
Geldbetrag(cents: -betrag.cents, waehrung: betrag.waehrung)
}
}
extension Geldbetrag {
/// Was der Nutzer tippt, in Cent.
///
/// Nimmt Komma und Punkt als Dezimaltrenner – auf einer deutschen Tastatur
/// tippt man „12,50", auf der Zehnertastatur „12.50", und beides meint
/// dasselbe. Tausendertrenner und Währungszeichen fliegen raus.
static func ausEingabe(_ eingabe: String, waehrung: String) -> Geldbetrag? {
var text = eingabe.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty else { return nil }
let negativ = text.hasPrefix("-")
text = text.filter { $0.isNumber || $0 == "," || $0 == "." }
// Ohne eine einzige Ziffer ist das keine Zahl, sondern ein Vertipper.
// Sonst käme aus "abc" ein Betrag von 0,00 heraus.
guard text.contains(where: \.isNumber) else { return nil }
// Steht beides drin, ist das hintere der Dezimaltrenner und das
// vordere der Tausendertrenner ("1.234,50" wie "1,234.50").
let letztesKomma = text.lastIndex(of: ",")
let letzterPunkt = text.lastIndex(of: ".")
let trenner: Character?
switch (letztesKomma, letzterPunkt) {
case (nil, nil): trenner = nil
case (_?, nil): trenner = ","
case (nil, _?): trenner = "."
case (let komma?, let punkt?): trenner = komma > punkt ? "," : "."
}
var ganze = text
var bruch = ""
if let trenner, let stelle = text.lastIndex(of: trenner) {
ganze = String(text[text.startIndex..<stelle])
bruch = String(text[text.index(after: stelle)...])
}
ganze = ganze.filter(\.isNumber)
bruch = bruch.filter(\.isNumber)
// Mehr als zwei Nachkommastellen war keine Dezimalangabe, sondern ein
// Tausendertrenner ("1.234" sind 1234, nicht 1.23).
if bruch.count > 2 {
ganze += bruch
bruch = ""
}
bruch = String((bruch + "00").prefix(2))
guard let vorne = Int(ganze.isEmpty ? "0" : ganze), let hinten = Int(bruch) else {
return nil
}
let summe = vorne * 100 + hinten
return Geldbetrag(cents: negativ ? -summe : summe, waehrung: waehrung)
}
/// Zum Vorbelegen eines Eingabefelds: „12.50", ohne Währung und Trenner.
var alsEingabe: String {
let betrag = abs(cents)
return "\(betrag / 100),\(String(format: "%02d", betrag % 100))"
}
}
+94
View File
@@ -0,0 +1,94 @@
import SwiftUI
/// Der Hinweis über einer Liste, deren Daten aus dem Zwischenspeicher kommen.
///
/// Wichtiger als er aussieht: Ohne ihn zeigt die App im Flugmodus alte Zahlen,
/// die aussehen wie frische. Mit Datum, weil „offline" allein nicht sagt, ob
/// der Stand von vor fünf Minuten oder von vor drei Wochen ist.
struct StandHinweis: View {
let geholtAm: Date
var body: some View {
Label {
Text(String(format: String(localized: "stand.hinweis"),
geholtAm.formatted(.relative(presentation: .named))))
} icon: {
Image(systemName: "wifi.slash")
}
.font(.footnote)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal)
.padding(.vertical, 6)
.background(.quaternary.opacity(0.4))
}
}
/// Wenn gar nichts da ist: Fehler zeigen und, wo es Sinn ergibt, einen
/// Knopf zum Nochmalversuchen.
struct FehlerAnsicht: View {
let fehler: APIFehler
var nochmal: (() -> Void)?
var body: some View {
ContentUnavailableView {
Label(String(localized: "fehler.titel"), systemImage: symbol)
} description: {
Text(fehler.errorDescription ?? String(localized: "fehler.unbekannt"))
} actions: {
if let nochmal, fehler.lohntWiederholung {
Button(String(localized: "allgemein.nochmal"), action: nochmal)
.buttonStyle(.borderedProminent)
}
}
}
private var symbol: String {
switch fehler {
case .keineVerbindung: return "wifi.slash"
case .nichtAngemeldet: return "person.crop.circle.badge.exclamationmark"
case .nichtGefunden: return "questionmark.folder"
default: return "exclamationmark.triangle"
}
}
}
/// Eine Liste, die noch nichts enthält – aber nicht wegen eines Fehlers.
struct LeerAnsicht: View {
let titel: String
let erklaerung: String
var symbol: String = "tray"
var body: some View {
ContentUnavailableView {
Label(titel, systemImage: symbol)
} description: {
Text(erklaerung)
}
}
}
/// Setzt die drei Zustände für eine Liste zusammen, damit das nicht jede
/// Ansicht neu baut.
struct Inhalt<A: Sendable, Darstellung: View>: View {
let zustand: Ladezustand<A>
var nochmal: (() -> Void)?
@ViewBuilder let darstellung: (A) -> Darstellung
var body: some View {
switch zustand {
case .laedt:
ProgressView()
.frame(maxWidth: .infinity, maxHeight: .infinity)
case .da(let wert):
darstellung(wert)
case .ausSpeicher(let wert, let wann, _):
VStack(spacing: 0) {
StandHinweis(geholtAm: wann)
darstellung(wert)
}
case .fehler(let fehler):
FehlerAnsicht(fehler: fehler, nochmal: nochmal)
}
}
}
@@ -0,0 +1,52 @@
import SwiftUI
/// Stufe 1 zeigt hier nur, woran man ist, und lässt abmelden. Die echten
/// Einstellungen (Hauptwährung, Budget-Starttag, Sprache, Gerätemarken)
/// kommen in Stufe 5.
struct EinstellungenAnsicht: View {
@Environment(AppZustand.self) private var zustand
@State private var zeigeAbmeldefrage = false
var body: some View {
Form {
Section {
if case .bereit(let benutzer) = zustand.lage {
LabeledContent(String(localized: "einstellungen.angemeldetAls"),
value: benutzer.anzeigename)
}
if let adresse = zustand.serverAdresse {
LabeledContent(String(localized: "einstellungen.server"),
value: adresse.host() ?? adresse.absoluteString)
}
} header: {
Text(String(localized: "einstellungen.zugang"))
} footer: {
Text(String(localized: "einstellungen.markeErklaerung"))
}
Section(String(localized: "einstellungen.ausDemServer")) {
LabeledContent(String(localized: "einstellungen.hauptwaehrung"),
value: zustand.einstellungen.basiswaehrung)
LabeledContent(String(localized: "einstellungen.budgetStarttag"),
value: "\(zustand.einstellungen.budgetStarttag)")
}
Section {
Button(String(localized: "allgemein.abmelden"), role: .destructive) {
zeigeAbmeldefrage = true
}
} footer: {
Text(String(localized: "einstellungen.abmeldenErklaerung"))
}
}
.navigationTitle(String(localized: "reiter.einstellungen"))
.refreshable { await zustand.aktualisiereEinstellungen() }
.confirmationDialog(String(localized: "einstellungen.abmeldenFrage"),
isPresented: $zeigeAbmeldefrage, titleVisibility: .visible) {
Button(String(localized: "allgemein.abmelden"), role: .destructive) {
Task { await zustand.meldeAb() }
}
Button(String(localized: "allgemein.abbrechen"), role: .cancel) {}
}
}
}
+155
View File
@@ -0,0 +1,155 @@
import SwiftUI
/// Die Kontenübersicht – die erste Fachansicht und zugleich die Probe, ob
/// Netzwerk, Modelle, Zwischenspeicher und Geldformatierung zusammenpassen.
struct KontenListe: View {
@Environment(AppZustand.self) private var zustand
@State private var lader: Lader<[Konto]>?
@State private var zeigeArchivierte = false
var body: some View {
Group {
if let lader {
Inhalt(zustand: lader.zustand, nochmal: { Task { await lader.lade() } }) { konten in
liste(konten)
}
} else {
ProgressView()
}
}
.navigationTitle(String(localized: "konten.titel"))
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Menu {
Toggle(String(localized: "konten.archivierteZeigen"),
isOn: $zeigeArchivierte)
} label: {
Image(systemName: "line.3.horizontal.decrease.circle")
}
}
}
.task {
guard lader == nil, let client = zustand.client else { return }
let neuer = Lader<[Konto]>(schluessel: "accounts") {
try await client.konten()
}
lader = neuer
await neuer.lade()
}
}
@ViewBuilder
private func liste(_ alle: [Konto]) -> some View {
let sichtbar = alle.filter { zeigeArchivierte || !$0.isArchived }
if sichtbar.isEmpty {
LeerAnsicht(titel: String(localized: "konten.leerTitel"),
erklaerung: String(localized: "konten.leerErklaerung"),
symbol: "wallet.bifold")
} else {
List {
Section {
ForEach(sichtbar) { konto in
KontoZeile(konto: konto)
}
} footer: {
Gesamtsumme(konten: sichtbar,
waehrung: zustand.einstellungen.basiswaehrung)
}
}
.listStyle(.insetGrouped)
.refreshable { await lader?.aktualisiere() }
}
}
}
private struct KontoZeile: View {
let konto: Konto
var body: some View {
HStack(spacing: 12) {
Symbolplakette(symbol: konto.icon, farbe: konto.color, groesse: 36)
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 6) {
Text(konto.name)
.font(.body)
.lineLimit(1)
if konto.isLocked {
// „Gebundenes Geld": zählt nicht zum verfügbaren Betrag.
Image(systemName: "lock.fill")
.font(.caption2)
.foregroundStyle(.secondary)
.accessibilityLabel(String(localized: "konten.gebunden"))
}
if konto.isArchived {
Text(String(localized: "konten.archiviert"))
.font(.caption2)
.padding(.horizontal, 6)
.padding(.vertical, 1)
.background(.quaternary, in: Capsule())
}
}
Text(konto.type.name)
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer(minLength: 8)
VStack(alignment: .trailing, spacing: 2) {
Text(konto.saldo.formatiert())
.font(.body.monospacedDigit())
.foregroundStyle(konto.balanceCents < 0 ? .red : .primary)
if konto.weichtVonDerBankAb, let bank = konto.bankBalanceCents {
// Nur melden, nicht korrigieren – welcher Wert stimmt,
// entscheidet der Mensch.
Label(Geldbetrag(cents: bank, waehrung: konto.currency).formatiert(),
systemImage: "exclamationmark.triangle.fill")
.font(.caption2)
.foregroundStyle(.orange)
.accessibilityLabel(String(localized: "konten.bankWeichtAb"))
}
}
}
.padding(.vertical, 2)
}
}
/// Die Summe unten. Gebundene und archivierte Konten bleiben aussen vor –
/// genauso rechnet das Dashboard im Backend (`routers/dashboard.py`).
private struct Gesamtsumme: View {
let konten: [Konto]
let waehrung: String
var body: some View {
let verfuegbar = konten
.filter { !$0.isLocked && !$0.isArchived }
.reduce(0) { $0 + $1.balanceBaseCents }
let gebunden = konten
.filter { $0.isLocked && !$0.isArchived }
.reduce(0) { $0 + $1.balanceBaseCents }
VStack(alignment: .leading, spacing: 4) {
HStack {
Text(String(localized: "konten.verfuegbar"))
Spacer()
Text(Geldbetrag(cents: verfuegbar, waehrung: waehrung).formatiert())
.monospacedDigit()
}
.font(.callout.weight(.medium))
.foregroundStyle(.primary)
if gebunden != 0 {
HStack {
Text(String(localized: "konten.gebundenSumme"))
Spacer()
Text(Geldbetrag(cents: gebunden, waehrung: waehrung).formatiert())
.monospacedDigit()
}
.font(.caption)
}
}
.padding(.top, 6)
}
}
+145
View File
@@ -0,0 +1,145 @@
import Foundation
// Die Typen hier bilden die Antwortschemas aus `backend/app/schemas.py` nach.
// Feldnamen stimmen eins zu eins überein – deshalb braucht keiner von ihnen
// `CodingKeys`. Wer ein Feld umbenennt, bricht genau hier.
/// `GET /api/version` – die Prüfung, ob unter einer Adresse ein Certain läuft.
struct Serverkennung: Codable, Sendable {
let name: String
let version: String
let gitSha: String?
private enum CodingKeys: String, CodingKey {
case name, version
case gitSha = "git_sha"
}
/// Der Name, den ein echter Server trägt (`backend/app/branding.py`).
var istCertain: Bool { name.localizedCaseInsensitiveContains("certain") }
}
/// `UserOut`
struct Benutzer: Codable, Sendable, Identifiable, Hashable {
let id: Int
let username: String
let role: Rolle
let displayName: String?
let isActive: Bool
let mustChangePassword: Bool
let mustChangePasswordReason: String?
let totpEnabled: Bool
let createdAt: Zeitpunkt
let mfaSetupRequired: Bool
private enum CodingKeys: String, CodingKey {
case id, username, role
case displayName = "display_name"
case isActive = "is_active"
case mustChangePassword = "must_change_password"
case mustChangePasswordReason = "must_change_password_reason"
case totpEnabled = "totp_enabled"
case createdAt = "created_at"
case mfaSetupRequired = "mfa_setup_required"
}
enum Rolle: String, Codable, Sendable {
case admin, user
}
var anzeigename: String { displayName?.isEmpty == false ? displayName! : username }
}
/// `LoginOut` – `status` ist `"ok"` oder `"2fa_required"`.
struct AnmeldeAntwort: Codable, Sendable {
let status: String
let user: Benutzer?
var brauchtZweitenFaktor: Bool { status == "2fa_required" }
}
/// `TwoFactorVerify`
struct ZweiterFaktorEingabe: Codable, Sendable {
let code: String
let recovery: Bool
}
/// `PasswordChange`
struct PasswortWechsel: Codable, Sendable {
let currentPassword: String
let newPassword: String
private enum CodingKeys: String, CodingKey {
case currentPassword = "current_password"
case newPassword = "new_password"
}
}
/// `ApiTokenCreate`
struct MarkeAnlegen: Codable, Sendable {
let name: String
let scope: String
let defaultAccountId: Int?
private enum CodingKeys: String, CodingKey {
case name, scope
case defaultAccountId = "default_account_id"
}
/// Die Marke, mit der sich die App anmeldet.
static func fuerApp(name: String) -> MarkeAnlegen {
MarkeAnlegen(name: name, scope: "app", defaultAccountId: nil)
}
}
/// `ApiTokenOut` beziehungsweise `ApiTokenCreated`.
/// `token` trägt den Klartext – und zwar genau einmal, beim Anlegen.
struct Marke: Codable, Sendable, Identifiable, Hashable {
let id: Int
let name: String
let scope: String
let defaultAccountId: Int?
let lastUsedAt: Zeitpunkt?
let createdAt: Zeitpunkt?
let token: String?
private enum CodingKeys: String, CodingKey {
case id, name, scope, token
case defaultAccountId = "default_account_id"
case lastUsedAt = "last_used_at"
case createdAt = "created_at"
}
}
/// Ein Eintrag aus `GET /api/settings`. Werte sind immer Zeichenketten.
struct Einstellung: Codable, Sendable, Hashable {
let key: String
let value: String
}
/// Die Einstellungen als benutzbares Ganzes.
struct Einstellungen: Sendable, Hashable {
var basiswaehrung: String = "EUR"
var budgetStarttag: Int = 1
var sprache: String = "de"
var erscheinungsbild: String = "system"
var datumsformat: String = "de"
var aktiveWaehrungen: [String] = ["EUR", "CHF", "USD"]
init() {}
init(_ eintraege: [Einstellung]) {
var nach: [String: String] = [:]
for eintrag in eintraege { nach[eintrag.key] = eintrag.value }
basiswaehrung = nach["base_currency"] ?? basiswaehrung
budgetStarttag = min(31, max(1, Int(nach["budget_start_day"] ?? "") ?? 1))
sprache = nach["locale"] ?? sprache
erscheinungsbild = nach["theme"] ?? erscheinungsbild
datumsformat = nach["date_format"] ?? datumsformat
if let roh = nach["active_currencies"] {
aktiveWaehrungen = roh.split(separator: ",")
.map { $0.trimmingCharacters(in: .whitespaces).uppercased() }
.filter { !$0.isEmpty }
}
}
}
+87
View File
@@ -0,0 +1,87 @@
import Foundation
/// `AccountOut` aus `backend/app/schemas.py`.
struct Konto: Codable, Sendable, Identifiable, Hashable {
let id: Int
let name: String
let type: Kontoart
let currency: String
let icon: String
let color: String
let openingBalanceCents: Int
let openingDate: Tagesdatum?
let creditLimitCents: Int?
let isLocked: Bool
let isArchived: Bool
let position: Int
let savingsCategoryId: Int?
let identifiers: [Kontokennung]
/// Saldo in der Währung des Kontos.
let balanceCents: Int
/// Derselbe Saldo in der Hauptwährung – vom Server gerechnet.
let balanceBaseCents: Int
/// Was die Bank selbst meldet. Nur bei angebundenen Konten gesetzt.
let bankBalanceCents: Int?
let bankBalanceAt: Zeitpunkt?
/// Nur ein gebuchter Banksaldo lässt sich mit unserer Rechnung
/// vergleichen; ein verfügbarer enthält Vorgemerktes.
let bankBalanceVergleichbar: Bool
private enum CodingKeys: String, CodingKey {
case id, name, type, currency, icon, color, position, identifiers
case openingBalanceCents = "opening_balance_cents"
case openingDate = "opening_date"
case creditLimitCents = "credit_limit_cents"
case isLocked = "is_locked"
case isArchived = "is_archived"
case savingsCategoryId = "savings_category_id"
case balanceCents = "balance_cents"
case balanceBaseCents = "balance_base_cents"
case bankBalanceCents = "bank_balance_cents"
case bankBalanceAt = "bank_balance_at"
case bankBalanceVergleichbar = "bank_balance_vergleichbar"
}
var saldo: Geldbetrag { Geldbetrag(cents: balanceCents, waehrung: currency) }
/// Ob der gemeldete Banksaldo von unserer Rechnung abweicht – nur dann ist
/// ein Hinweis angebracht.
var weichtVonDerBankAb: Bool {
guard bankBalanceVergleichbar, let bank = bankBalanceCents else { return false }
return bank != balanceCents
}
}
/// `AccountType`
enum Kontoart: String, Codable, Sendable, CaseIterable {
case girokonto, sparkonto, bargeld, kreditkarte, depot, sonstiges
var name: String {
switch self {
case .girokonto: return String(localized: "kontoart.girokonto")
case .sparkonto: return String(localized: "kontoart.sparkonto")
case .bargeld: return String(localized: "kontoart.bargeld")
case .kreditkarte: return String(localized: "kontoart.kreditkarte")
case .depot: return String(localized: "kontoart.depot")
case .sonstiges: return String(localized: "kontoart.sonstiges")
}
}
/// Ein Übertrag auf diese Konten zählt im Budget als Sparen, nicht als
/// neutrale Umbuchung (`SAVINGS_ACCOUNT_TYPES` im Backend).
var giltAlsSparen: Bool { self == .sparkonto || self == .depot }
}
/// `AccountIdentifierOut`
struct Kontokennung: Codable, Sendable, Identifiable, Hashable {
let id: Int
let kind: Art
let value: String
enum Art: String, Codable, Sendable {
case iban
case accountNo = "account_no"
case cardNo = "card_no"
case text
}
}
+127
View File
@@ -0,0 +1,127 @@
import Foundation
/// Ein Kalendertag ohne Uhrzeit, so wie das Backend ihn schickt: `"2026-07-14"`.
///
/// Warum ein eigener Typ und nicht `Date` mit einer Decoder-Strategie: Das
/// Backend mischt zwei Formate. Buchungsdaten sind reine Tage, `created_at`
/// und Konsorten sind volle Zeitstempel mit Zone. Eine einzige
/// `dateDecodingStrategy` müsste beides raten und würde mal das eine, mal das
/// andere zerlegen. Zwei saubere Typen sind hier weniger Arbeit als ein
/// cleverer Decoder.
struct Tagesdatum: Codable, Hashable, Comparable, Sendable, CustomStringConvertible {
/// Mitternacht dieses Tages, in der Zeitzone des Geräts.
let datum: Date
/// Die Form, in der es kam und wieder hinausgeht.
let text: String
private static let leser: DateFormatter = {
let f = DateFormatter()
f.calendar = Calendar(identifier: .gregorian)
f.locale = Locale(identifier: "en_US_POSIX")
f.timeZone = .current
f.dateFormat = "yyyy-MM-dd"
return f
}()
init?(_ text: String) {
guard let geparst = Tagesdatum.leser.date(from: text) else { return nil }
self.datum = geparst
self.text = text
}
init(_ datum: Date) {
self.datum = datum
self.text = Tagesdatum.leser.string(from: datum)
}
static var heute: Tagesdatum { Tagesdatum(Date()) }
init(from decoder: Decoder) throws {
let roh = try decoder.singleValueContainer().decode(String.self)
guard let fertig = Tagesdatum(roh) else {
throw DecodingError.dataCorrupted(.init(
codingPath: decoder.codingPath,
debugDescription: "Kein Tagesdatum: \(roh)"))
}
self = fertig
}
func encode(to encoder: Encoder) throws {
var behaelter = encoder.singleValueContainer()
try behaelter.encode(text)
}
static func < (links: Tagesdatum, rechts: Tagesdatum) -> Bool {
links.datum < rechts.datum
}
var description: String { text }
/// Für die Anzeige, in der Sprache des Geräts.
func formatiert(_ stil: Date.FormatStyle.DateStyle = .abbreviated) -> String {
datum.formatted(.dateTime.day().month(.defaultDigits).year())
.replacingOccurrences(of: "\u{202F}", with: " ")
}
}
/// Ein voller Zeitstempel, wie ihn `created_at`, `last_used_at` und
/// `bank_balance_at` tragen.
///
/// Das Backend schickt ISO 8601, mal mit Sekundenbruchteilen, mal ohne, mal
/// mit `Z`, mal mit Offset. Deshalb zwei Leser hintereinander statt einem.
struct Zeitpunkt: Codable, Hashable, Comparable, Sendable {
let datum: Date
private static let mitBruchteilen: ISO8601DateFormatter = {
let f = ISO8601DateFormatter()
f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
return f
}()
private static let ohneBruchteile: ISO8601DateFormatter = {
let f = ISO8601DateFormatter()
f.formatOptions = [.withInternetDateTime]
return f
}()
/// Letzter Ausweg: Python schreibt ohne Zone, wenn keine gesetzt ist.
private static let ohneZone: DateFormatter = {
let f = DateFormatter()
f.calendar = Calendar(identifier: .gregorian)
f.locale = Locale(identifier: "en_US_POSIX")
f.timeZone = TimeZone(identifier: "UTC")
f.dateFormat = "yyyy-MM-dd'T'HH:mm:ss"
return f
}()
init(_ datum: Date) { self.datum = datum }
init?(_ text: String) {
let ohneMikro = text.replacingOccurrences(
of: #"(\.\d{3})\d+"#, with: "$1", options: .regularExpression)
if let d = Zeitpunkt.mitBruchteilen.date(from: ohneMikro) { self.datum = d; return }
if let d = Zeitpunkt.ohneBruchteile.date(from: text) { self.datum = d; return }
let gekuerzt = String(text.prefix(19))
if let d = Zeitpunkt.ohneZone.date(from: gekuerzt) { self.datum = d; return }
return nil
}
init(from decoder: Decoder) throws {
let roh = try decoder.singleValueContainer().decode(String.self)
guard let fertig = Zeitpunkt(roh) else {
throw DecodingError.dataCorrupted(.init(
codingPath: decoder.codingPath,
debugDescription: "Kein Zeitpunkt: \(roh)"))
}
self = fertig
}
func encode(to encoder: Encoder) throws {
var behaelter = encoder.singleValueContainer()
try behaelter.encode(Zeitpunkt.mitBruchteilen.string(from: datum))
}
static func < (links: Zeitpunkt, rechts: Zeitpunkt) -> Bool {
links.datum < rechts.datum
}
}
+106
View File
@@ -0,0 +1,106 @@
import Foundation
/// Was beim Reden mit dem Certain-Backend schiefgehen kann.
///
/// Die Fälle sind nach dem geschnitten, was die App damit *tun* muss – nicht
/// nach HTTP-Status. `pflichtOffen` führt auf einen anderen Bildschirm,
/// `konflikt` ist eine Rückfrage, die sich mit „trotzdem" wiederholen lässt,
/// alles übrige ist eine Meldung.
enum APIFehler: Error, LocalizedError, Sendable, Equatable {
/// Netz weg, Server nicht erreichbar, Zeitüberschreitung.
case keineVerbindung(String)
/// Unter der Adresse antwortet etwas, aber kein Certain.
case keinCertainServer
/// 401 – Marke ungültig oder widerrufen, Sitzung abgelaufen.
case nichtAngemeldet
/// 403 mit maschinenlesbarem Schlüssel: erst muss etwas erledigt werden.
case pflichtOffen(Pflicht)
/// 403 aus einem anderen Grund.
case verboten(String)
/// 404 – gibt es nicht, oder gehört jemand anderem.
case nichtGefunden
/// 409 – meistens eine Rückfrage, die sich mit `trotzdem = true`
/// wiederholen lässt. Der Text ist für den Nutzer geschrieben.
case konflikt(String)
/// 400 oder 422.
case ungueltig(String)
/// Alles übrige, was der Server sagt.
case server(status: Int, text: String)
/// Die Antwort passt nicht zu dem, was wir erwarten.
case antwortUnlesbar(String)
/// Was der Benutzer erledigen muss, bevor die API ihn durchlässt.
/// Die Werte sind die Schlüssel aus `backend/app/deps.py`.
enum Pflicht: String, Sendable {
case passwortWechseln = "password_change_required"
case zweiterFaktor = "mfa_setup_required"
}
var errorDescription: String? {
switch self {
case .keineVerbindung(let grund):
return String(format: String(localized: "fehler.keineVerbindung"), grund)
case .keinCertainServer:
return String(localized: "fehler.keinCertainServer")
case .nichtAngemeldet:
return String(localized: "fehler.nichtAngemeldet")
case .pflichtOffen(.passwortWechseln):
return String(localized: "fehler.passwortWechseln")
case .pflichtOffen(.zweiterFaktor):
return String(localized: "fehler.zweiterFaktor")
case .verboten(let text), .konflikt(let text), .ungueltig(let text):
return text
case .nichtGefunden:
return String(localized: "fehler.nichtGefunden")
case .server(let status, let text):
return text.isEmpty
? String(format: String(localized: "fehler.server"), status)
: text
case .antwortUnlesbar(let grund):
return String(format: String(localized: "fehler.antwortUnlesbar"), grund)
}
}
/// Ob ein erneuter Versuch überhaupt Sinn ergibt.
var lohntWiederholung: Bool {
switch self {
case .keineVerbindung, .server:
return true
default:
return false
}
}
}
/// Der Fehlerkörper des Backends.
///
/// `detail` ist dort mal eine Zeichenkette (`{"detail": "Nicht gefunden"}`),
/// mal eine Liste von Pydantic-Meldungen (bei 422). Beides muss hier durch,
/// sonst verliert die App bei jedem Eingabefehler die Meldung.
struct Fehlerantwort: Decodable, Sendable {
let detail: String
private enum Schluessel: String, CodingKey { case detail }
private struct Einzelfehler: Decodable {
let msg: String?
}
init(from decoder: Decoder) throws {
let behaelter = try decoder.container(keyedBy: Schluessel.self)
if let text = try? behaelter.decode(String.self, forKey: .detail) {
detail = text
} else if let liste = try? behaelter.decode([Einzelfehler].self, forKey: .detail) {
detail = liste.compactMap(\.msg).joined(separator: ", ")
} else {
detail = ""
}
}
/// Versucht, aus Antwortdaten eine Meldung zu ziehen. Schlägt das fehl,
/// kommt eine leere Zeichenkette – nie ein Fehler, denn wir sind hier
/// bereits im Fehlerfall.
static func ausDaten(_ daten: Data) -> String {
(try? JSONDecoder().decode(Fehlerantwort.self, from: daten))?.detail ?? ""
}
}
+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)
}
}
+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() {}
}
+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)")
}
}
+57
View File
@@ -0,0 +1,57 @@
import Foundation
import Observation
/// Holt eine Liste, legt sie in den Zwischenspeicher und weiss, was bei einem
/// Fehlschlag noch da ist.
///
/// Jede Listenansicht braucht dasselbe: laden, Fehler zeigen, zum Aktualisieren
/// herunterziehen, offline den letzten Stand behalten. Einmal hier, statt in
/// jeder Ansicht neu.
@MainActor
@Observable
final class Lader<A: Codable & Sendable> {
private(set) var zustand: Ladezustand<A> = .laedt
private let schluessel: String
private let holen: () async throws -> A
/// - Parameter schluessel: Name im Zwischenspeicher. Der Endpunktpfad ist
/// eine gute Wahl – er ist eindeutig und beim Suchen lesbar.
init(schluessel: String, holen: @escaping () async throws -> A) {
self.schluessel = schluessel
self.holen = holen
}
/// Lädt. Beim ersten Mal wird ein vorhandener Stand sofort angezeigt,
/// damit die Liste nicht leer aufblitzt, während der Abruf läuft.
func lade(zeigeGemerktesSofort: Bool = true) async {
if zeigeGemerktesSofort, zustand.inhalt == nil,
let gemerkt = Zwischenspeicher.hole(A.self, unter: schluessel) {
zustand = .ausSpeicher(gemerkt.wert, geholtAm: gemerkt.geholtAm,
fehler: .keineVerbindung(""))
} else if zustand.inhalt == nil {
zustand = .laedt
}
do {
let frisch = try await holen()
Zwischenspeicher.lege(frisch, unter: schluessel)
zustand = .da(frisch)
} catch let fehler as APIFehler {
if let gemerkt = Zwischenspeicher.hole(A.self, unter: schluessel) {
zustand = .ausSpeicher(gemerkt.wert, geholtAm: gemerkt.geholtAm,
fehler: fehler)
} else {
zustand = .fehler(fehler)
}
} catch {
zustand = .fehler(.keineVerbindung(error.localizedDescription))
}
}
/// Fürs Herunterziehen: kein Zwischenzustand, die alte Liste bleibt
/// stehen, bis die neue da ist.
func aktualisiere() async {
await lade(zeigeGemerktesSofort: false)
}
}
+83
View File
@@ -0,0 +1,83 @@
import Foundation
/// Die letzte geglückte Antwort je Abruf, auf der Platte.
///
/// Wozu: In der U-Bahn und im Ausland soll die App nicht mit einer
/// Fehlermeldung dastehen, sondern den letzten Stand zeigen – mit Datum, damit
/// niemand alte Zahlen für aktuelle hält. Geschrieben wird offline nicht.
///
/// Bewusst schlicht: eine JSON-Datei je Schlüssel, kein Verfallsdatum, kein
/// Aufräumen nach Grösse. Die Antworten sind klein (die grösste, die
/// Buchungsliste, liegt im einstelligen Megabyte-Bereich), und ein Abmelden
/// räumt ohnehin alles weg.
enum Zwischenspeicher {
/// Was aus dem Speicher kam, und wann es geholt wurde.
struct Eintrag<A>: Sendable where A: Sendable {
let wert: A
let geholtAm: Date
}
private static let ordner: URL? = {
guard let basis = try? FileManager.default.url(
for: .cachesDirectory, in: .userDomainMask,
appropriateFor: nil, create: true) else { return nil }
let eigener = basis.appendingPathComponent("CertainAntworten", isDirectory: true)
try? FileManager.default.createDirectory(at: eigener, withIntermediateDirectories: true)
return eigener
}()
private static func datei(_ schluessel: String) -> URL? {
// Der Schluessel ist ein Pfad mit Abfrage; daraus muss ein gueltiger
// Dateiname werden.
let sauber = schluessel.unicodeScalars.map { zeichen -> Character in
CharacterSet.alphanumerics.contains(zeichen) ? Character(zeichen) : "_"
}
return ordner?.appendingPathComponent(String(sauber) + ".json")
}
static func lege<A: Encodable>(_ wert: A, unter schluessel: String) {
guard let ziel = datei(schluessel),
let daten = try? JSONEncoder().encode(wert) else { return }
try? daten.write(to: ziel, options: .atomic)
}
static func hole<A: Decodable & Sendable>(_ typ: A.Type, unter schluessel: String) -> Eintrag<A>? {
guard let quelle = datei(schluessel),
let daten = try? Data(contentsOf: quelle),
let wert = try? JSONDecoder().decode(A.self, from: daten) else { return nil }
let wann = (try? quelle.resourceValues(forKeys: [.contentModificationDateKey]))?
.contentModificationDate ?? Date()
return Eintrag(wert: wert, geholtAm: wann)
}
static func leere() {
guard let ordner else { return }
try? FileManager.default.removeItem(at: ordner)
}
}
/// Was eine Ansicht gerade anzeigen soll.
///
/// Der vierte Fall ist der Grund für diesen Typ: Daten da, aber vom Band –
/// die Ansicht muss das sagen können, ohne dass jede Ansicht ihre eigene
/// Zustandsverwaltung erfindet.
enum Ladezustand<A: Sendable>: Sendable {
case laedt
case da(A)
/// Der Abruf schlug fehl, aber wir haben noch einen Stand von vorher.
case ausSpeicher(A, geholtAm: Date, fehler: APIFehler)
case fehler(APIFehler)
var inhalt: A? {
switch self {
case .da(let wert): return wert
case .ausSpeicher(let wert, _, _): return wert
case .laedt, .fehler: return nil
}
}
var laeuft: Bool {
if case .laedt = self { return true }
return false
}
}
+119
View File
@@ -0,0 +1,119 @@
# Certain für iOS
Native App zum Certain-Backend. Der Code hier ist **nie übersetzt worden** — auf
der Maschine, auf der er entstanden ist, gibt es kein Xcode. Rechne beim ersten
Bauen mit Fehlern und schick sie mir; das ist eingeplant.
## Projekt anlegen
1. Xcode → neues Projekt → **App**, Schnittstelle **SwiftUI**, Sprache **Swift**.
2. Mindestversion auf **iOS 26** stellen.
3. Als Geräte **iPhone und iPad** ankreuzen.
4. Die Datei `ContentView.swift`, die Xcode anlegt, **löschen** — `App/` bringt
einen eigenen Einstiegspunkt mit.
5. Die Ordner aus `ios/` per Ziehen ins Projekt übernehmen, mit *Create groups*
(nicht *folder references*).
6. `Texte/de.lproj` und `Texte/en.lproj` übernehmen und unter
*Project → Info → Localizations* **Deutsch** und **Englisch** eintragen.
Deutsch ist die Grundsprache.
Der Ordner `Tests/` gehört in ein Test-Ziel, nicht in das App-Ziel.
## Was wohin gehört
| Ordner | Inhalt |
|---|---|
| `App/` | Einstiegspunkt, Navigation, globaler Zustand |
| `Netzwerk/` | Client, Endpunkte, Fehler, Offline-Ablage |
| `Modelle/` | Codable-Typen, eins zu eins zu den Backend-Schemas |
| `Bausteine/` | Wiederverwendetes: Geld, Farben, Symbole, Zustandsansichten |
| `Anmeldung/` | Server-Adresse, Anmeldung, zweiter Faktor, Passwortwechsel |
| `Konten/` | Kontenliste (die erste Fachansicht) |
| `Texte/` | Übersetzungen |
| `Tests/` | Reine Logiktests, ohne Netz |
## Wie die Anmeldung funktioniert
Das Backend kennt für die Weboberfläche nur ein Sitzungs-Cookie. Für die App
wurde eine **Gerätemarke der Reichweite `app`** ergänzt
(`backend/app/models.py`, `TokenScope`). Der Ablauf:
1. Nutzer gibt die Server-Adresse ein. Die App prüft mit `GET /api/version`, ob
dort überhaupt ein Certain läuft.
2. Nutzer meldet sich mit Benutzername und Passwort an — über das normale
Cookie, inklusive zweitem Faktor und Pflicht-Passwortwechsel, falls fällig.
3. Noch in derselben Sitzung legt die App über `POST /api/inbox/tokens` mit
`scope: "app"` eine Marke an und legt deren Klartext im **Schlüsselbund** ab.
4. Danach läuft **alles** über den Header `X-Certain-Token`. Das Cookie wird
verworfen.
Damit gibt es kein Passwort auf dem Gerät und keinen Ablauf nach 30 Tagen. Die
Marke lässt sich am Rechner unter Einstellungen → Gerätemarken einzeln
widerrufen; danach sperrt sie sofort.
Eine App-Marke kann **keine weitere Marke ausstellen** — das geht nur mit einer
echten Anmeldung. Sonst hätte ein Dieb sich Ersatzschlüssel ausgestellt und das
Widerrufen wäre wirkungslos.
## Später: Erweiterungen
Teilen-Menü und Kurzbefehle kommen in Stufe 6. Beide brauchen Zugriff auf
dieselbe Marke wie die App. Dafür ist im Projekt nötig:
- eine **App-Gruppe** (`group.<deine-bundle-id>`) für die Server-Adresse,
- eine **geteilte Schlüsselbund-Gruppe** für die Marke selbst.
`Netzwerk/Ablage.swift` liest beides bereits aus Konstanten, die du an einer
Stelle setzt (`Ablage.gruppe`). Solange keine Erweiterung existiert, funktioniert
alles auch ohne App-Gruppe.
## Was die App bewusst nicht kann
- **Bankkonten verbinden.** Die Bank leitet auf eine feste Web-Adresse zurück,
die bei Enable Banking registriert ist. Abrufen und Zuordnen geht nativ, das
Herstellen der Verbindung bleibt im Browser.
- **Postfach einrichten** und **Sicherung/Wiederherstellung.** Gehört an den
Rechner.
- **Benutzerverwaltung.** Administration für Mehrbenutzerbetrieb.
## Was geprüft ist — und was nicht
**Geprüft:** Jedes Feld der Modelle wurde gegen echte Antworten eines
laufenden Backends verglichen (`/api/version`, `/api/auth/me`, `/api/accounts`,
`/api/settings`, `/api/inbox/tokens`). Alle Namen stimmen überein. Dabei kam
heraus, dass `created_at` ohne Zeitzone und mit Mikrosekunden kommt
(`2026-10-20T14:18:15.780705`) — dafür hat `Zeitpunkt` einen dritten Leser,
und `GrundlagenTests` deckt den Fall ab.
**Nicht geprüft:** Ob das Ganze übersetzt. Kein Xcode auf der Maschine, auf
der es entstanden ist.
### Wo es beim ersten Bauen am ehesten klemmt
Nach abnehmender Wahrscheinlichkeit:
1. **`@testable import Certain`** in `Tests/GrundlagenTests.swift` — muss auf
den Namen deines App-Ziels zeigen. Heisst es anders, anpassen.
2. **`Lader` ist eine generische `@Observable`-Klasse.** Das geht, ist aber
die ungewöhnlichste Konstruktion hier. Falls der Übersetzer murrt: Die
Klasse wird bisher nur an einer Stelle benutzt (`KontenListe`).
3. **Nebenläufigkeit.** `AppZustand` ist `@MainActor`, `CertainClient` ein
Actor. Unter strikter Nebenläufigkeitsprüfung kann es an den Übergängen
Warnungen setzen.
4. **`Tab` und `.tabViewStyle(.sidebarAdaptable)`** in `RootAnsicht` setzen
iOS 18 voraus. Bei iOS 26 als Mindestversion kein Problem.
5. **Die Tests nutzen Swift Testing** (`import Testing`, `@Test`, `#expect`),
nicht XCTest. Xcode 16 und neuer kann beides im selben Ziel.
Schick mir die Fehler, dann räumen wir sie weg.
## Stand
Stufe 1: Gerüst, Anmeldung samt zweitem Faktor und Passwortwechsel,
Kontenliste. 21 Swift-Dateien, rund 2600 Zeilen. Siehe den Plan im Repository
für die weiteren Stufen.
Noch nicht in der App, obwohl in Stufe 1 erreichbar: Einen zweiten Faktor
**einrichten** geht nur im Browser. Wer ein Konto hat, für das die Richtlinie
MFA verlangt, sieht deshalb einen Hinweis mit Link statt eines Einrichtungs-
Assistenten. Das kommt mit den Einstellungen in Stufe 5.
+304
View File
@@ -0,0 +1,304 @@
import Testing
import Foundation
@testable import Certain
// Reine Logik, kein Netz. Das ist alles, was sich ohne laufenden Server
// prüfen lässt – und zugleich das, was beim blinden Schreiben am ehesten
// still danebengeht.
//
// Hinweis: `@testable import Certain` muss auf den Namen deines App-Ziels
// zeigen. Heisst es anders, hier anpassen.
// MARK: - Geldbeträge
@Suite("Geldbetrag")
struct GeldbetragTests {
@Test("Komma und Punkt meinen dasselbe")
func trennerSindAustauschbar() {
#expect(Geldbetrag.ausEingabe("12,50", waehrung: "CHF")?.cents == 1250)
#expect(Geldbetrag.ausEingabe("12.50", waehrung: "CHF")?.cents == 1250)
#expect(Geldbetrag.ausEingabe("12", waehrung: "CHF")?.cents == 1200)
#expect(Geldbetrag.ausEingabe("0,05", waehrung: "CHF")?.cents == 5)
}
@Test("Bei beiden Zeichen ist das hintere der Dezimaltrenner")
func tausenderUndDezimal() {
#expect(Geldbetrag.ausEingabe("1.234,50", waehrung: "EUR")?.cents == 123450)
#expect(Geldbetrag.ausEingabe("1,234.50", waehrung: "EUR")?.cents == 123450)
}
@Test("Drei Stellen hinter dem Trenner sind Tausender, keine Rappen")
func dreiStellenSindKeinBruch() {
// "1.234" meint tausendzweihundertvierunddreissig, nicht 1,23.
#expect(Geldbetrag.ausEingabe("1.234", waehrung: "EUR")?.cents == 123400)
}
@Test("Vorzeichen bleibt erhalten")
func negativ() {
#expect(Geldbetrag.ausEingabe("-5", waehrung: "EUR")?.cents == -500)
#expect(Geldbetrag.ausEingabe("-12,34", waehrung: "EUR")?.cents == -1234)
}
@Test("Unsinn ergibt keinen Betrag")
func unsinn() {
#expect(Geldbetrag.ausEingabe("", waehrung: "EUR") == nil)
#expect(Geldbetrag.ausEingabe(" ", waehrung: "EUR") == nil)
// Der wichtige Fall: Ohne diese Prüfung käme hier 0,00 heraus und die
// App buchte stillschweigend einen Nullbetrag.
#expect(Geldbetrag.ausEingabe("abc", waehrung: "EUR") == nil)
#expect(Geldbetrag.ausEingabe(",", waehrung: "EUR") == nil)
}
@Test("Hin und zurück durchs Eingabefeld")
func rundlauf() {
let betrag = Geldbetrag(cents: 123450, waehrung: "CHF")
#expect(betrag.alsEingabe == "1234,50")
#expect(Geldbetrag.ausEingabe(betrag.alsEingabe, waehrung: "CHF")?.cents == 123450)
let krumm = Geldbetrag(cents: 5, waehrung: "CHF")
#expect(krumm.alsEingabe == "0,05")
#expect(Geldbetrag.ausEingabe(krumm.alsEingabe, waehrung: "CHF")?.cents == 5)
}
@Test("Die Formatierung nennt Betrag und Währung")
func formatierung() {
let text = Geldbetrag(cents: 1250, waehrung: "CHF").formatiert()
// Kein Vergleich auf die genaue Zeichenkette: Trennzeichen und Stellung
// des Währungszeichens hängen an der Sprache des Geräts.
#expect(text.contains("12"))
#expect(text.contains("50"))
}
@Test("Plus nur, wenn ausdrücklich gewünscht")
func vorzeichenstil() {
let positiv = Geldbetrag(cents: 500, waehrung: "EUR")
#expect(positiv.formatiert(vorzeichen: .immer).hasPrefix("+"))
#expect(positiv.formatiert().hasPrefix("+") == false)
}
@Test("Summieren geht nur in derselben Währung")
func summieren() {
let a = Geldbetrag(cents: 100, waehrung: "EUR")
let b = Geldbetrag(cents: 250, waehrung: "EUR")
#expect((a + b).cents == 350)
}
}
// MARK: - Zeitangaben
@Suite("Zeitangaben")
struct ZeitangabenTests {
@Test("Ein Tagesdatum überlebt Hin- und Rückweg")
func tagesdatum() throws {
let tag = try #require(Tagesdatum("2026-07-14"))
#expect(tag.text == "2026-07-14")
#expect(Tagesdatum(tag.datum).text == "2026-07-14")
}
@Test("Tage lassen sich vergleichen")
func reihenfolge() throws {
let frueher = try #require(Tagesdatum("2026-01-31"))
let spaeter = try #require(Tagesdatum("2026-02-01"))
#expect(frueher < spaeter)
}
@Test("Kaputte Tagesangaben ergeben nichts")
func kaputterTag() {
#expect(Tagesdatum("14.07.2026") == nil)
#expect(Tagesdatum("") == nil)
#expect(Tagesdatum("2026-13-45") == nil)
}
@Test("Zeitstempel mit und ohne Sekundenbruchteile")
func zeitpunkte() {
// Das Backend schickt beide Formen, je nach Feld.
#expect(Zeitpunkt("2026-07-14T10:11:12Z") != nil)
#expect(Zeitpunkt("2026-07-14T10:11:12.345Z") != nil)
#expect(Zeitpunkt("2026-07-14T10:11:12+02:00") != nil)
// Python schreibt ohne Zone, wenn keine gesetzt ist.
#expect(Zeitpunkt("2026-07-14T10:11:12") != nil)
// Mikrosekunden, wie SQLite sie ablegt.
#expect(Zeitpunkt("2026-07-14T10:11:12.345678") != nil)
#expect(Zeitpunkt("übermorgen") == nil)
}
}
// MARK: - Server-Adresse
@Suite("Server-Adresse")
struct AdressTests {
@Test("Ohne Schema wird https ergänzt")
func schemaErgaenzen() throws {
let adresse = try #require(AppZustand.deuteAdresse("budget.beispiel.com"))
#expect(adresse.absoluteString == "https://budget.beispiel.com")
}
@Test("Ein angehängter Pfad fliegt raus")
func pfadWeg() throws {
// Der Nutzer kopiert gern die Adresse aus der Browserzeile, samt Pfad.
// Die API hängt aber immer unter /api an der Wurzel.
let adresse = try #require(AppZustand.deuteAdresse("https://budget.beispiel.com/buchungen?x=1"))
#expect(adresse.absoluteString == "https://budget.beispiel.com")
}
@Test("http bleibt http")
func schemaBleibt() throws {
let adresse = try #require(AppZustand.deuteAdresse("http://192.168.1.5:8000"))
#expect(adresse.absoluteString == "http://192.168.1.5:8000")
}
@Test("Unsinn ergibt keine Adresse")
func unsinn() {
#expect(AppZustand.deuteAdresse("") == nil)
#expect(AppZustand.deuteAdresse(" ") == nil)
}
}
// MARK: - Symbole
@Suite("Symbole")
struct SymbolTests {
@Test("Bekannte Namen werden übersetzt")
func bekannt() {
#expect(Symbole.sf("wallet") == "wallet.bifold")
#expect(Symbole.sf("shopping-cart") == "cart")
}
@Test("Unbekanntes wird zum Platzhalter, nie zu nichts")
func unbekannt() {
// Eine leere Stelle in der Liste sähe aus wie ein Fehler.
#expect(Symbole.sf("gibtesnicht") == Symbole.platzhalter)
#expect(Symbole.sf(nil) == Symbole.platzhalter)
}
}
// MARK: - Fehlerantworten
@Suite("Fehlerantworten")
struct FehlerTests {
@Test("detail als Zeichenkette")
func alsText() {
let daten = Data(#"{"detail":"Nicht gefunden"}"#.utf8)
#expect(Fehlerantwort.ausDaten(daten) == "Nicht gefunden")
}
@Test("detail als Liste, wie bei 422")
func alsListe() {
// Genau hier gehen Meldungen verloren, wenn der Decoder nur eine
// Zeichenkette kennt.
let daten = Data(#"{"detail":[{"loc":["body","amount_cents"],"msg":"muss groesser als 0 sein","type":"value_error"}]}"#.utf8)
#expect(Fehlerantwort.ausDaten(daten) == "muss groesser als 0 sein")
}
@Test("Was gar kein Fehlerkörper ist, ergibt leeren Text statt eines Absturzes")
func unbrauchbar() {
#expect(Fehlerantwort.ausDaten(Data("<html>".utf8)) == "")
#expect(Fehlerantwort.ausDaten(Data()) == "")
}
}
// MARK: - Modelle gegen echte Antworten
@Suite("Modelle")
struct ModellTests {
@Test("Ein Konto, wie der Server es schickt")
func konto() throws {
// Abgenommen von einer echten Antwort aus GET /api/accounts.
let roh = """
{"id":3,"name":"Revolut CHF","type":"girokonto","currency":"CHF",
"icon":"wallet","color":"#3f51b5","opening_balance_cents":0,
"opening_date":"2026-01-01","credit_limit_cents":null,
"is_locked":false,"is_archived":false,"position":2,
"savings_category_id":null,
"identifiers":[{"id":7,"kind":"iban","value":"CH93..."}],
"balance_cents":-1043,"balance_base_cents":-1043,
"bank_balance_cents":null,"bank_balance_at":null,
"bank_balance_vergleichbar":false}
"""
let konto = try JSONDecoder().decode(Konto.self, from: Data(roh.utf8))
#expect(konto.name == "Revolut CHF")
#expect(konto.type == .girokonto)
#expect(konto.saldo.cents == -1043)
#expect(konto.identifiers.first?.kind == .iban)
#expect(konto.weichtVonDerBankAb == false)
}
@Test("Ein Banksaldo, der abweicht, wird gemeldet")
func saldoWeichtAb() throws {
let roh = """
{"id":1,"name":"Giro","type":"girokonto","currency":"EUR","icon":"wallet",
"color":"#6b7480","opening_balance_cents":0,"opening_date":null,
"credit_limit_cents":null,"is_locked":false,"is_archived":false,
"position":0,"savings_category_id":null,"identifiers":[],
"balance_cents":1000,"balance_base_cents":1000,
"bank_balance_cents":1200,"bank_balance_at":"2026-07-14T06:00:00Z",
"bank_balance_vergleichbar":true}
"""
let konto = try JSONDecoder().decode(Konto.self, from: Data(roh.utf8))
#expect(konto.weichtVonDerBankAb)
}
@Test("Ein nicht vergleichbarer Banksaldo loest keinen Hinweis aus")
func saldoNichtVergleichbar() throws {
// Ein verfuegbarer Saldo enthaelt Vorgemerktes - dass er abweicht,
// ist normal und keine Meldung wert.
let roh = """
{"id":1,"name":"Giro","type":"girokonto","currency":"EUR","icon":"wallet",
"color":"#6b7480","opening_balance_cents":0,"opening_date":null,
"credit_limit_cents":null,"is_locked":false,"is_archived":false,
"position":0,"savings_category_id":null,"identifiers":[],
"balance_cents":1000,"balance_base_cents":1000,
"bank_balance_cents":1200,"bank_balance_at":"2026-07-14T06:00:00Z",
"bank_balance_vergleichbar":false}
"""
let konto = try JSONDecoder().decode(Konto.self, from: Data(roh.utf8))
#expect(konto.weichtVonDerBankAb == false)
}
@Test("Einstellungen mit Lücken fallen auf sinnvolle Werte zurück")
func einstellungen() {
let leer = Einstellungen([])
#expect(leer.basiswaehrung == "EUR")
#expect(leer.budgetStarttag == 1)
let gesetzt = Einstellungen([
Einstellung(key: "base_currency", value: "CHF"),
Einstellung(key: "budget_start_day", value: "25"),
Einstellung(key: "active_currencies", value: "EUR, CHF ,USD"),
])
#expect(gesetzt.basiswaehrung == "CHF")
#expect(gesetzt.budgetStarttag == 25)
#expect(gesetzt.aktiveWaehrungen == ["EUR", "CHF", "USD"])
}
@Test("Ein unsinniger Starttag wird eingefangen")
func starttagGrenzen() {
// Das Backend begrenzt auf 1..31; die App darf sich nicht darauf
// verlassen, dass in der Einstellung nichts anderes steht.
#expect(Einstellungen([Einstellung(key: "budget_start_day", value: "99")])
.budgetStarttag == 31)
#expect(Einstellungen([Einstellung(key: "budget_start_day", value: "0")])
.budgetStarttag == 1)
#expect(Einstellungen([Einstellung(key: "budget_start_day", value: "weiss nicht")])
.budgetStarttag == 1)
}
@Test("Die Serverkennung erkennt einen fremden Server")
func serverkennung() throws {
let echt = try JSONDecoder().decode(
Serverkennung.self,
from: Data(#"{"name":"Certain Finance","version":"0.1.0","git_sha":"abc"}"#.utf8))
#expect(echt.istCertain)
let fremd = try JSONDecoder().decode(
Serverkennung.self,
from: Data(#"{"name":"Nextcloud","version":"29","git_sha":null}"#.utf8))
#expect(fremd.istCertain == false)
}
}
+104
View File
@@ -0,0 +1,104 @@
/* Certain für iOS – Deutsch (Grundsprache)
Die Schlüssel sind nach Bereich benannt. Bitte beim Ergänzen die englische
Fassung gleich mitpflegen; im Web stehen de.json und en.json zeilengleich,
und das ist der Grund, warum dort nie etwas fehlt. */
/* --- Allgemein --- */
"allgemein.nochmal" = "Nochmal versuchen";
"allgemein.abbrechen" = "Abbrechen";
"allgemein.abmelden" = "Abmelden";
/* --- Reiter --- */
"reiter.konten" = "Konten";
"reiter.einstellungen" = "Einstellungen";
/* --- Fehler --- */
"fehler.titel" = "Das hat nicht geklappt";
"fehler.unbekannt" = "Unbekannter Fehler.";
"fehler.keineVerbindung" = "Keine Verbindung zum Server: %@";
"fehler.keinCertainServer" = "Unter dieser Adresse antwortet zwar etwas, aber kein Certain.";
"fehler.nichtAngemeldet" = "Die Anmeldung dieses Geräts gilt nicht mehr. Vermutlich wurde sie am Rechner widerrufen.";
"fehler.passwortWechseln" = "Bevor es weitergeht, muss das Passwort gewechselt werden.";
"fehler.zweiterFaktor" = "Dieses Konto braucht einen zweiten Faktor.";
"fehler.nichtGefunden" = "Gibt es nicht.";
"fehler.server" = "Der Server meldet einen Fehler (%d).";
"fehler.antwortUnlesbar" = "Die Antwort des Servers passt nicht zu dem, was die App erwartet: %@";
/* --- Offline --- */
"stand.hinweis" = "Stand von %@ – der Server ist gerade nicht erreichbar.";
/* --- Server-Adresse --- */
"server.titel" = "Mit Certain verbinden";
"server.ueberschrift" = "Adresse";
"server.platzhalter" = "budget.beispiel.com";
"server.erklaerung" = "Dieselbe Adresse, unter der du Certain im Browser öffnest. Ohne https davor geht auch.";
"server.verbinden" = "Verbinden";
"server.keineAdresse" = "Das sieht nicht nach einer Adresse aus.";
/* --- Anmeldung --- */
"anmeldung.titel" = "Anmelden";
"anmeldung.benutzer" = "Benutzername";
"anmeldung.passwort" = "Passwort";
"anmeldung.anmelden" = "Anmelden";
"anmeldung.erklaerung" = "Nur dieses eine Mal. Danach merkt sich das Gerät einen eigenen Schlüssel, und dein Passwort wird nirgends gespeichert.";
"anmeldung.andererServer" = "Anderen Server wählen";
"anmeldung.aktuellerServer" = "Verbunden mit %@";
"anmeldung.markeOhneSchluessel" = "Der Server hat keinen Schlüssel zurückgegeben. Bitte nochmal versuchen.";
/* --- Zweiter Faktor --- */
"zweifaktor.titel" = "Zweiter Faktor";
"zweifaktor.ueberschrift" = "Code";
"zweifaktor.platzhalterCode" = "123456";
"zweifaktor.platzhalterWiederherstellung" = "ABCDE-FGHIJ";
"zweifaktor.erklaerungCode" = "Die sechs Ziffern aus deiner Authenticator-App.";
"zweifaktor.erklaerungWiederherstellung" = "Einer der Codes, die du beim Einrichten bekommen hast. Jeder gilt nur einmal.";
"zweifaktor.bestaetigen" = "Bestätigen";
"zweifaktor.dochWiederherstellung" = "Stattdessen einen Wiederherstellungscode benutzen";
"zweifaktor.dochCode" = "Stattdessen den Code aus der App benutzen";
"zweifaktor.abbrechen" = "Anmeldung abbrechen";
"zweifaktor.verbraucht" = "Zu viele Fehlversuche. Bitte von vorn anmelden.";
/* --- Zweiter Faktor fehlt noch --- */
"zweifaktorEinrichten.titel" = "Zweiter Faktor nötig";
"zweifaktorEinrichten.erklaerung" = "Für dieses Konto verlangt die Richtlinie einen zweiten Faktor. Einrichten lässt er sich bisher nur im Browser – in der App kommt das noch.";
"zweifaktorEinrichten.imBrowser" = "Im Browser öffnen";
/* --- Passwortwechsel --- */
"passwort.titel" = "Passwort wechseln";
"passwort.bisher" = "Bisheriges Passwort";
"passwort.neu" = "Neues Passwort";
"passwort.wiederholen" = "Noch einmal";
"passwort.erklaerung" = "Für dieses Konto steht ein Passwortwechsel an. Vorher lässt der Server nichts anderes zu.";
"passwort.stimmtNichtUeberein" = "Die beiden stimmen nicht überein.";
"passwort.speichern" = "Passwort wechseln";
/* --- Konten --- */
"konten.titel" = "Konten";
"konten.leerTitel" = "Noch keine Konten";
"konten.leerErklaerung" = "Lege dein erstes Konto im Browser an – in der App kommt das in einer späteren Stufe.";
"konten.archivierteZeigen" = "Archivierte zeigen";
"konten.archiviert" = "archiviert";
"konten.gebunden" = "Gebundenes Geld";
"konten.gebundenSumme" = "Davon gebunden";
"konten.verfuegbar" = "Verfügbar";
"konten.bankWeichtAb" = "Die Bank meldet einen anderen Saldo";
/* --- Kontoarten --- */
"kontoart.girokonto" = "Girokonto";
"kontoart.sparkonto" = "Sparkonto";
"kontoart.bargeld" = "Bargeld";
"kontoart.kreditkarte" = "Kreditkarte";
"kontoart.depot" = "Depot";
"kontoart.sonstiges" = "Sonstiges";
/* --- Einstellungen --- */
"einstellungen.zugang" = "Zugang";
"einstellungen.angemeldetAls" = "Angemeldet als";
"einstellungen.server" = "Server";
"einstellungen.markeErklaerung" = "Dieses Gerät hat einen eigenen Schlüssel. Du kannst ihn am Rechner unter Einstellungen → Gerätemarken jederzeit widerrufen; dann meldet sich die App sofort ab.";
"einstellungen.ausDemServer" = "Vom Server";
"einstellungen.hauptwaehrung" = "Hauptwährung";
"einstellungen.budgetStarttag" = "Budget beginnt am";
"einstellungen.abmeldenFrage" = "Von diesem Gerät abmelden?";
"einstellungen.abmeldenErklaerung" = "Beim Abmelden wird der Schlüssel dieses Geräts auch am Server entfernt.";
+104
View File
@@ -0,0 +1,104 @@
/* Certain for iOS – English
Keys mirror de.lproj line for line. Keep them that way: in the web app
de.json and en.json are the same length, and that is why nothing is ever
missing there. */
/* --- General --- */
"allgemein.nochmal" = "Try again";
"allgemein.abbrechen" = "Cancel";
"allgemein.abmelden" = "Sign out";
/* --- Tabs --- */
"reiter.konten" = "Accounts";
"reiter.einstellungen" = "Settings";
/* --- Errors --- */
"fehler.titel" = "That did not work";
"fehler.unbekannt" = "Unknown error.";
"fehler.keineVerbindung" = "Cannot reach the server: %@";
"fehler.keinCertainServer" = "Something answers at this address, but it is not Certain.";
"fehler.nichtAngemeldet" = "This device is no longer signed in. It was probably revoked on the desktop.";
"fehler.passwortWechseln" = "The password has to be changed before anything else.";
"fehler.zweiterFaktor" = "This account needs a second factor.";
"fehler.nichtGefunden" = "Does not exist.";
"fehler.server" = "The server reports an error (%d).";
"fehler.antwortUnlesbar" = "The server's answer does not match what the app expects: %@";
/* --- Offline --- */
"stand.hinweis" = "As of %@ – the server cannot be reached right now.";
/* --- Server address --- */
"server.titel" = "Connect to Certain";
"server.ueberschrift" = "Address";
"server.platzhalter" = "budget.example.com";
"server.erklaerung" = "The same address you open Certain with in your browser. Leaving off https is fine.";
"server.verbinden" = "Connect";
"server.keineAdresse" = "That does not look like an address.";
/* --- Sign in --- */
"anmeldung.titel" = "Sign in";
"anmeldung.benutzer" = "Username";
"anmeldung.passwort" = "Password";
"anmeldung.anmelden" = "Sign in";
"anmeldung.erklaerung" = "Just this once. Afterwards the device keeps a key of its own, and your password is stored nowhere.";
"anmeldung.andererServer" = "Choose a different server";
"anmeldung.aktuellerServer" = "Connected to %@";
"anmeldung.markeOhneSchluessel" = "The server returned no key. Please try again.";
/* --- Second factor --- */
"zweifaktor.titel" = "Second factor";
"zweifaktor.ueberschrift" = "Code";
"zweifaktor.platzhalterCode" = "123456";
"zweifaktor.platzhalterWiederherstellung" = "ABCDE-FGHIJ";
"zweifaktor.erklaerungCode" = "The six digits from your authenticator app.";
"zweifaktor.erklaerungWiederherstellung" = "One of the codes you got when setting this up. Each works once.";
"zweifaktor.bestaetigen" = "Confirm";
"zweifaktor.dochWiederherstellung" = "Use a recovery code instead";
"zweifaktor.dochCode" = "Use the code from the app instead";
"zweifaktor.abbrechen" = "Cancel sign-in";
"zweifaktor.verbraucht" = "Too many wrong attempts. Please start over.";
/* --- Second factor missing --- */
"zweifaktorEinrichten.titel" = "Second factor required";
"zweifaktorEinrichten.erklaerung" = "The policy requires a second factor for this account. Setting one up still only works in the browser; the app will get it later.";
"zweifaktorEinrichten.imBrowser" = "Open in browser";
/* --- Password change --- */
"passwort.titel" = "Change password";
"passwort.bisher" = "Current password";
"passwort.neu" = "New password";
"passwort.wiederholen" = "Once more";
"passwort.erklaerung" = "This account has to change its password. The server allows nothing else until then.";
"passwort.stimmtNichtUeberein" = "These two do not match.";
"passwort.speichern" = "Change password";
/* --- Accounts --- */
"konten.titel" = "Accounts";
"konten.leerTitel" = "No accounts yet";
"konten.leerErklaerung" = "Create your first account in the browser; the app will get this in a later stage.";
"konten.archivierteZeigen" = "Show archived";
"konten.archiviert" = "archived";
"konten.gebunden" = "Money set aside";
"konten.gebundenSumme" = "Of which set aside";
"konten.verfuegbar" = "Available";
"konten.bankWeichtAb" = "The bank reports a different balance";
/* --- Account types --- */
"kontoart.girokonto" = "Current account";
"kontoart.sparkonto" = "Savings account";
"kontoart.bargeld" = "Cash";
"kontoart.kreditkarte" = "Credit card";
"kontoart.depot" = "Securities account";
"kontoart.sonstiges" = "Other";
/* --- Settings --- */
"einstellungen.zugang" = "Access";
"einstellungen.angemeldetAls" = "Signed in as";
"einstellungen.server" = "Server";
"einstellungen.markeErklaerung" = "This device holds a key of its own. You can revoke it any time on the desktop under Settings → Device keys; the app signs out immediately.";
"einstellungen.ausDemServer" = "From the server";
"einstellungen.hauptwaehrung" = "Main currency";
"einstellungen.budgetStarttag" = "Budget starts on";
"einstellungen.abmeldenFrage" = "Sign out on this device?";
"einstellungen.abmeldenErklaerung" = "Signing out also removes this device's key on the server.";