commit 5aa3535557b9e50f4f65afb4bcecb583cbb3ec2f
parent 89fde9bf667a23159f29483350333a4520bf5637
Author: Florian Dold <dold@taler.net>
Date: Wed, 23 Sep 2026 17:48:00 +0200
merchant web UI: restore bank monitoring in advanced account settings
Load and update bank history URLs and authentication from the payout
account menu. Hide monitoring behind a collapsed Advanced settings
disclosure, preserve existing credentials and wire metadata, and expose
load and save failures without losing edits.
Issue: https://bugs.taler.net/n/11813
Diffstat:
5 files changed, 801 insertions(+), 4 deletions(-)
diff --git a/packages/taler-merchant-webui/src/App.tsx b/packages/taler-merchant-webui/src/App.tsx
@@ -222,6 +222,8 @@ export function AppContent(): VNode {
accounts: realAccounts,
isLoading: accountsLoading,
resource: accountsResource,
+ getBankMonitoring,
+ updateBankMonitoring,
createPayoutAccount,
deletePayoutAccount,
} = usePayoutAccounts({ enabled: needsMoneyData });
@@ -925,6 +927,8 @@ export function AppContent(): VNode {
isOrdersLoading={firstOrdersLoading}
isTemplatesLoading={firstTemplatesLoading}
onAddAccount={() => setLocation("/money/payout-accounts/add")}
+ onLoadBankMonitoring={getBankMonitoring}
+ onSaveBankMonitoring={updateBankMonitoring}
onReactivateAccount={async (acc) => {
if (createPayoutAccount) {
const accountData = {
diff --git a/packages/taler-merchant-webui/src/api/hooks/usePayoutAccounts.ts b/packages/taler-merchant-webui/src/api/hooks/usePayoutAccounts.ts
@@ -22,7 +22,7 @@ import {
Paytos,
} from "@gnu-taler/taler-util";
import { merchantClient } from "../client.js";
-import { unwrap } from "../failure.js";
+import { unwrap, unwrapEmpty } from "../failure.js";
import { session } from "../../stores/session.js";
import type { PayoutAccount } from "../../types/domain.js";
import { getClientConfig } from "./common.js";
@@ -154,12 +154,39 @@ export function usePayoutAccounts({
return { mfaRequired: false };
};
+ const getBankMonitoring = async (hWire: string) => {
+ if (!token) throw new Error("Not signed in");
+ return unwrap(
+ await merchantClient(config).getBankAccountDetails(
+ token as AccessToken,
+ hWire,
+ ),
+ );
+ };
+
+ const updateBankMonitoring = async (
+ hWire: string,
+ details: TalerMerchantApi.AccountPatchDetails,
+ ) => {
+ if (!token) throw new Error("Not signed in");
+ unwrapEmpty(
+ await merchantClient(config).updateBankAccount(
+ token as AccessToken,
+ hWire,
+ details,
+ ),
+ );
+ await mutate();
+ };
+
return {
accounts: data,
error: normalizedError,
isLoading: resource.isLoading,
mutate,
resource,
+ getBankMonitoring,
+ updateBankMonitoring,
createPayoutAccount,
deletePayoutAccount,
};
diff --git a/packages/taler-merchant-webui/src/screens/BankMonitoringDialog.tsx b/packages/taler-merchant-webui/src/screens/BankMonitoringDialog.tsx
@@ -0,0 +1,308 @@
+/*
+ This file is part of GNU Taler
+ (C) 2026 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import { useEffect, useRef, useState } from "preact/hooks";
+import type { TalerMerchantApi } from "@gnu-taler/taler-util";
+import type { PayoutAccount } from "../types/domain.js";
+import { useTranslation } from "../context/translation.js";
+import { Modal } from "../ui/Modal.js";
+import { ApiErrorBanner } from "../ui/ApiErrorBanner.js";
+
+interface BankMonitoringDialogProps {
+ account: PayoutAccount;
+ onLoad: (hWire: string) => Promise<TalerMerchantApi.BankAccountDetail>;
+ onSave: (
+ hWire: string,
+ details: TalerMerchantApi.AccountPatchDetails,
+ ) => Promise<void>;
+ onClose: () => void;
+}
+
+export function BankMonitoringDialog(props: BankMonitoringDialogProps) {
+ const { t } = useTranslation();
+ const [settings, setSettings] =
+ useState<TalerMerchantApi.BankAccountDetail>();
+ const [error, setError] = useState<unknown>();
+ const [attempt, setAttempt] = useState(0);
+ const [saving, setSaving] = useState(false);
+ const load = useRef(props.onLoad);
+ load.current = props.onLoad;
+ const hWire = props.account.hWire!;
+ useEffect(() => {
+ let current = true;
+ setError(undefined);
+ load.current(hWire).then(
+ (result) => {
+ if (current) setSettings(result);
+ },
+ (cause) => {
+ if (current) setError(cause);
+ },
+ );
+ return () => {
+ current = false;
+ };
+ // Reload only when the account or explicit retry changes, not when the
+ // parent re-renders with fresh callback identities.
+ }, [hWire, attempt]);
+ return (
+ <Modal
+ isOpen
+ title={t`Bank monitoring`}
+ onClose={() => {
+ if (!saving) props.onClose();
+ }}
+ maxWidth="lg"
+ >
+ <p class="mb-4 text-sm text-gray-600">{t`Connect your bank's incoming transfer history to confirm when payouts arrive.`}</p>
+ <p class="mb-4 break-all text-sm text-gray-600">
+ {props.account.account}
+ </p>
+ {settings ? (
+ <BankMonitoringForm
+ settings={settings}
+ saving={saving}
+ setSaving={setSaving}
+ onSave={async (details) => {
+ await props.onSave(props.account.hWire!, details);
+ props.onClose();
+ }}
+ onCancel={props.onClose}
+ />
+ ) : error ? (
+ <>
+ <ApiErrorBanner
+ error={error}
+ title={t`Bank monitoring settings could not be loaded`}
+ />
+ <button
+ type="button"
+ onClick={() => setAttempt(attempt + 1)}
+ >{t`Retry`}</button>
+ </>
+ ) : (
+ <p role="status">{t`Loading bank monitoring settings…`}</p>
+ )}
+ </Modal>
+ );
+}
+
+function BankMonitoringForm({
+ settings,
+ saving,
+ setSaving,
+ onSave,
+ onCancel,
+}: {
+ settings: TalerMerchantApi.BankAccountDetail;
+ saving: boolean;
+ setSaving: (value: boolean) => void;
+ onSave: (details: TalerMerchantApi.AccountPatchDetails) => Promise<void>;
+ onCancel: () => void;
+}) {
+ const { t } = useTranslation();
+ const [enabled, setEnabled] = useState(Boolean(settings.credit_facade_url));
+ const [url, setUrl] = useState(settings.credit_facade_url ?? "");
+ const [auth, setAuth] = useState<"keep" | "none" | "basic" | "bearer">(
+ "keep",
+ );
+ const [username, setUsername] = useState("");
+ const [password, setPassword] = useState("");
+ const [token, setToken] = useState("");
+ const [error, setError] = useState<unknown>();
+ const fieldClass =
+ "mt-1 w-full rounded-md border border-gray-300 px-3 py-2 text-sm";
+ return (
+ <form
+ class="space-y-4"
+ onSubmit={async (event) => {
+ event.preventDefault();
+ if (saving) return;
+ setError(undefined);
+ let normalizedUrl: string | undefined;
+ if (enabled) {
+ try {
+ const parsed = new URL(url.trim());
+ if (
+ !["https:", "http:"].includes(parsed.protocol) ||
+ parsed.username ||
+ parsed.password ||
+ parsed.hash
+ )
+ throw new Error();
+ normalizedUrl = parsed.href;
+ } catch {
+ setError(
+ new Error(
+ t`Enter an HTTP or HTTPS bank history URL without embedded credentials or a fragment.`,
+ ),
+ );
+ return;
+ }
+ if (
+ (auth === "basic" && (!username.trim() || !password)) ||
+ (auth === "bearer" && !token.trim())
+ ) {
+ setError(
+ new Error(
+ t`Enter the credentials for the selected authentication method.`,
+ ),
+ );
+ return;
+ }
+ }
+ const credentials: TalerMerchantApi.FacadeCredentials | undefined =
+ !enabled || auth === "none"
+ ? { type: "none" }
+ : auth === "basic"
+ ? { type: "basic", username: username.trim(), password }
+ : auth === "bearer"
+ ? { type: "bearer", token: token.trim() }
+ : undefined;
+ setSaving(true);
+ try {
+ await onSave({
+ credit_facade_url: normalizedUrl,
+ credit_facade_credentials: credentials,
+ // PATCH replaces this field too; preserve it when editing monitoring.
+ extra_wire_subject_metadata: settings.extra_wire_subject_metadata,
+ });
+ } catch (cause) {
+ setError(cause);
+ } finally {
+ setSaving(false);
+ }
+ }}
+ >
+ <fieldset disabled={saving} class="space-y-4">
+ <label class="flex items-center gap-2 text-sm font-medium">
+ <input
+ id="bank-monitoring-enabled"
+ type="checkbox"
+ checked={enabled}
+ onChange={(e) => setEnabled(e.currentTarget.checked)}
+ />
+ {t`Check incoming bank transfers automatically`}
+ </label>
+ {enabled && (
+ <>
+ <label
+ class="block text-sm font-medium"
+ htmlFor="bank-monitoring-url"
+ >
+ {t`Bank history URL`}
+ <input
+ id="bank-monitoring-url"
+ type="url"
+ required
+ value={url}
+ class={fieldClass}
+ onInput={(e) => setUrl(e.currentTarget.value)}
+ />
+ </label>
+ <label
+ class="block text-sm font-medium"
+ htmlFor="bank-monitoring-auth"
+ >
+ {t`Authentication`}
+ <select
+ id="bank-monitoring-auth"
+ value={auth}
+ class={fieldClass}
+ onChange={(e) => setAuth(e.currentTarget.value as typeof auth)}
+ >
+ <option value="keep">{t`Keep existing credentials`}</option>
+ <option value="none">{t`No authentication`}</option>
+ <option value="basic">{t`Username and password`}</option>
+ <option value="bearer">{t`Access token`}</option>
+ </select>
+ </label>
+ <p class="text-xs text-gray-500">{t`Saved credentials are never displayed. Keep them unchanged or enter replacements.`}</p>
+ {auth === "basic" && (
+ <>
+ <label
+ class="block text-sm font-medium"
+ htmlFor="bank-monitoring-username"
+ >
+ {t`Username`}
+ <input
+ id="bank-monitoring-username"
+ required
+ autoComplete="off"
+ value={username}
+ class={fieldClass}
+ onInput={(e) => setUsername(e.currentTarget.value)}
+ />
+ </label>
+ <label
+ class="block text-sm font-medium"
+ htmlFor="bank-monitoring-password"
+ >
+ {t`Password`}
+ <input
+ id="bank-monitoring-password"
+ type="password"
+ required
+ autoComplete="new-password"
+ value={password}
+ class={fieldClass}
+ onInput={(e) => setPassword(e.currentTarget.value)}
+ />
+ </label>
+ </>
+ )}
+ {auth === "bearer" && (
+ <label
+ class="block text-sm font-medium"
+ htmlFor="bank-monitoring-token"
+ >
+ {t`Access token`}
+ <input
+ id="bank-monitoring-token"
+ type="password"
+ required
+ autoComplete="new-password"
+ value={token}
+ class={fieldClass}
+ onInput={(e) => setToken(e.currentTarget.value)}
+ />
+ </label>
+ )}
+ </>
+ )}
+ </fieldset>
+ <ApiErrorBanner
+ error={error}
+ title={t`Bank monitoring settings could not be saved`}
+ />
+ <div class="flex justify-end gap-3">
+ <button
+ type="button"
+ disabled={saving}
+ onClick={onCancel}
+ class="rounded-md border px-4 py-2 text-sm"
+ >{t`Cancel`}</button>
+ <button
+ type="submit"
+ disabled={saving}
+ class="rounded-md bg-blue-600 px-4 py-2 text-sm font-semibold text-white disabled:opacity-50"
+ >
+ {saving ? t`Saving…` : t`Save`}
+ </button>
+ </div>
+ </form>
+ );
+}
diff --git a/packages/taler-merchant-webui/src/screens/MoneyInScreen.tsx b/packages/taler-merchant-webui/src/screens/MoneyInScreen.tsx
@@ -17,6 +17,7 @@
import type { VNode } from "preact";
import { useState } from "preact/hooks";
import { TalerMerchantApi } from "@gnu-taler/taler-util";
+import { BankMonitoringDialog } from "./BankMonitoringDialog.js";
import { Header } from "../ui/Header.js";
import { useTranslation } from "../context/translation.js";
import { WireTransferDetails } from "../ui/WireTransferDetails.js";
@@ -120,6 +121,13 @@ export interface MoneyInScreenProps {
isOrdersLoading?: boolean;
isTemplatesLoading?: boolean;
onAddAccount?: () => void;
+ onLoadBankMonitoring?: (
+ hWire: string,
+ ) => Promise<TalerMerchantApi.BankAccountDetail>;
+ onSaveBankMonitoring?: (
+ hWire: string,
+ details: TalerMerchantApi.AccountPatchDetails,
+ ) => Promise<void>;
onDeleteAccount?: (
account: PayoutAccount,
challengeIds?: string[],
@@ -163,6 +171,8 @@ export function MoneyInScreen({
isOrdersLoading = false,
isTemplatesLoading = false,
onAddAccount,
+ onLoadBankMonitoring,
+ onSaveBankMonitoring,
onDeleteAccount,
onReactivateAccount,
onLaunchKycUrl,
@@ -173,6 +183,9 @@ export function MoneyInScreen({
const actionColumnLabel = t`Action`;
const [, setLocation] = useLocation();
const liveAccounts = accountsResource?.data ?? accounts;
+ const [monitoringAccount, setMonitoringAccount] = useState<PayoutAccount>();
+ const [showAdvancedAccountActions, setShowAdvancedAccountActions] =
+ useState(false);
const [deletingAccount, setDeletingAccount] = useState<
PayoutAccount | undefined
>(undefined);
@@ -780,11 +793,12 @@ export function MoneyInScreen({
<div class="relative shrink-0">
<button
type="button"
- onClick={() =>
+ onClick={() => {
+ setShowAdvancedAccountActions(false);
setOpenAccountMenuId(
openAccountMenuId === acc.id ? undefined : acc.id,
- )
- }
+ );
+ }}
class="flex h-8 w-8 items-center justify-center rounded-md text-lg font-bold text-gray-500 hover:bg-gray-100 hover:text-gray-800"
aria-label={
acc.owner
@@ -801,6 +815,41 @@ export function MoneyInScreen({
role="menu"
class="absolute right-0 z-20 mt-1 w-40 rounded-lg border border-gray-200 bg-white py-1 shadow-lg"
>
+ {acc.hWire &&
+ onLoadBankMonitoring &&
+ onSaveBankMonitoring && (
+ <>
+ <button
+ type="button"
+ role="menuitem"
+ aria-expanded={showAdvancedAccountActions}
+ class="flex w-full items-center justify-between gap-2 px-3 py-2 text-left text-xs font-semibold text-gray-700 hover:bg-gray-50"
+ onClick={() =>
+ setShowAdvancedAccountActions(
+ !showAdvancedAccountActions,
+ )
+ }
+ >
+ {t`Advanced settings`}
+ <span aria-hidden="true">
+ {showAdvancedAccountActions ? "▾" : "▸"}
+ </span>
+ </button>
+ {showAdvancedAccountActions && (
+ <button
+ type="button"
+ role="menuitem"
+ class="block w-full py-2 pl-6 pr-3 text-left text-xs font-semibold text-gray-700 hover:bg-gray-50"
+ onClick={() => {
+ setOpenAccountMenuId(undefined);
+ setMonitoringAccount(acc);
+ }}
+ >
+ {t`Bank monitoring`}
+ </button>
+ )}
+ </>
+ )}
{isInactive ? (
<button
type="button"
@@ -1237,6 +1286,17 @@ export function MoneyInScreen({
)}
</div>
)}
+ {monitoringAccount?.hWire &&
+ onLoadBankMonitoring &&
+ onSaveBankMonitoring && (
+ <BankMonitoringDialog
+ key={monitoringAccount.hWire}
+ account={monitoringAccount}
+ onLoad={onLoadBankMonitoring}
+ onSave={onSaveBankMonitoring}
+ onClose={() => setMonitoringAccount(undefined)}
+ />
+ )}
{/* Delete Account Modal */}
{deletingAccount && (
// eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions -- a dialog closing on Escape is the pattern the rule exists to ask for; it just does not count `dialog` as interactive.
diff --git a/packages/taler-merchant-webui/src/screens/bankMonitoring.test.tsx b/packages/taler-merchant-webui/src/screens/bankMonitoring.test.tsx
@@ -0,0 +1,398 @@
+/*
+ This file is part of GNU Taler
+ (C) 2026 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import assert from "node:assert";
+import test from "node:test";
+import { render } from "preact";
+import { act } from "preact/test-utils";
+import { AppContent } from "../App.js";
+import { Router } from "wouter-preact";
+import { mutate, unstable_serialize } from "swr";
+import { cache } from "swr/_internal";
+import { useHttpLibForTesting as setTestHttpLib } from "../api/client.js";
+import {
+ FakeHttpLib,
+ noContent,
+ ok,
+ talerError,
+} from "../testing/fake-http.js";
+import {
+ customBackendUrlStore,
+ session,
+ signIn,
+ signOut,
+} from "../stores/session.js";
+import type { PayoutAccount } from "../types/domain.js";
+
+const account: PayoutAccount = {
+ id: "A".repeat(103),
+ hWire: "A".repeat(103),
+ account: "payto://iban/DE89370400440532013000?receiver-name=Cafe",
+ owner: "Cafe",
+ status: "ready",
+ active: true,
+};
+
+async function flush() {
+ await act(async () => {
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ });
+}
+function findButton(label: string) {
+ return Array.from(document.querySelectorAll("button")).find(
+ (b) => b.textContent?.trim() === label,
+ );
+}
+async function click(label: string) {
+ const button = findButton(label);
+ assert.ok(button, `Missing button: ${label}`);
+ await act(async () => button.click());
+ await flush();
+}
+function field(id: string, value: string) {
+ const element = document.getElementById(id) as
+ | HTMLInputElement
+ | HTMLSelectElement;
+ assert.ok(element, `Missing field ${id}`);
+ act(() => {
+ element.value = value;
+ element.dispatchEvent(
+ new Event(element.tagName === "SELECT" ? "change" : "input", {
+ bubbles: true,
+ }),
+ );
+ });
+}
+async function toggle() {
+ const checkbox = document.getElementById(
+ "bank-monitoring-enabled",
+ ) as HTMLInputElement;
+ assert.ok(checkbox);
+ await act(async () => {
+ checkbox.checked = !checkbox.checked;
+ checkbox.dispatchEvent(new Event("change", { bubbles: true }));
+ });
+}
+async function submit() {
+ const form = document.querySelector('[role="dialog"] form');
+ assert.ok(form);
+ await act(async () => {
+ form.dispatchEvent(
+ new Event("submit", { bubbles: true, cancelable: true }),
+ );
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ });
+ await flush();
+}
+
+async function setup(
+ url: string | undefined = "https://bank.example/history/",
+) {
+ const previousBackend = customBackendUrlStore.get();
+ let settings = {
+ payto_uri: account.account,
+ h_wire: account.hWire,
+ salt: "B".repeat(103),
+ active: true,
+ credit_facade_url: url || undefined,
+ extra_wire_subject_metadata: "CAFE",
+ };
+ let failLoad = false;
+ let failSave = false;
+ const patches: Record<string, unknown>[] = [];
+ const http = new FakeHttpLib().handle((request) => {
+ if (
+ request.method === "GET" &&
+ new URL(request.url).pathname.endsWith("/private/accounts")
+ ) {
+ return ok({
+ accounts: [
+ { payto_uri: account.account, h_wire: account.hWire, active: true },
+ ],
+ });
+ }
+ if (
+ !new URL(request.url).pathname.endsWith(
+ `/private/accounts/${account.hWire}`,
+ )
+ )
+ return undefined;
+ if (request.method === "GET")
+ return failLoad ? { status: 500, body: talerError() } : ok(settings);
+ if (request.method === "PATCH") {
+ const body = JSON.parse(JSON.stringify(request.body));
+ patches.push(body);
+ if (failSave) return { status: 500, body: talerError() };
+ settings = { ...settings, credit_facade_url: body.credit_facade_url };
+ return noContent();
+ }
+ return undefined;
+ });
+ const restoreHttp = setTestHttpLib(http);
+ signIn("cafe", "secret-token:portal", "https://merchant.example/");
+ const container = document.createElement("div");
+ document.body.append(container);
+ // Seed the list because the Node renderer disables SWR subscriptions.
+ // The dialog's GET and PATCH still go through the real account hook and
+ // HTTP client, and AppContent supplies the menu callbacks.
+ const listKey = [
+ "listBankAccounts",
+ "https://merchant.example/",
+ "cafe",
+ session.value.token,
+ ];
+ await mutate(
+ listKey,
+ [{ payto_uri: account.account, h_wire: account.hWire, active: true }],
+ false,
+ );
+ const kycKey = [
+ "instanceKycStatus",
+ "https://merchant.example/",
+ "cafe",
+ session.value.token,
+ ];
+ await mutate(kycKey, { kyc_data: [] }, false);
+ // The test dispatcher also disables the effect that completes initial
+ // revalidation. Mark these preloaded resources as finished, as the browser
+ // would after their list requests, without replacing the account actions.
+ for (const key of [listKey, kycKey]) {
+ const serialized = unstable_serialize(key);
+ cache.set(serialized, {
+ ...cache.get(serialized),
+ isLoading: false,
+ isValidating: false,
+ });
+ }
+ const locationHook = () =>
+ ["/money/payout-accounts", () => undefined] as const;
+ await act(async () =>
+ render(
+ <Router hook={locationHook as never}>
+ <AppContent />
+ </Router>,
+ container,
+ ),
+ );
+ async function toggleMenu() {
+ const actions = container.querySelector<HTMLButtonElement>(
+ '[aria-label="Actions for bank account Cafe"]',
+ );
+ assert.ok(actions, container.textContent ?? "Empty screen");
+ await act(async () => actions.click());
+ }
+ async function toggleAdvanced() {
+ const advanced = container.querySelector<HTMLButtonElement>(
+ '[role="menuitem"][aria-expanded]',
+ );
+ assert.ok(advanced);
+ assert.match(advanced.textContent ?? "", /Advanced settings/);
+ await act(async () => advanced.click());
+ }
+ return {
+ patches,
+ http,
+ toggleMenu,
+ toggleAdvanced,
+ setFailLoad(value: boolean) {
+ failLoad = value;
+ },
+ setFailSave(value: boolean) {
+ failSave = value;
+ },
+ async open() {
+ await toggleMenu();
+ assert.equal(findButton("Bank monitoring"), undefined);
+ await toggleAdvanced();
+ await click("Bank monitoring");
+ await flush();
+ },
+ async close() {
+ await mutate(listKey, undefined, false);
+ await mutate(kycKey, undefined, false);
+ render(null, container);
+ container.remove();
+ restoreHttp();
+ signOut();
+ customBackendUrlStore.set(previousBackend);
+ },
+ };
+}
+
+test("bank monitoring is hidden until advanced account settings are expanded", async () => {
+ const view = await setup();
+ try {
+ await view.toggleMenu();
+ assert.equal(findButton("Bank monitoring"), undefined);
+ assert.ok(findButton("Delete"));
+ assert.ok(
+ document.querySelector('[role="menuitem"][aria-expanded="false"]'),
+ );
+ await view.toggleAdvanced();
+ assert.ok(findButton("Bank monitoring"));
+ assert.ok(
+ document.querySelector('[role="menuitem"][aria-expanded="true"]'),
+ );
+ await view.toggleAdvanced();
+ assert.equal(findButton("Bank monitoring"), undefined);
+ await view.toggleAdvanced();
+ await view.toggleMenu();
+ await view.toggleMenu();
+ assert.equal(findButton("Bank monitoring"), undefined);
+ } finally {
+ await view.close();
+ }
+});
+
+test("payout account menu loads monitoring, preserves hidden credentials and wire metadata", async () => {
+ const view = await setup();
+ try {
+ await view.open();
+ assert.equal(
+ (document.getElementById("bank-monitoring-url") as HTMLInputElement)
+ .value,
+ "https://bank.example/history/",
+ );
+ assert.equal(
+ (document.getElementById("bank-monitoring-auth") as HTMLSelectElement)
+ .value,
+ "keep",
+ );
+ field("bank-monitoring-url", "https://bank.example/new-history/");
+ await submit();
+ assert.deepEqual(view.patches, [
+ {
+ credit_facade_url: "https://bank.example/new-history/",
+ extra_wire_subject_metadata: "CAFE",
+ },
+ ]);
+ assert.equal(document.querySelector('[role="dialog"]'), null);
+ await view.open();
+ assert.equal(
+ (document.getElementById("bank-monitoring-url") as HTMLInputElement)
+ .value,
+ "https://bank.example/new-history/",
+ );
+ await toggle();
+ await submit();
+ assert.deepEqual(view.patches[1], {
+ credit_facade_credentials: { type: "none" },
+ extra_wire_subject_metadata: "CAFE",
+ });
+ } finally {
+ await view.close();
+ }
+});
+
+test("monitoring can be enabled with bearer or basic credentials and without authentication", async () => {
+ const view = await setup("");
+ try {
+ await view.open();
+ await toggle();
+ field("bank-monitoring-url", "https://bank.example/history/");
+ field("bank-monitoring-auth", "bearer");
+ field("bank-monitoring-token", "bank-secret-token");
+ assert.equal(
+ (document.getElementById("bank-monitoring-token") as HTMLInputElement)
+ .type,
+ "password",
+ );
+ await submit();
+ assert.deepEqual(view.patches[0]?.credit_facade_credentials, {
+ type: "bearer",
+ token: "bank-secret-token",
+ });
+ await view.open();
+ assert.equal(document.getElementById("bank-monitoring-token"), null);
+ field("bank-monitoring-auth", "basic");
+ field("bank-monitoring-username", "cafe-bank");
+ field("bank-monitoring-password", "bank-secret-password");
+ await submit();
+ assert.deepEqual(view.patches[1]?.credit_facade_credentials, {
+ type: "basic",
+ username: "cafe-bank",
+ password: "bank-secret-password",
+ });
+ await view.open();
+ field("bank-monitoring-auth", "none");
+ await submit();
+ assert.deepEqual(view.patches[2]?.credit_facade_credentials, {
+ type: "none",
+ });
+ for (const storage of [localStorage, sessionStorage]) {
+ for (let i = 0; i < storage.length; i++)
+ assert.doesNotMatch(
+ storage.getItem(storage.key(i)!) ?? "",
+ /bank-secret/,
+ );
+ }
+ } finally {
+ await view.close();
+ }
+});
+
+test("monitoring load and save failures remain visible and can be retried", async () => {
+ const view = await setup();
+ try {
+ view.setFailLoad(true);
+ await view.open();
+ assert.match(
+ document.body.textContent ?? "",
+ /settings could not be loaded/,
+ );
+ assert.equal(document.querySelector('[role="dialog"] form'), null);
+ view.setFailLoad(false);
+ await click("Retry");
+ view.setFailSave(true);
+ field("bank-monitoring-url", "https://bank.example/retry/");
+ await submit();
+ assert.match(
+ document.body.textContent ?? "",
+ /settings could not be saved/,
+ );
+ assert.equal(
+ (document.getElementById("bank-monitoring-url") as HTMLInputElement)
+ .value,
+ "https://bank.example/retry/",
+ );
+ view.setFailSave(false);
+ await submit();
+ assert.equal(document.querySelector('[role="dialog"]'), null);
+ assert.equal(view.patches.length, 2);
+ } finally {
+ await view.close();
+ }
+});
+
+test("monitoring rejects non-HTTP URLs and incomplete replacement credentials", async () => {
+ const view = await setup();
+ try {
+ await view.open();
+ field("bank-monitoring-url", "file:///tmp/history");
+ await submit();
+ assert.equal(view.patches.length, 0);
+ assert.match(document.body.textContent ?? "", /HTTP or HTTPS/);
+ field("bank-monitoring-url", "https://bank.example/history/");
+ field("bank-monitoring-auth", "bearer");
+ await submit();
+ assert.equal(view.patches.length, 0);
+ assert.match(document.body.textContent ?? "", /Enter the credentials/);
+ await click("Cancel");
+ assert.equal(document.querySelector('[role="dialog"]'), null);
+ } finally {
+ await view.close();
+ }
+});