Compare commits
2
Commits
15b863ff5e
...
6b86a6bf91
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6b86a6bf91 | ||
|
|
8a7545bf5b |
+57
-1
@@ -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
|
Die Anmeldung läuft über eine server-seitige Sitzung im Cookie (siehe
|
||||||
``services/sessions``). ``get_current_user`` liest das Cookie, prüft die Sitzung
|
``services/sessions``). ``get_current_user`` liest das Cookie, prüft die Sitzung
|
||||||
und liefert den Benutzer – oder 401.
|
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
|
``enforce_account_state`` hängt am zentralen ``/api``-Router und sperrt Benutzer
|
||||||
aus, die erst noch etwas erledigen müssen (Passwort wechseln, zweiten Faktor
|
aus, die erst noch etwas erledigen müssen (Passwort wechseln, zweiten Faktor
|
||||||
einrichten). Bisher wurde das nur im Frontend umgeleitet – wer die API direkt
|
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
|
return request.state.session_user
|
||||||
token = request.cookies.get(get_settings().cookie_name, "")
|
token = request.cookies.get(get_settings().cookie_name, "")
|
||||||
user = sessions.validate(db, token)
|
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.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
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+14
-4
@@ -813,14 +813,24 @@ class CategorizationRule(Base):
|
|||||||
|
|
||||||
|
|
||||||
class TokenScope(str, enum.Enum):
|
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.
|
``quick_add`` ist die enge Form und die Voreinstellung. Eine solche Marke
|
||||||
Sie darf deshalb nur erfassen, niemals lesen: Geht das Gerät verloren, ist
|
liegt auf dem Telefon, oft in einem Kurzbefehl, den man teilt. Sie darf
|
||||||
kein Datenabfluss möglich.
|
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"
|
quick_add = "quick_add"
|
||||||
|
app = "app"
|
||||||
|
|
||||||
|
|
||||||
class ApiToken(Base):
|
class ApiToken(Base):
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ from pydantic import BaseModel
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from ..database import get_db
|
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 (
|
from ..models import (
|
||||||
Account,
|
Account,
|
||||||
ApiToken,
|
ApiToken,
|
||||||
@@ -45,9 +45,14 @@ def list_tokens(db: Session = Depends(get_db), user: User = Depends(get_current_
|
|||||||
def create_token(
|
def create_token(
|
||||||
payload: ApiTokenCreate,
|
payload: ApiTokenCreate,
|
||||||
db: Session = Depends(get_db),
|
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:
|
if payload.default_account_id is not None:
|
||||||
get_owned(db, Account, payload.default_account_id, user)
|
get_owned(db, Account, payload.default_account_id, user)
|
||||||
klartext = generate_session_token()
|
klartext = generate_session_token()
|
||||||
@@ -55,6 +60,7 @@ def create_token(
|
|||||||
owner_id=user.id,
|
owner_id=user.id,
|
||||||
name=payload.name.strip(),
|
name=payload.name.strip(),
|
||||||
token_hash=hash_session_token(klartext),
|
token_hash=hash_session_token(klartext),
|
||||||
|
scope=payload.scope,
|
||||||
default_account_id=payload.default_account_id,
|
default_account_id=payload.default_account_id,
|
||||||
)
|
)
|
||||||
db.add(marke)
|
db.add(marke)
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ from .models import (
|
|||||||
IdentifierKind,
|
IdentifierKind,
|
||||||
ImportStatus,
|
ImportStatus,
|
||||||
Role,
|
Role,
|
||||||
|
TokenScope,
|
||||||
TxnType,
|
TxnType,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -687,11 +688,15 @@ class FlaggedKeywordOut(BaseModel):
|
|||||||
class ApiTokenCreate(BaseModel):
|
class ApiTokenCreate(BaseModel):
|
||||||
name: str = Field(min_length=1, max_length=80)
|
name: str = Field(min_length=1, max_length=80)
|
||||||
default_account_id: int | None = None
|
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):
|
class ApiTokenOut(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
name: str
|
name: str
|
||||||
|
scope: TokenScope
|
||||||
default_account_id: int | None
|
default_account_id: int | None
|
||||||
last_used_at: datetime | None
|
last_used_at: datetime | None
|
||||||
created_at: datetime | None
|
created_at: datetime | None
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Benutzername und Passwort – einmal. Danach trägt die Gerätemarke, und
|
||||||
|
/// das Passwort wird nirgends gespeichert.
|
||||||
|
struct AnmeldeAnsicht: View {
|
||||||
|
@Environment(AppZustand.self) private var zustand
|
||||||
|
@State private var benutzer = Ablage.benutzername ?? ""
|
||||||
|
@State private var passwort = ""
|
||||||
|
@FocusState private var feld: Feld?
|
||||||
|
|
||||||
|
private enum Feld { case benutzer, passwort }
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
Form {
|
||||||
|
Section {
|
||||||
|
TextField(String(localized: "anmeldung.benutzer"), text: $benutzer)
|
||||||
|
.textContentType(.username)
|
||||||
|
.textInputAutocapitalization(.never)
|
||||||
|
.autocorrectionDisabled()
|
||||||
|
.focused($feld, equals: .benutzer)
|
||||||
|
.submitLabel(.next)
|
||||||
|
.onSubmit { feld = .passwort }
|
||||||
|
|
||||||
|
SecureField(String(localized: "anmeldung.passwort"), text: $passwort)
|
||||||
|
.textContentType(.password)
|
||||||
|
.focused($feld, equals: .passwort)
|
||||||
|
.submitLabel(.go)
|
||||||
|
.onSubmit { melde() }
|
||||||
|
} footer: {
|
||||||
|
Text(String(localized: "anmeldung.erklaerung"))
|
||||||
|
}
|
||||||
|
|
||||||
|
if let meldung = zustand.meldung {
|
||||||
|
Section {
|
||||||
|
Label(meldung, systemImage: "exclamationmark.triangle")
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
Button(action: melde) {
|
||||||
|
if zustand.beschaeftigt {
|
||||||
|
ProgressView()
|
||||||
|
} else {
|
||||||
|
Text(String(localized: "anmeldung.anmelden"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.disabled(bereit == false || zustand.beschaeftigt)
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
Button(String(localized: "anmeldung.andererServer"), role: .destructive) {
|
||||||
|
Task { await zustand.meldeAb(adresseBehalten: false) }
|
||||||
|
}
|
||||||
|
} footer: {
|
||||||
|
if let adresse = zustand.serverAdresse {
|
||||||
|
Text(String(format: String(localized: "anmeldung.aktuellerServer"),
|
||||||
|
adresse.host() ?? adresse.absoluteString))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle(String(localized: "anmeldung.titel"))
|
||||||
|
.onAppear { feld = benutzer.isEmpty ? .benutzer : .passwort }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var bereit: Bool {
|
||||||
|
!benutzer.trimmingCharacters(in: .whitespaces).isEmpty && !passwort.isEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
private func melde() {
|
||||||
|
guard bereit else { return }
|
||||||
|
let name = benutzer.trimmingCharacters(in: .whitespaces)
|
||||||
|
let geheim = passwort
|
||||||
|
passwort = ""
|
||||||
|
Task { await zustand.melde(benutzer: name, passwort: geheim) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Pflicht-Passwortwechsel. Solange der offen ist, sperrt das Backend jeden
|
||||||
|
/// fachlichen Endpunkt mit 403 – auch das Anlegen der Gerätemarke. Deshalb
|
||||||
|
/// steht dieser Bildschirm vor allem anderen.
|
||||||
|
///
|
||||||
|
/// Die Richtlinie des Servers (Mindestlänge, Zeichenarten) prüfen wir hier
|
||||||
|
/// nicht nach. Das Backend kennt sie, und sie doppelt zu pflegen hiesse, sie
|
||||||
|
/// irgendwann unterschiedlich zu haben. Es meldet den Grund im Klartext.
|
||||||
|
struct PasswortWechselAnsicht: View {
|
||||||
|
@Environment(AppZustand.self) private var zustand
|
||||||
|
@State private var alt = ""
|
||||||
|
@State private var neu = ""
|
||||||
|
@State private var wiederholung = ""
|
||||||
|
@FocusState private var feld: Feld?
|
||||||
|
|
||||||
|
private enum Feld { case alt, neu, wiederholung }
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
Form {
|
||||||
|
Section {
|
||||||
|
SecureField(String(localized: "passwort.bisher"), text: $alt)
|
||||||
|
.textContentType(.password)
|
||||||
|
.focused($feld, equals: .alt)
|
||||||
|
.submitLabel(.next)
|
||||||
|
.onSubmit { feld = .neu }
|
||||||
|
} footer: {
|
||||||
|
Text(String(localized: "passwort.erklaerung"))
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
SecureField(String(localized: "passwort.neu"), text: $neu)
|
||||||
|
.textContentType(.newPassword)
|
||||||
|
.focused($feld, equals: .neu)
|
||||||
|
.submitLabel(.next)
|
||||||
|
.onSubmit { feld = .wiederholung }
|
||||||
|
|
||||||
|
SecureField(String(localized: "passwort.wiederholen"), text: $wiederholung)
|
||||||
|
.textContentType(.newPassword)
|
||||||
|
.focused($feld, equals: .wiederholung)
|
||||||
|
.submitLabel(.go)
|
||||||
|
.onSubmit { wechsle() }
|
||||||
|
} footer: {
|
||||||
|
if !wiederholung.isEmpty, neu != wiederholung {
|
||||||
|
Text(String(localized: "passwort.stimmtNichtUeberein"))
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let meldung = zustand.meldung {
|
||||||
|
Section {
|
||||||
|
Label(meldung, systemImage: "exclamationmark.triangle")
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
Button(action: wechsle) {
|
||||||
|
if zustand.beschaeftigt {
|
||||||
|
ProgressView()
|
||||||
|
} else {
|
||||||
|
Text(String(localized: "passwort.speichern"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.disabled(bereit == false || zustand.beschaeftigt)
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
Button(String(localized: "allgemein.abmelden"), role: .destructive) {
|
||||||
|
Task { await zustand.meldeAb() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle(String(localized: "passwort.titel"))
|
||||||
|
.onAppear { feld = .alt }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var bereit: Bool {
|
||||||
|
!alt.isEmpty && !neu.isEmpty && neu == wiederholung
|
||||||
|
}
|
||||||
|
|
||||||
|
private func wechsle() {
|
||||||
|
guard bereit else { return }
|
||||||
|
let bisher = alt
|
||||||
|
let frisch = neu
|
||||||
|
Task {
|
||||||
|
await zustand.wechslePasswort(alt: bisher, neu: frisch)
|
||||||
|
alt = ""; neu = ""; wiederholung = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Der allererste Bildschirm: Wo steht dein Certain?
|
||||||
|
///
|
||||||
|
/// Die Adresse wird nicht blind übernommen, sondern mit `GET /api/version`
|
||||||
|
/// geprüft. Das erspart die Sorte Fehlersuche, bei der man sich eine Stunde
|
||||||
|
/// fragt, warum die Anmeldung nicht geht, und am Ende ein Buchstabe fehlte.
|
||||||
|
struct ServerAnsicht: View {
|
||||||
|
@Environment(AppZustand.self) private var zustand
|
||||||
|
@State private var eingabe = ""
|
||||||
|
@FocusState private var imFeld: Bool
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
Form {
|
||||||
|
Section {
|
||||||
|
TextField(String(localized: "server.platzhalter"), text: $eingabe)
|
||||||
|
.textContentType(.URL)
|
||||||
|
.keyboardType(.URL)
|
||||||
|
.textInputAutocapitalization(.never)
|
||||||
|
.autocorrectionDisabled()
|
||||||
|
.focused($imFeld)
|
||||||
|
.submitLabel(.go)
|
||||||
|
.onSubmit { pruefe() }
|
||||||
|
} header: {
|
||||||
|
Text(String(localized: "server.ueberschrift"))
|
||||||
|
} footer: {
|
||||||
|
Text(String(localized: "server.erklaerung"))
|
||||||
|
}
|
||||||
|
|
||||||
|
if let meldung = zustand.meldung {
|
||||||
|
Section {
|
||||||
|
Label(meldung, systemImage: "exclamationmark.triangle")
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
Button(action: pruefe) {
|
||||||
|
if zustand.beschaeftigt {
|
||||||
|
ProgressView()
|
||||||
|
} else {
|
||||||
|
Text(String(localized: "server.verbinden"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.disabled(eingabe.trimmingCharacters(in: .whitespaces).isEmpty
|
||||||
|
|| zustand.beschaeftigt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle(String(localized: "server.titel"))
|
||||||
|
.onAppear { imFeld = true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func pruefe() {
|
||||||
|
Task { _ = await zustand.pruefeUndMerkeServer(eingabe) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Der zweite Faktor. Entweder sechs Ziffern aus der Authenticator-App oder
|
||||||
|
/// ein Wiederherstellungscode der Form `ABCDE-FGHIJ`.
|
||||||
|
///
|
||||||
|
/// Fünf Fehlversuche, dann ist die Zwischensitzung verbraucht und es geht
|
||||||
|
/// zurück zur Anmeldung – so hält es das Backend
|
||||||
|
/// (`backend/app/services/sessions.py`, `MAX_2FA_ATTEMPTS`).
|
||||||
|
struct ZweiFaktorAnsicht: View {
|
||||||
|
@Environment(AppZustand.self) private var zustand
|
||||||
|
@State private var code = ""
|
||||||
|
@State private var mitWiederherstellung = false
|
||||||
|
@FocusState private var imFeld: Bool
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
Form {
|
||||||
|
Section {
|
||||||
|
TextField(platzhalter, text: $code)
|
||||||
|
.textContentType(mitWiederherstellung ? .none : .oneTimeCode)
|
||||||
|
.keyboardType(mitWiederherstellung ? .asciiCapable : .numberPad)
|
||||||
|
.textInputAutocapitalization(.characters)
|
||||||
|
.autocorrectionDisabled()
|
||||||
|
.monospaced()
|
||||||
|
.focused($imFeld)
|
||||||
|
.submitLabel(.go)
|
||||||
|
.onSubmit { pruefe() }
|
||||||
|
.onChange(of: code) { _, neu in
|
||||||
|
// Sechs Ziffern aus der App: direkt absenden, das
|
||||||
|
// spart den Griff zur Tastatur.
|
||||||
|
if !mitWiederherstellung, neu.count == 6, neu.allSatisfy(\.isNumber) {
|
||||||
|
pruefe()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Text(String(localized: "zweifaktor.ueberschrift"))
|
||||||
|
} footer: {
|
||||||
|
Text(mitWiederherstellung
|
||||||
|
? String(localized: "zweifaktor.erklaerungWiederherstellung")
|
||||||
|
: String(localized: "zweifaktor.erklaerungCode"))
|
||||||
|
}
|
||||||
|
|
||||||
|
if let meldung = zustand.meldung {
|
||||||
|
Section {
|
||||||
|
Label(meldung, systemImage: "exclamationmark.triangle")
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
Button(action: pruefe) {
|
||||||
|
if zustand.beschaeftigt {
|
||||||
|
ProgressView()
|
||||||
|
} else {
|
||||||
|
Text(String(localized: "zweifaktor.bestaetigen"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.disabled(code.isEmpty || zustand.beschaeftigt)
|
||||||
|
|
||||||
|
Button(mitWiederherstellung
|
||||||
|
? String(localized: "zweifaktor.dochCode")
|
||||||
|
: String(localized: "zweifaktor.dochWiederherstellung")) {
|
||||||
|
mitWiederherstellung.toggle()
|
||||||
|
code = ""
|
||||||
|
imFeld = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
Button(String(localized: "zweifaktor.abbrechen"), role: .destructive) {
|
||||||
|
Task { await zustand.meldeAb() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle(String(localized: "zweifaktor.titel"))
|
||||||
|
.onAppear { imFeld = true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var platzhalter: String {
|
||||||
|
mitWiederherstellung
|
||||||
|
? String(localized: "zweifaktor.platzhalterWiederherstellung")
|
||||||
|
: String(localized: "zweifaktor.platzhalterCode")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func pruefe() {
|
||||||
|
let eingabe = code.trimmingCharacters(in: .whitespaces)
|
||||||
|
guard !eingabe.isEmpty else { return }
|
||||||
|
Task {
|
||||||
|
await zustand.pruefeZweitenFaktor(
|
||||||
|
code: eingabe, wiederherstellung: mitWiederherstellung)
|
||||||
|
code = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Die Richtlinie verlangt einen zweiten Faktor, das Konto hat keinen.
|
||||||
|
/// Einrichten geht in Stufe 1 noch nicht in der App – also sagen wir das
|
||||||
|
/// klar, statt den Nutzer gegen eine Wand laufen zu lassen.
|
||||||
|
struct ZweitenFaktorEinrichtenHinweis: View {
|
||||||
|
@Environment(AppZustand.self) private var zustand
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
ContentUnavailableView {
|
||||||
|
Label(String(localized: "zweifaktorEinrichten.titel"),
|
||||||
|
systemImage: "lock.shield")
|
||||||
|
} description: {
|
||||||
|
Text(String(localized: "zweifaktorEinrichten.erklaerung"))
|
||||||
|
} actions: {
|
||||||
|
if let adresse = zustand.serverAdresse {
|
||||||
|
Link(String(localized: "zweifaktorEinrichten.imBrowser"),
|
||||||
|
destination: adresse)
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
}
|
||||||
|
Button(String(localized: "allgemein.abmelden"), role: .destructive) {
|
||||||
|
Task { await zustand.meldeAb() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle(String(localized: "zweifaktorEinrichten.titel"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 ?? ""
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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() {}
|
||||||
|
}
|
||||||
@@ -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)")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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.
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.";
|
||||||
@@ -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.";
|
||||||
Reference in New Issue
Block a user