Merge pull request 'Fix: FinTS-Konto-Auswahl speichern + Bearbeiten-Dialog mit Checkboxen' (#83) from fds2610-bot/Steuerkram:main into main

Reviewed-on: https://codeberg.org/fds2610/Steuerkram/pulls/83
This commit is contained in:
fds2610 2026-05-22 12:21:31 +02:00
commit fdc4da79f8
5 changed files with 176 additions and 11 deletions

View file

@ -2,6 +2,15 @@
---
## 2026-05-22 – Fix: FinTS-Konto-Auswahl wird jetzt gespeichert + Bearbeiten-Dialog mit Checkboxen
- **Bug:** Beim ersten Abruf erschien das Konto-Auswahl-Modal, das Speichern blieb aber hängen. Ursache war PHP-Session-Locking: der SSE-Stream `/bank/direkt/{id}/run` hielt die Session-Datei während des `awaitAccountSelection()`-Polling-Loops gesperrt, der parallele POST `/bank/direkt/{id}/accounts` blockierte beim `session_start()` → Deadlock bis Timeout
- **Fix:** Im SSE-Endpoint `session_write_close()` direkt nach dem Auth-Check; entstört zugleich die analog gelagerte TAN-Eingabe
- **UI:** Im Bearbeiten-Dialog einer FinTS-Verbindung erscheinen die gespeicherten Konten jetzt als Checkbox-Liste (statt nur als Text). Abhaken entfernt den Identifier beim Speichern aus `account_filter`; alle abgehakt verhält sich wie der Reset-Toggle
- Filter-Berechnung als `BankSource::computeFilterFromPost()` extrahiert, neuer Test `38_BankSourceFilterFromPostTest` deckt Reset, Teilauswahl, leere Auswahl und Schutz gegen Unterschieben fremder Identifier ab
---
## 2026-05-22 – Feature: APP_KEY Status & Generator in den Einstellungen
- Neuer Settings-Bereich unter dem Betriebsmodus zeigt Status des Verschlüsselungsschlüssels (`APP_KEY`): **Gesetzt** (grün, 64 Hex-Zeichen valide), **Ungültig** (falsches Format) oder **Fehlt** (leer)

View file

@ -462,6 +462,12 @@ class BankController extends BaseController
ob_end_clean();
}
// Session-Lock freigeben, damit parallele POSTs (TAN-/Konto-Auswahl)
// während des Pollings nicht blockieren.
if (session_status() === PHP_SESSION_ACTIVE) {
session_write_close();
}
$send = function (string $event, string $data): void {
foreach (explode("\n", $data) as $line) {
echo 'event: ' . $event . "\n";
@ -537,9 +543,12 @@ class BankController extends BaseController
'benutzerkennung' => trim($_POST['benutzerkennung'] ?? '') ?: null,
'pin' => $newPin !== '' ? $newPin : $source['pin'],
];
// Reset-Button: Konto-Filter explizit löschen → beim nächsten Abruf neu auswählen
if (isset($_POST['reset_account_filter'])) {
$data['account_filter'] = null;
$filterUpdate = BankSource::computeFilterFromPost(
$source['account_filter'] ?? null,
$_POST
);
if ($filterUpdate['update']) {
$data['account_filter'] = $filterUpdate['value'];
}
}

View file

@ -90,6 +90,49 @@ class BankSource
Database::execute('UPDATE bank_sources SET last_fetched_at = NOW() WHERE id = ?', [$id]);
}
/**
* Berechnet einen neuen account_filter aus dem bisherigen Filter und einem
* POST-Payload des Bearbeiten-Dialogs.
*
* Rückgabe:
* ['update' => true, 'value' => null] – Filter löschen (Reset)
* ['update' => true, 'value' => '[...]'] – neuen JSON-Filter setzen
* ['update' => false, 'value' => null] – Filter unverändert lassen
*
* Nur Identifier, die bereits im bisherigen Filter standen, werden übernommen
* (kein Unterschieben fremder Konten via POST).
*/
public static function computeFilterFromPost(?string $currentJson, array $post): array
{
if (isset($post['reset_account_filter'])) {
return ['update' => true, 'value' => null];
}
if (!array_key_exists('active_accounts', $post)) {
return ['update' => false, 'value' => null];
}
$known = [];
if ($currentJson !== null && $currentJson !== '') {
$decoded = json_decode($currentJson, true);
if (is_array($decoded)) {
$known = array_values(array_map('strval', $decoded));
}
}
$postedRaw = is_array($post['active_accounts']) ? $post['active_accounts'] : [];
$posted = array_map('strval', $postedRaw);
$kept = array_values(array_intersect($known, $posted));
if ($kept === [] && $known !== []) {
// Alle abgehakt → wie Reset behandeln (beim nächsten Abruf neu fragen)
return ['update' => true, 'value' => null];
}
if ($kept !== []) {
return ['update' => true, 'value' => json_encode($kept, JSON_UNESCAPED_UNICODE)];
}
return ['update' => false, 'value' => null];
}
/** Aktualisiert sort_order anhand der übergebenen ID-Reihenfolge (Position 0..N). */
public static function updateOrder(array $orderedIds): void
{

View file

@ -274,7 +274,10 @@ $connectionsJson = json_encode(array_map(fn($c) => [
</div>
<div class="form-group">
<label>Aktive Konten</label>
<div id="edit-account-list" class="text-sm" style="padding:.4rem 0;color:#475569">–</div>
<div id="edit-account-list" style="display:flex;flex-direction:column;gap:.3rem;padding:.2rem 0"></div>
<div id="edit-account-empty" class="text-sm text-muted" style="display:none;padding:.4rem 0">
(wird beim nächsten Abruf abgefragt)
</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>
@ -799,18 +802,50 @@ $connectionsJson = json_encode(array_map(fn($c) => [
editPin.value = '';
editFintsUrl.value = conn.fints_url || '';
editResetAcc.checked = false;
let accountsText = '–';
// Konto-Checkboxen aus account_filter aufbauen
editAccountList.innerHTML = '';
let accounts = [];
if (conn.account_filter) {
try {
const arr = JSON.parse(conn.account_filter);
if (Array.isArray(arr) && arr.length > 0) {
accountsText = arr.join(', ');
}
if (Array.isArray(arr)) accounts = arr.map(String);
} catch (e) { /* ignore */ }
} else {
accountsText = '(wird beim nächsten Abruf abgefragt)';
}
editAccountList.textContent = accountsText;
const editAccountEmpty = document.getElementById('edit-account-empty');
if (accounts.length === 0) {
editAccountList.style.display = 'none';
editAccountEmpty.style.display = 'block';
} else {
editAccountList.style.display = 'flex';
editAccountEmpty.style.display = 'none';
accounts.forEach(id => {
const row = document.createElement('label');
row.style.display = 'flex';
row.style.alignItems = 'center';
row.style.gap = '.5rem';
row.style.cursor = 'pointer';
row.style.fontWeight = 'normal';
row.style.margin = '0';
const cb = document.createElement('input');
cb.type = 'checkbox';
cb.name = 'active_accounts[]';
cb.value = id;
cb.checked = true;
cb.style.width = 'auto';
cb.style.margin = '0';
const span = document.createElement('span');
// IBAN lesbarer machen (4er-Gruppen), AN:-Identifier wie geliefert
span.textContent = /^[A-Z]{2}\d{2}/.test(id)
? id.replace(/(.{4})/g, '$1 ').trim()
: id;
span.style.fontFamily = 'monospace';
span.style.fontSize = '.85rem';
row.appendChild(cb);
row.appendChild(span);
editAccountList.appendChild(row);
});
}
}
modalEdit.style.display = 'flex';

View file

@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
use Steuerkram\Models\BankSource;
/**
* Test 38: BankSource::computeFilterFromPost – Logik des Bearbeiten-Dialogs
* (Checkbox-Auswahl der aktiven Konten + Reset-Toggle).
*/
class BankSourceFilterFromPostTest extends TestCase
{
protected string $testName = 'Test 38: BankSource::computeFilterFromPost – Filter aus POST';
public function run(): void
{
$current = json_encode(['DE53120300000000123456', 'AN:42:']);
// 1. Reset-Flag hat Vorrang und liefert null
$r = BankSource::computeFilterFromPost($current, [
'reset_account_filter' => '1',
'active_accounts' => ['DE53120300000000123456'], // wird ignoriert
]);
$this->assert($r['update'] === true, 'Reset löst Update aus');
$this->assert($r['value'] === null, 'Reset liefert null');
// 2. Eine Checkbox abgehakt – nur die übrig bleibende wird gespeichert
$r = BankSource::computeFilterFromPost($current, [
'active_accounts' => ['DE53120300000000123456'],
]);
$this->assert($r['update'] === true, 'Teilauswahl löst Update aus');
$decoded = json_decode((string)$r['value'], true);
$this->assertEquals(['DE53120300000000123456'], $decoded, 'Nur angehakter Wert verbleibt');
// 3. Alle Checkboxen abgehakt → Reset-Verhalten (null)
$r = BankSource::computeFilterFromPost($current, [
'active_accounts' => [],
]);
$this->assert($r['update'] === true, 'Alles abgehakt löst Update aus');
$this->assert($r['value'] === null, 'Alles abgehakt → null (wie Reset)');
// 4. Kein active_accounts-Schlüssel im POST → unverändert lassen
$r = BankSource::computeFilterFromPost($current, []);
$this->assert($r['update'] === false, 'Ohne active_accounts kein Update');
// 5. POST enthält fremden Identifier, der nicht im aktuellen Filter steht → wird verworfen
$r = BankSource::computeFilterFromPost($current, [
'active_accounts' => ['DE53120300000000123456', 'FREMD:99:'],
]);
$decoded = json_decode((string)$r['value'], true);
$this->assertEquals(
['DE53120300000000123456'],
$decoded,
'Nur Identifier aus aktuellem Filter werden übernommen'
);
// 6. Aktueller Filter leer (null) + leeres active_accounts → kein Update
$r = BankSource::computeFilterFromPost(null, [
'active_accounts' => [],
]);
$this->assert($r['update'] === false, 'Leerer Filter + leere Auswahl → kein Update');
// 7. Aktueller Filter ist ungültiges JSON → wie leerer Filter behandeln
$r = BankSource::computeFilterFromPost('not-json', [
'active_accounts' => ['DE53120300000000123456'],
]);
$this->assert($r['update'] === false, 'Ungültiges Current-JSON führt zu leerer kept-Liste → kein Update');
}
}