feat: Oberflaeche fuer die Bankanbindung

Einstellungen -> "Bankkonten direkt verbinden": Zugangsdaten hinterlegen,
Land waehlen, Bank suchen, verbinden. Nach der Autorisierung landet man auf
/bank/rueckkehr, wo der Code gegen die Sitzung getauscht wird – ueber den
Server, denn der Code ist ein Einmal-Geheimnis und die Anfrage muss mit dem
privaten Schluessel signiert werden, der den Server nie verlaesst.

Danach stehen die gefundenen Bankkonten da und werden je einem Konto in
Certain zugeordnet. Die Auswahl zeigt nur Konten passender Waehrung, und
solange nichts zugeordnet ist, steht dabei, dass von diesem Konto nichts
abgerufen wird.

Die Einrichtungsanleitung nennt die Rueckleitungs-Adresse kopierfertig – sie
muss bei Enable Banking hinterlegt werden, und ohne sie scheitert die
Autorisierung mit einer Fehlermeldung der Bank, die nichts erklaert.

Laeuft die Zustimmung bald ab, steht das farbig an der Verbindung statt dass
der Abruf eines Tages stumm aufhoert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Guido
2026-08-26 21:34:41 +02:00
co-authored by Claude Opus 5
parent 7a3bd200b1
commit 735ece6843
8 changed files with 508 additions and 0 deletions
+74
View File
@@ -0,0 +1,74 @@
import { useEffect, useState } from "react";
import { Link, useSearchParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { api } from "../api";
import Icon from "../components/Icon";
/**
* Landeplatz nach der Autorisierung bei der Bank.
*
* Die Bank hängt an die Rückleitungs-Adresse einen ``code`` und den ``state``,
* den Certain beim Start erzeugt hat. Der Code wird hier gegen die Sitzung
* getauscht – erst danach weiss Certain, welche Konten es dort gibt.
*
* Der Austausch läuft über den Server, nicht im Browser: Der Code ist ein
* Einmal-Geheimnis, und die Anfrage muss mit dem privaten Schlüssel signiert
* werden, der den Server nie verlässt.
*/
export default function BankRueckkehr() {
const { t } = useTranslation();
const [params] = useSearchParams();
const [stand, setStand] = useState({ laeuft: true });
useEffect(() => {
const code = params.get("code");
const state = params.get("state");
const fehler = params.get("error");
if (fehler) {
setStand({ laeuft: false, fehler: params.get("error_description") || fehler });
return;
}
if (!code || !state) {
setStand({ laeuft: false, fehler: t("bankReturn.missing") });
return;
}
api.bankCallback(state, code)
.then((r) => setStand({ laeuft: false, konten: r.konten }))
.catch((e) => setStand({ laeuft: false, fehler: e.message }));
// Nur einmal ausführen: Der Code lässt sich kein zweites Mal einlösen.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<div className="page">
<div className="page-head">
<h1>{t("bankReturn.title")}</h1>
</div>
<div className="card">
{stand.laeuft && <p className="muted">{t("bankReturn.working")}</p>}
{!stand.laeuft && stand.fehler && (
<div className="alert error" style={{ display: "block" }}>
<strong>{t("bankReturn.failed")}</strong>
<p className="muted small">{stand.fehler}</p>
<p className="muted small">{t("bankReturn.failedHint")}</p>
</div>
)}
{!stand.laeuft && !stand.fehler && (
<div className="alert" style={{ display: "block" }}>
<strong>{t("bankReturn.ok", { n: stand.konten })}</strong>
<p className="muted small">{t("bankReturn.next")}</p>
</div>
)}
<div className="btn-pair" style={{ marginTop: "var(--sp-3)" }}>
<Link className="btn primary" to="/settings">
<Icon name="settings" size={15} /> {t("bankReturn.toSettings")}
</Link>
</div>
</div>
</div>
);
}
+2
View File
@@ -7,6 +7,7 @@ import { api } from "../api";
import Icon from "../components/Icon";
import Kartenspalten from "../components/Kartenspalten";
import PolicyCard from "./settings/PolicyCard";
import Bankanbindung from "./settings/Bankanbindung";
import Postfaecher from "./settings/Postfaecher";
import RestoreCard from "./settings/RestoreCard";
@@ -163,6 +164,7 @@ export default function Settings() {
)}
</div>
<Bankanbindung />
<Postfaecher />
<RestoreCard />
</Kartenspalten>
+314
View File
@@ -0,0 +1,314 @@
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { api } from "../../api";
import { useToast } from "../../toast";
import { useConfirm } from "../../confirm";
import Icon from "../../components/Icon";
import Modal from "../../components/Modal";
import { formatDate } from "../../format";
// Länder, in denen die Banken dieses Nutzers liegen können. Die Schweiz fehlt
// bewusst: Sie fällt nicht unter PSD2, dort gibt es keine Schnittstelle.
const LAENDER = ["DE", "LT", "AT", "NL", "FR", "IT", "ES", "IE", "PL"];
/**
* Bankkonten über PSD2 verbinden.
*
* Seit PSD2 müssen EU-Banken eine Schnittstelle anbieten, aber nur für
* lizenzierte Dienste. Enable Banking hält diese Lizenz; man registriert eine
* eigene Anwendung und darf danach die selbst verknüpften Konten lesen.
*
* Abgerufene Umsätze werden nicht gebucht – sie landen als Vorschlag im
* Eingang, wie Mails und Kurzbefehl-Erfassungen.
*/
export default function Bankanbindung() {
const { t } = useTranslation();
const toast = useToast();
const confirm = useConfirm();
const [app, setApp] = useState(null);
const [form, setForm] = useState(null);
const [konten, setKonten] = useState([]);
const [verbindungen, setVerbindungen] = useState([]);
const [links, setLinks] = useState([]);
const [land, setLand] = useState("DE");
const [banken, setBanken] = useState(null);
const [busy, setBusy] = useState(false);
const basis = useMemo(() => window.location.origin, []);
const kontoById = useMemo(
() => Object.fromEntries(konten.map((k) => [k.id, k])), [konten]);
async function laden() {
try {
const [a, k, v, l] = await Promise.all([
api.getBankApp().catch(() => null),
api.listAccounts(),
api.listBankConnections().catch(() => []),
api.listBankLinks().catch(() => []),
]);
setApp(a);
setKonten(k);
setVerbindungen(v);
setLinks(l);
} catch (e) {
toast(e.message, "error");
}
}
useEffect(() => { laden(); }, []);
async function speichern(e) {
e.preventDefault();
setBusy(true);
try {
await api.saveBankApp(form);
setForm(null);
laden();
toast(t("bank.appSaved"));
} catch (e) {
toast(e.message, "error");
} finally {
setBusy(false);
}
}
async function bankenLaden() {
setBusy(true);
try {
setBanken(await api.listBanks(land));
} catch (e) {
toast(e.message, "error");
setBanken(null);
} finally {
setBusy(false);
}
}
async function verbinden(bank) {
setBusy(true);
try {
const r = await api.connectBank({ aspsp_name: bank.name, country: land });
// Weiter zur Bank. Von dort kommt der Nutzer auf /bank/rueckkehr zurück.
window.location.href = r.url;
} catch (e) {
toast(e.message, "error");
setBusy(false);
}
}
async function abrufen(v) {
setBusy(true);
try {
const r = await api.syncBankConnection(v.id);
toast(r.ohne_zuordnung ? t("bank.noMapping")
: t("bank.synced", { geholt: r.geholt, neu: r.neu }));
laden();
} catch (e) {
toast(e.message, "error");
} finally {
setBusy(false);
}
}
async function trennen(v) {
if (!(await confirm({
title: t("bank.disconnect"),
message: t("bank.disconnectConfirm", { name: v.aspsp_name }),
danger: true, confirmLabel: t("bank.disconnect"),
}))) return;
try { await api.deleteBankConnection(v.id); laden(); } catch (e) { toast(e.message, "error"); }
}
async function zuordnen(link, kontoId) {
try {
if (kontoId) await api.setBankLinkAccount(link.id, Number(kontoId));
else await api.clearBankLinkAccount(link.id);
laden();
} catch (e) {
toast(e.message, "error");
}
}
const linksVon = (v) => links.filter((l) => l.connection_id === v.id);
return (
<div className="card">
<div className="card-head">
<Icon name="landmark" size={18} />
<h2>{t("bank.title")}</h2>
</div>
<p className="muted small">{t("bank.intro")}</p>
{/* Schritt 1: die Anwendung dieser Installation */}
{!app ? (
<div className="alert" style={{ display: "block", marginTop: "var(--sp-3)" }}>
<strong>{t("bank.setupTitle")}</strong>
<p className="muted small">{t("bank.setupHint")}</p>
<ol className="muted small" style={{ paddingLeft: "1.2em" }}>
<li>{t("bank.setupStep1")}</li>
<li>{t("bank.setupStep2")}</li>
<li>
{t("bank.setupStep3")}
<div className="input-with-button" style={{ marginTop: 4 }}>
<input readOnly className="selectable" value={`${basis}/bank/rueckkehr`} />
<button type="button" className="btn sm"
onClick={() => navigator.clipboard?.writeText(`${basis}/bank/rueckkehr`)}>
<Icon name="copy" size={14} />
</button>
</div>
</li>
</ol>
<button className="btn primary" style={{ marginTop: "var(--sp-2)" }}
onClick={() => setForm({ application_id: "", private_key: "", base_url: basis })}>
<Icon name="plus" size={15} /> {t("bank.setupStart")}
</button>
</div>
) : (
<div className="mail-source">
<div><strong>{t("bank.appReady")}</strong></div>
<div className="muted small selectable">{app.application_id}</div>
<div className="muted small">{app.base_url}</div>
<div className="mail-actions">
<button className="btn sm ghost"
onClick={() => setForm({ application_id: app.application_id,
private_key: "", base_url: app.base_url })}>
<Icon name="edit" size={15} /> {t("common.edit")}
</button>
</div>
</div>
)}
{/* Schritt 2: bestehende Verbindungen samt Kontozuordnung */}
{verbindungen.map((v) => (
<div className="mail-source" key={v.id}>
<div><strong>{v.aspsp_name}</strong> <span className="muted small">{v.aspsp_country}</span></div>
<div className="muted small">
{v.last_error
? `${t("bank.lastError")}: ${v.last_error}`
: v.last_sync_at ? t("bank.lastSync", { datum: formatDate(v.last_sync_at.slice(0, 10)) })
: t("bank.neverSynced")}
</div>
{v.valid_until && (
<div className={v.expires_soon ? "muted small warn" : "muted small"}>
{v.expires_soon
? t("bank.expiresSoon", { datum: formatDate(v.valid_until.slice(0, 10)) })
: t("bank.validUntil", { datum: formatDate(v.valid_until.slice(0, 10)) })}
</div>
)}
<div style={{ marginTop: "var(--sp-2)" }}>
{linksVon(v).length === 0 ? (
<p className="muted small">{t("bank.noAccountsFound")}</p>
) : (
linksVon(v).map((l) => (
<div className="sender-row" key={l.id}>
<div className="muted small selectable">
<strong>{l.remote_name || l.iban || l.remote_uid}</strong>
{l.iban ? ` · ${l.iban}` : ""} · {l.currency}
</div>
<select value={l.account_id || ""}
onChange={(e) => zuordnen(l, e.target.value)}>
<option value="">{t("bank.notMapped")}</option>
{konten.filter((k) => k.currency === l.currency).map((k) => (
<option key={k.id} value={k.id}>{k.name}</option>
))}
</select>
{l.account_id ? null : (
<p className="muted small" style={{ margin: "4px 0 0" }}>
{t("bank.mapHint")}
</p>
)}
</div>
))
)}
</div>
<div className="mail-actions">
<button className="btn sm primary" disabled={busy} onClick={() => abrufen(v)}>
{t("bank.sync")}
</button>
<button className="btn sm danger" onClick={() => trennen(v)}>
<Icon name="trash" size={15} />
</button>
</div>
</div>
))}
{/* Schritt 3: neue Bank verbinden */}
{app && (
<div style={{ marginTop: "var(--sp-3)" }}>
<label className="field">
<span>{t("bank.country")}</span>
<select value={land} onChange={(e) => { setLand(e.target.value); setBanken(null); }}>
{LAENDER.map((l) => <option key={l} value={l}>{l}</option>)}
</select>
</label>
<button className="btn" disabled={busy} onClick={bankenLaden}>
<Icon name="search" size={15} /> {t("bank.findBanks")}
</button>
{banken && (
<div style={{ marginTop: "var(--sp-2)" }}>
{banken.length === 0 ? (
<p className="muted small">{t("bank.noBanks")}</p>
) : (
<>
<p className="muted small">{t("bank.pickBank", { n: banken.length })}</p>
<div className="account-list">
{banken.map((b) => (
<div className="account-row" key={`${b.name}-${b.country}`}>
<span className="grow ellipsis">{b.name}</span>
<button className="btn sm primary" disabled={busy}
onClick={() => verbinden(b)}>
{t("bank.connect")}
</button>
</div>
))}
</div>
</>
)}
</div>
)}
</div>
)}
{form !== null && (
<Modal
title={t("bank.appTitle")}
onClose={() => setForm(null)}
footer={
<>
<button type="button" className="btn ghost" onClick={() => setForm(null)}>
{t("common.cancel")}
</button>
<button type="submit" form="bank-app-form" className="btn primary" disabled={busy}>
{t("common.save")}
</button>
</>
}
>
<form id="bank-app-form" onSubmit={speichern}>
<label className="field">
<span>{t("bank.applicationId")}</span>
<input value={form.application_id} required
onChange={(e) => setForm({ ...form, application_id: e.target.value })} />
</label>
<label className="field">
<span>{app ? t("bank.privateKeyKeep") : t("bank.privateKey")}</span>
<textarea rows={6} value={form.private_key} required={!app}
placeholder="-----BEGIN PRIVATE KEY-----&#10;…"
onChange={(e) => setForm({ ...form, private_key: e.target.value })} />
</label>
<p className="muted small">{t("bank.privateKeyHint")}</p>
<label className="field">
<span>{t("bank.baseUrl")}</span>
<input value={form.base_url} required
onChange={(e) => setForm({ ...form, base_url: e.target.value })} />
</label>
<p className="muted small">{t("bank.baseUrlHint")}</p>
</form>
</Modal>
)}
</div>
);
}