Steuerkram/src/Views/bank/fints.php
fds2610-bot 995b52fbd3 Feature: FinTS-Direktverbindung und vereinheitlichte Importquellen
- FinTS-Abruf für Comdirect/DKB/Sparkassen via nemiah/php-fints (4.1)
  inkl. photoTAN-Eingabe-Modal und Multi-Konto-Auswahl
- Neue Tabelle bank_sources (CSV/API/FinTS) ersetzt bank_import_formats
  und bank_connections, Backfill in Migration 068
- Symmetrische Verschlüsselung sensibler Felder (PayPal-Signature,
  FinTS-PIN) mit AES-256-GCM, APP_KEY in .env
- Routes /bank/fints* → /bank/direkt*; "+ Neue Bank…"-Dialog erlaubt
  freie API/FinTS-Anbieter
- Scripts/encrypt_bank_secrets.php als idempotenter Migrations-Helfer
- Neue Tests 36 (Crypto) und 37 (BankSource)
2026-05-22 07:17:00 +02:00

683 lines
35 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
use Steuerkram\View;
$h = fn($v) => View::h($v);
$kTextClr = fn(string $hex): string => (0.299*hexdec(substr($hex,1,2))+0.587*hexdec(substr($hex,3,2))+0.114*hexdec(substr($hex,5,2))) > 128 ? '#1e293b' : '#ffffff';
// Verbindungsdaten als JSON für JS bereitstellen.
// PINs und API-Secrets werden bewusst NICHT mitgeschickt – im Formular blanko, leer = unverändert.
$connectionsJson = json_encode(array_map(fn($c) => [
'id' => (int)$c['id'],
'bezeichnung' => $c['bezeichnung'],
'type' => $c['type'],
'provider_id' => (int)$c['provider_id'],
'provider_name' => $c['provider_name'],
'verbindungstyp' => $c['verbindungstyp'],
'kuerzel' => $c['kuerzel'] ?? '',
'farbe' => $c['farbe'] ?? '#6366f1',
'app_name' => $c['app_name'] ?? '',
'sandbox' => (bool)($c['sandbox'] ?? false),
'blz' => $c['blz'] ?? '',
'fints_url' => $c['fints_url'] ?? '',
'benutzerkennung' => $c['benutzerkennung'] ?? '',
'account_filter' => $c['account_filter'] ?? null,
], $connections));
?>
<?php if (empty($connections)): ?>
<div class="card">
<div class="card-body" style="text-align:center;padding:3rem;color:var(--color-muted)">
Noch keine Direktverbindungen konfiguriert. Klicke auf <strong>NEU</strong>, um eine hinzuzufügen.
</div>
</div>
<?php else: ?>
<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:1rem">
<?php foreach ($connections as $conn): ?>
<div class="card btn-run-connection" data-id="<?= (int)$conn['id'] ?>" style="margin:0;cursor:pointer" title="Abruf starten">
<div class="card-body">
<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:.5rem">
<div>
<div style="display:flex;align-items:center;gap:.5rem;flex-wrap:wrap">
<?php if (!empty($conn['kuerzel'])): ?>
<?php $kf = $conn['farbe'] ?? '#6366f1'; ?>
<span class="kuerzel-badge" style="background:<?= $h($kf) ?>;color:<?= $kTextClr($kf) ?>"><?= $h($conn['kuerzel']) ?></span>
<?php endif; ?>
<div style="font-weight:600;font-size:1rem"><?= $h($conn['bezeichnung']) ?></div>
</div>
<div class="text-muted text-sm" style="margin-top:.2rem"><?= $h($conn['provider_name']) ?></div>
<span class="badge <?= $conn['verbindungstyp'] === 'API' ? 'badge-blue' : 'badge-green' ?>" style="margin-top:.5rem">
<?= $h($conn['verbindungstyp']) ?>
</span>
<div class="text-muted text-sm" style="margin-top:.6rem">
Abruf: <?= $conn['last_fetched_at'] ? $h(View::formatDate($conn['last_fetched_at'])) : '<kein>' ?>
</div>
</div>
<button type="button"
class="btn-edit-connection"
data-id="<?= (int)$conn['id'] ?>"
style="background:none;border:none;cursor:pointer;color:var(--color-muted);padding:.2rem;line-height:1;flex-shrink:0"
title="Bearbeiten">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
</svg>
</button>
</div>
</div>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
<!-- Modal: Neue Verbindung -->
<div id="modal-new-connection"
style="display:none;position:fixed;inset:0;z-index:1000;background:rgba(0,0,0,.4);align-items:center;justify-content:center">
<div style="background:#fff;border-radius:var(--radius);box-shadow:0 8px 32px rgba(0,0,0,.15);width:100%;max-width:480px;padding:1.5rem">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:1.25rem">
<h2 style="margin:0;font-size:1.125rem">Neue Direktverbindung</h2>
<button type="button" id="btn-modal-close"
style="background:none;border:none;font-size:1.25rem;cursor:pointer;color:var(--color-muted)">&times;</button>
</div>
<form method="post" action="/bank/direkt">
<div class="form-group">
<label for="fc-provider">Bank / Anbieter</label>
<select id="fc-provider" name="provider_id" required>
<option value="">Bitte wählen…</option>
<?php foreach ($providers as $p): ?>
<option value="<?= (int)$p['id'] ?>"
data-typ="<?= $h($p['verbindungstyp']) ?>">
<?= $h($p['name']) ?>
</option>
<?php endforeach; ?>
<option value="__new__" data-typ="FinTS">+ Neue Bank…</option>
</select>
</div>
<div id="fc-new-bank-wrap" style="display:none">
<div class="form-group">
<label for="fc-new-bank-name">Name der neuen Bank</label>
<input type="text" id="fc-new-bank-name" name="new_bank_name"
placeholder="z. B. Sparkasse Musterstadt" maxlength="100">
</div>
<div class="form-group">
<label>Verbindungstyp</label>
<div style="display:flex;gap:1rem;padding:.25rem 0">
<label style="display:flex;align-items:center;gap:.4rem;font-weight:normal;cursor:pointer">
<input type="radio" name="new_bank_typ" value="FinTS" checked style="width:auto;margin:0">
<span>FinTS</span>
</label>
<label style="display:flex;align-items:center;gap:.4rem;font-weight:normal;cursor:pointer">
<input type="radio" name="new_bank_typ" value="API" style="width:auto;margin:0">
<span>API</span>
</label>
</div>
<div class="text-muted text-sm">FinTS = klassischer Bank-Abruf via PIN. API = REST/NVP-Schnittstelle (wie PayPal).</div>
</div>
</div>
<div class="form-group">
<label for="fc-bezeichnung">Bezeichnung</label>
<input type="text" id="fc-bezeichnung" name="bezeichnung" required
placeholder="z. B. Geschäftskonto PayPal">
</div>
<div style="display:flex;gap:.75rem;align-items:flex-end">
<div class="form-group" style="flex:1;margin-bottom:0">
<label for="fc-kuerzel">Kürzel (Badge)</label>
<input type="text" id="fc-kuerzel" name="kuerzel" maxlength="10" placeholder="PP">
</div>
<div class="form-group" style="margin-bottom:0">
<label for="fc-farbe">Farbe</label>
<input type="color" id="fc-farbe" name="farbe" value="#6366f1" style="width:48px;height:36px;padding:2px;cursor:pointer">
</div>
</div>
<div id="api-fields" style="display:none">
<div class="form-group">
<label for="fc-app-name">API-Benutzer</label>
<input type="text" id="fc-app-name" name="app_name" autocomplete="off">
</div>
<div class="form-group">
<label for="fc-client-id">API-Kennwort</label>
<input type="password" id="fc-client-id" name="client_id" autocomplete="new-password">
</div>
<div class="form-group">
<label for="fc-secret">Signature (Secret)</label>
<input type="password" id="fc-secret" name="secret" autocomplete="new-password">
</div>
<div class="form-group" style="display:flex;align-items:center;gap:.5rem">
<input type="checkbox" id="fc-sandbox" name="sandbox" value="1" style="width:auto;margin:0">
<label for="fc-sandbox" style="margin:0;font-weight:normal;cursor:pointer">Sandbox-Modus (Testumgebung)</label>
</div>
</div>
<div id="fints-fields" style="display:none">
<div class="form-group">
<label for="fc-blz">Bankleitzahl (BLZ)</label>
<input type="text" id="fc-blz" name="blz" maxlength="11" placeholder="z. B. 12030000">
</div>
<div class="form-group">
<label for="fc-benutzerkennung">Benutzerkennung / Anmeldename</label>
<input type="text" id="fc-benutzerkennung" name="benutzerkennung" autocomplete="off">
</div>
<div class="form-group">
<label for="fc-pin">PIN</label>
<input type="password" id="fc-pin" name="pin" autocomplete="new-password">
</div>
<div class="form-group">
<label for="fc-fints-url">FinTS-Server-URL <span class="text-muted text-sm">(optional, wird sonst über BLZ ermittelt)</span></label>
<input type="url" id="fc-fints-url" name="fints_url" placeholder="https://...">
</div>
<div class="text-muted text-sm">Die Konto-Auswahl wird beim ersten Abruf abgefragt.</div>
</div>
<div style="display:flex;justify-content:flex-end;gap:.5rem;margin-top:1rem">
<button type="button" id="btn-modal-cancel" class="btn btn-secondary">Abbrechen</button>
<button type="submit" class="btn btn-primary">Speichern</button>
</div>
</form>
</div>
</div>
<!-- Lösch-Formular (außerhalb des Modals, damit keine verschachtelten Forms entstehen) -->
<form id="form-edit-delete" method="post" action="" style="display:none"></form>
<!-- Modal: Verbindung bearbeiten -->
<div id="modal-edit-connection"
style="display:none;position:fixed;inset:0;z-index:1000;background:rgba(0,0,0,.4);align-items:center;justify-content:center">
<div style="background:#fff;border-radius:var(--radius);box-shadow:0 8px 32px rgba(0,0,0,.15);width:100%;max-width:480px;padding:1.5rem">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:1.25rem">
<h2 style="margin:0;font-size:1.125rem">Verbindung bearbeiten</h2>
<button type="button" id="btn-edit-close"
style="background:none;border:none;font-size:1.25rem;cursor:pointer;color:var(--color-muted)">&times;</button>
</div>
<form id="form-edit-connection" method="post" action="">
<div class="form-group">
<label>Bank / Anbieter</label>
<div id="edit-provider-name" style="padding:.4rem 0;font-weight:500"></div>
</div>
<div class="form-group">
<label for="edit-bezeichnung">Bezeichnung</label>
<input type="text" id="edit-bezeichnung" name="bezeichnung" required>
</div>
<div style="display:flex;gap:.75rem;align-items:flex-end">
<div class="form-group" style="flex:1;margin-bottom:0">
<label for="edit-kuerzel">Kürzel (Badge)</label>
<input type="text" id="edit-kuerzel" name="kuerzel" maxlength="10" placeholder="PP">
</div>
<div class="form-group" style="margin-bottom:0">
<label for="edit-farbe">Farbe</label>
<input type="color" id="edit-farbe" name="farbe" value="#6366f1" style="width:48px;height:36px;padding:2px;cursor:pointer">
</div>
</div>
<div id="edit-api-fields" style="display:none">
<div class="form-group">
<label for="edit-app-name">API-Benutzer</label>
<input type="text" id="edit-app-name" name="app_name" autocomplete="off">
</div>
<div class="form-group">
<label for="edit-client-id">API-Kennwort</label>
<input type="password" id="edit-client-id" name="client_id" autocomplete="new-password"
placeholder="Leer = unverändert behalten">
</div>
<div class="form-group">
<label for="edit-secret">Signature (Secret)</label>
<input type="password" id="edit-secret" name="secret" autocomplete="new-password"
placeholder="Leer = unverändert behalten">
</div>
<div class="form-group" style="display:flex;align-items:center;gap:.5rem">
<input type="checkbox" id="edit-sandbox" name="sandbox" value="1" style="width:auto;margin:0">
<label for="edit-sandbox" style="margin:0;font-weight:normal;cursor:pointer">Sandbox-Modus (Testumgebung)</label>
</div>
</div>
<div id="edit-fints-fields" style="display:none">
<div class="form-group">
<label for="edit-blz">Bankleitzahl (BLZ)</label>
<input type="text" id="edit-blz" name="blz" maxlength="11">
</div>
<div class="form-group">
<label for="edit-benutzerkennung">Benutzerkennung / Anmeldename</label>
<input type="text" id="edit-benutzerkennung" name="benutzerkennung" autocomplete="off">
</div>
<div class="form-group">
<label for="edit-pin">PIN</label>
<input type="password" id="edit-pin" name="pin" autocomplete="new-password"
placeholder="Leer = unverändert behalten">
</div>
<div class="form-group">
<label for="edit-fints-url">FinTS-Server-URL <span class="text-muted text-sm">(optional)</span></label>
<input type="url" id="edit-fints-url" name="fints_url" placeholder="https://...">
</div>
<div class="form-group">
<label>Aktive Konten</label>
<div id="edit-account-list" class="text-sm" style="padding:.4rem 0;color:#475569">–</div>
<label style="display:flex;align-items:center;gap:.5rem;font-weight:normal;cursor:pointer;margin-top:.4rem">
<input type="checkbox" id="edit-reset-accounts" name="reset_account_filter" value="1" style="width:auto;margin:0">
<span>Auswahl zurücksetzen (beim nächsten Abruf neu fragen)</span>
</label>
</div>
</div>
<div style="display:flex;justify-content:space-between;align-items:center;margin-top:1rem">
<button type="button" id="btn-edit-delete" class="btn btn-danger btn-sm">Löschen</button>
<div style="display:flex;gap:.5rem">
<button type="button" id="btn-edit-cancel" class="btn btn-secondary">Abbrechen</button>
<button type="submit" class="btn btn-primary">Speichern</button>
</div>
</div>
</form>
</div>
</div>
<!-- Modal: Konto-Auswahl bei mehreren Konten -->
<div id="modal-account-select"
style="display:none;position:fixed;inset:0;z-index:1100;background:rgba(0,0,0,.4);align-items:center;justify-content:center">
<div style="background:#fff;border-radius:var(--radius);box-shadow:0 8px 32px rgba(0,0,0,.15);width:100%;max-width:480px;padding:1.5rem;display:flex;flex-direction:column;gap:.75rem">
<h2 style="margin:0;font-size:1.125rem">Konten auswählen</h2>
<div class="text-muted text-sm">Wähle die Konten, die regelmäßig importiert werden sollen. Die Auswahl wird gespeichert und kann später im Bearbeiten-Dialog zurückgesetzt werden.</div>
<div id="account-list" style="display:flex;flex-direction:column;gap:.4rem;max-height:280px;overflow-y:auto;padding:.25rem"></div>
<div id="account-error" class="text-sm" style="color:#dc2626;display:none"></div>
<div style="display:flex;justify-content:flex-end;gap:.5rem">
<button type="button" id="btn-account-cancel" class="btn btn-secondary">Abbrechen</button>
<button type="button" id="btn-account-submit" class="btn btn-primary">Auswahl speichern</button>
</div>
</div>
</div>
<!-- Modal: TAN-Eingabe (photoTAN/chipTAN) -->
<div id="modal-tan-input"
style="display:none;position:fixed;inset:0;z-index:1100;background:rgba(0,0,0,.4);align-items:center;justify-content:center">
<div style="background:#fff;border-radius:var(--radius);box-shadow:0 8px 32px rgba(0,0,0,.15);width:100%;max-width:480px;padding:1.5rem;display:flex;flex-direction:column;gap:.75rem">
<h2 style="margin:0;font-size:1.125rem">TAN eingeben</h2>
<div id="tan-context" class="text-muted text-sm"></div>
<div id="tan-challenge" class="text-sm" style="white-space:pre-wrap"></div>
<div id="tan-image-wrap" style="text-align:center"></div>
<div id="tan-medium" class="text-muted text-sm"></div>
<div class="form-group" style="margin:0">
<label for="tan-input">TAN</label>
<input type="text" id="tan-input" autocomplete="off" inputmode="numeric"
style="font-size:1.25rem;letter-spacing:.25em;text-align:center">
</div>
<div id="tan-error" class="text-sm" style="color:#dc2626;display:none"></div>
<div style="display:flex;justify-content:flex-end;gap:.5rem">
<button type="button" id="btn-tan-cancel" class="btn btn-secondary">Abbrechen</button>
<button type="button" id="btn-tan-submit" class="btn btn-primary">TAN absenden</button>
</div>
</div>
</div>
<!-- Modal: Abruf-Log -->
<div id="modal-run-log"
style="display:none;position:fixed;inset:0;z-index:1000;background:rgba(0,0,0,.4);align-items:center;justify-content:center">
<div style="background:#fff;border-radius:var(--radius);box-shadow:0 8px 32px rgba(0,0,0,.15);width:100%;max-width:680px;padding:1.5rem;display:flex;flex-direction:column;gap:1rem">
<div style="display:flex;justify-content:space-between;align-items:center">
<h2 style="margin:0;font-size:1.125rem" id="run-log-title">Abruf läuft…</h2>
<span id="run-log-spinner" style="font-size:.8rem;color:var(--color-muted)">⏳ wird abgerufen…</span>
</div>
<div id="run-log-area"
style="background:#0f172a;color:#e2e8f0;font-family:monospace;font-size:.8rem;
padding:1rem;border-radius:var(--radius);height:340px;overflow-y:auto;white-space:pre-wrap;line-height:1.5">
</div>
<div style="display:flex;justify-content:flex-end">
<button type="button" id="btn-run-close" class="btn btn-primary" disabled>Schließen</button>
</div>
</div>
</div>
<script>
(function () {
const connections = <?= $connectionsJson ?>;
// ── NEU-Modal ────────────────────────────────────────────────
const modalNew = document.getElementById('modal-new-connection');
const btnNew = document.getElementById('btn-new-connection');
const btnClose = document.getElementById('btn-modal-close');
const btnCancel = document.getElementById('btn-modal-cancel');
const selectNew = document.getElementById('fc-provider');
const apiNew = document.getElementById('api-fields');
const fintsNew = document.getElementById('fints-fields');
const newBankWrap = document.getElementById('fc-new-bank-wrap');
const newBankInput = document.getElementById('fc-new-bank-name');
function openNew() { modalNew.style.display = 'flex'; }
function closeNew() { modalNew.style.display = 'none'; }
if (btnNew) btnNew.addEventListener('click', openNew);
if (btnClose) btnClose.addEventListener('click', closeNew);
if (btnCancel) btnCancel.addEventListener('click', closeNew);
modalNew.addEventListener('click', (e) => { if (e.target === modalNew) closeNew(); });
function syncTypFields(typ) {
apiNew.style.display = (typ === 'API') ? 'block' : 'none';
fintsNew.style.display = (typ === 'FinTS') ? 'block' : 'none';
}
selectNew.addEventListener('change', () => {
const opt = selectNew.options[selectNew.selectedIndex];
const isNew = opt.value === '__new__';
newBankWrap.style.display = isNew ? 'block' : 'none';
newBankInput.required = isNew;
if (isNew) {
const selected = modalNew.querySelector('input[name="new_bank_typ"]:checked');
syncTypFields(selected ? selected.value : 'FinTS');
} else {
syncTypFields(opt.dataset.typ);
}
});
modalNew.querySelectorAll('input[name="new_bank_typ"]').forEach(radio => {
radio.addEventListener('change', () => {
if (selectNew.value === '__new__') {
syncTypFields(radio.value);
}
});
});
// ── Bearbeiten-Modal ─────────────────────────────────────────
const modalEdit = document.getElementById('modal-edit-connection');
const btnEditClose = document.getElementById('btn-edit-close');
const btnEditCancel= document.getElementById('btn-edit-cancel');
const formEdit = document.getElementById('form-edit-connection');
const formDelete = document.getElementById('form-edit-delete');
const btnEditDelete = document.getElementById('btn-edit-delete');
const editName = document.getElementById('edit-provider-name');
const editBez = document.getElementById('edit-bezeichnung');
const editApiFields = document.getElementById('edit-api-fields');
const editFintsFields = document.getElementById('edit-fints-fields');
const editAppName = document.getElementById('edit-app-name');
const editClientId = document.getElementById('edit-client-id');
const editSecret = document.getElementById('edit-secret');
const editSandbox = document.getElementById('edit-sandbox');
const editKuerzel = document.getElementById('edit-kuerzel');
const editFarbe = document.getElementById('edit-farbe');
const editBlz = document.getElementById('edit-blz');
const editBenutzer = document.getElementById('edit-benutzerkennung');
const editPin = document.getElementById('edit-pin');
const editFintsUrl = document.getElementById('edit-fints-url');
const editAccountList = document.getElementById('edit-account-list');
const editResetAcc = document.getElementById('edit-reset-accounts');
function closeEdit() { modalEdit.style.display = 'none'; }
if (btnEditClose) btnEditClose.addEventListener('click', closeEdit);
if (btnEditCancel) btnEditCancel.addEventListener('click', closeEdit);
modalEdit.addEventListener('click', (e) => { if (e.target === modalEdit) closeEdit(); });
btnEditDelete.addEventListener('click', () => {
if (!confirm('Verbindung wirklich löschen?')) return;
formDelete.submit();
});
// ── Abruf-Modal ──────────────────────────────────────────────
const modalRun = document.getElementById('modal-run-log');
const runLogArea = document.getElementById('run-log-area');
const runTitle = document.getElementById('run-log-title');
const runSpinner = document.getElementById('run-log-spinner');
const btnRunClose= document.getElementById('btn-run-close');
let activeEs = null;
function closeRun() {
if (activeEs) { activeEs.close(); activeEs = null; }
modalRun.style.display = 'none';
runLogArea.textContent = '';
}
btnRunClose.addEventListener('click', closeRun);
modalRun.addEventListener('click', (e) => { if (e.target === modalRun && !btnRunClose.disabled) closeRun(); });
// ── Konto-Auswahl-Modal ──────────────────────────────────────
const modalAcc = document.getElementById('modal-account-select');
const accList = document.getElementById('account-list');
const accError = document.getElementById('account-error');
const btnAccSubmit = document.getElementById('btn-account-submit');
const btnAccCancel = document.getElementById('btn-account-cancel');
let currentAccToken = null;
function openAccountModal(payload) {
currentAccToken = payload.token;
accList.innerHTML = '';
accError.style.display = 'none';
payload.accounts.forEach((acc) => {
const row = document.createElement('label');
row.style.display = 'flex';
row.style.alignItems = 'center';
row.style.gap = '.5rem';
row.style.cursor = 'pointer';
row.style.padding = '.35rem .5rem';
row.style.border = '1px solid #e2e8f0';
row.style.borderRadius = 'var(--radius)';
const cb = document.createElement('input');
cb.type = 'checkbox'; cb.value = acc.id; cb.checked = true;
cb.style.margin = '0';
const label = document.createElement('span');
label.textContent = acc.label;
row.appendChild(cb);
row.appendChild(label);
accList.appendChild(row);
});
modalAcc.style.display = 'flex';
}
function closeAccountModal() {
modalAcc.style.display = 'none';
currentAccToken = null;
}
btnAccCancel.addEventListener('click', () => {
closeAccountModal();
if (activeEs) { activeEs.close(); activeEs = null; }
runTitle.textContent = 'Abruf abgebrochen';
runSpinner.textContent = '✗ abgebrochen';
btnRunClose.disabled = false;
});
btnAccSubmit.addEventListener('click', async () => {
const ids = Array.from(accList.querySelectorAll('input[type=checkbox]:checked')).map(cb => cb.value);
if (ids.length === 0) {
accError.textContent = 'Mindestens ein Konto auswählen.';
accError.style.display = 'block';
return;
}
btnAccSubmit.disabled = true;
try {
const form = new FormData();
form.append('token', currentAccToken);
form.append('ids', JSON.stringify(ids));
const res = await fetch('/bank/direkt/' + currentSourceId + '/accounts', { method: 'POST', body: form });
const data = await res.json();
if (!res.ok || data.error) {
accError.textContent = data.error || 'Übermittlung fehlgeschlagen.';
accError.style.display = 'block';
return;
}
closeAccountModal();
} catch (e) {
accError.textContent = 'Netzwerkfehler: ' + e.message;
accError.style.display = 'block';
} finally {
btnAccSubmit.disabled = false;
}
});
// ── TAN-Modal ────────────────────────────────────────────────
const modalTan = document.getElementById('modal-tan-input');
const tanContext = document.getElementById('tan-context');
const tanChallenge = document.getElementById('tan-challenge');
const tanImageWrap = document.getElementById('tan-image-wrap');
const tanMedium = document.getElementById('tan-medium');
const tanInput = document.getElementById('tan-input');
const tanError = document.getElementById('tan-error');
const btnTanSubmit = document.getElementById('btn-tan-submit');
const btnTanCancel = document.getElementById('btn-tan-cancel');
let currentSourceId = null;
let currentTanToken = null;
function openTanModal(payload) {
currentTanToken = payload.token;
tanContext.textContent = payload.context ? 'Konto: ' + payload.context : '';
tanChallenge.textContent = payload.challenge || '';
tanMedium.textContent = payload.mediumName ? 'TAN-Medium: ' + payload.mediumName : '';
tanImageWrap.innerHTML = '';
if (payload.imageData && payload.imageMime) {
const img = new Image();
img.src = 'data:' + payload.imageMime + ';base64,' + payload.imageData;
img.style.maxWidth = '320px';
tanImageWrap.appendChild(img);
}
tanInput.value = '';
tanError.style.display = 'none';
modalTan.style.display = 'flex';
setTimeout(() => tanInput.focus(), 50);
}
function closeTanModal() {
modalTan.style.display = 'none';
currentTanToken = null;
}
btnTanCancel.addEventListener('click', () => {
closeTanModal();
if (activeEs) { activeEs.close(); activeEs = null; }
runTitle.textContent = 'Abruf abgebrochen';
runSpinner.textContent = '✗ abgebrochen';
btnRunClose.disabled = false;
});
async function submitTan() {
const tan = tanInput.value.trim();
if (!tan) { tanError.textContent = 'Bitte TAN eingeben.'; tanError.style.display = 'block'; return; }
if (!currentSourceId || !currentTanToken) return;
btnTanSubmit.disabled = true;
try {
const form = new FormData();
form.append('token', currentTanToken);
form.append('tan', tan);
const res = await fetch('/bank/direkt/' + currentSourceId + '/tan', { method: 'POST', body: form });
const data = await res.json();
if (!res.ok || data.error) {
tanError.textContent = data.error || 'TAN-Übermittlung fehlgeschlagen.';
tanError.style.display = 'block';
return;
}
closeTanModal();
} catch (e) {
tanError.textContent = 'Netzwerkfehler: ' + e.message;
tanError.style.display = 'block';
} finally {
btnTanSubmit.disabled = false;
}
}
btnTanSubmit.addEventListener('click', submitTan);
tanInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') submitTan(); });
document.querySelectorAll('.btn-run-connection').forEach(card => {
card.addEventListener('click', (e) => {
if (e.target.closest('.btn-edit-connection')) return;
const id = card.dataset.id;
currentSourceId = id;
runLogArea.textContent = '';
runTitle.textContent = 'Abruf läuft…';
runSpinner.textContent = '⏳ wird abgerufen…';
btnRunClose.disabled = true;
modalRun.style.display = 'flex';
if (activeEs) activeEs.close();
activeEs = new EventSource('/bank/direkt/' + id + '/run');
activeEs.addEventListener('log', (ev) => {
runLogArea.textContent += ev.data + '\n';
runLogArea.scrollTop = runLogArea.scrollHeight;
});
activeEs.addEventListener('tan-required', (ev) => {
try {
openTanModal(JSON.parse(ev.data));
} catch (e) {
runLogArea.textContent += '\n[TAN-Anforderung konnte nicht geparst werden]\n';
}
});
activeEs.addEventListener('accounts-required', (ev) => {
try {
openAccountModal(JSON.parse(ev.data));
} catch (e) {
runLogArea.textContent += '\n[Konto-Auswahl konnte nicht geparst werden]\n';
}
});
activeEs.addEventListener('done', (ev) => {
activeEs.close();
activeEs = null;
runTitle.textContent = ev.data === 'ok' ? 'Abruf abgeschlossen' : 'Abruf mit Fehlern beendet';
runSpinner.textContent = ev.data === 'ok' ? '✓ fertig' : '⚠ Fehler';
btnRunClose.disabled = false;
});
activeEs.addEventListener('error', () => {
activeEs.close();
activeEs = null;
runLogArea.textContent += '\n[Verbindung unterbrochen]\n';
runLogArea.scrollTop = runLogArea.scrollHeight;
runTitle.textContent = 'Abruf unterbrochen';
runSpinner.textContent = '✗ unterbrochen';
btnRunClose.disabled = false;
});
});
});
document.querySelectorAll('.btn-edit-connection').forEach(btn => {
btn.addEventListener('click', () => {
const id = parseInt(btn.dataset.id, 10);
const conn = connections.find(c => c.id === id);
if (!conn) return;
formEdit.action = '/bank/direkt/' + id;
formDelete.action = '/bank/direkt/' + id + '/delete';
editName.textContent = conn.provider_name;
editBez.value = conn.bezeichnung;
editKuerzel.value = conn.kuerzel || '';
editFarbe.value = conn.farbe || '#6366f1';
const isApi = conn.type === 'api';
const isFints = conn.type === 'fints';
editApiFields.style.display = isApi ? 'block' : 'none';
editFintsFields.style.display = isFints ? 'block' : 'none';
if (isApi) {
editAppName.value = conn.app_name || '';
editClientId.value = '';
editSecret.value = '';
editSandbox.checked = conn.sandbox === true;
}
if (isFints) {
editBlz.value = conn.blz || '';
editBenutzer.value = conn.benutzerkennung || '';
editPin.value = '';
editFintsUrl.value = conn.fints_url || '';
editResetAcc.checked = false;
let accountsText = '–';
if (conn.account_filter) {
try {
const arr = JSON.parse(conn.account_filter);
if (Array.isArray(arr) && arr.length > 0) {
accountsText = arr.join(', ');
}
} catch (e) { /* ignore */ }
} else {
accountsText = '(wird beim nächsten Abruf abgefragt)';
}
editAccountList.textContent = accountsText;
}
modalEdit.style.display = 'flex';
});
});
})();
</script>