taler-typescript-core

Wallet core logic and WebUIs for various components
Log | Files | Refs | Submodules | README | LICENSE

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:
Mpackages/taler-merchant-webui/src/App.tsx | 4++++
Mpackages/taler-merchant-webui/src/api/hooks/usePayoutAccounts.ts | 29++++++++++++++++++++++++++++-
Apackages/taler-merchant-webui/src/screens/BankMonitoringDialog.tsx | 308+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-merchant-webui/src/screens/MoneyInScreen.tsx | 66+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Apackages/taler-merchant-webui/src/screens/bankMonitoring.test.tsx | 398+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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(); + } +});