taler-typescript-core

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

commit 7445b88e1f7bbea8c087a3b76112d580d3286d23
parent 1dd74e2e3ed5296822bb12cfb508f05d288a1b78
Author: Florian Dold <dold@taler.net>
Date:   Mon, 24 Aug 2026 02:29:22 +0200

bank web UI: validate withdrawal state transitions

Diffstat:
Mpackages/libeufin-bank-webui/src/components/QR.tsx | 2+-
Mpackages/libeufin-bank-webui/src/pages/OperationState/state.ts | 29+++++++++++++++++------------
Mpackages/libeufin-bank-webui/src/pages/OperationState/views.tsx | 31++++++++++++++++++++++++-------
Mpackages/libeufin-bank-webui/src/pages/WalletWithdrawForm.tsx | 18+++++++++++++-----
Mpackages/libeufin-bank-webui/src/pages/WithdrawalConfirmationQuestion.tsx | 17+++++++++++++++--
5 files changed, 70 insertions(+), 27 deletions(-)

diff --git a/packages/libeufin-bank-webui/src/components/QR.tsx b/packages/libeufin-bank-webui/src/components/QR.tsx @@ -28,7 +28,7 @@ export function QR({ text }: { text: string }): VNode { divRef.current.innerHTML = qr.createSvgTag({ scalable: true, }); - }); + }, [text]); return ( <div class="flex flex-col "> diff --git a/packages/libeufin-bank-webui/src/pages/OperationState/state.ts b/packages/libeufin-bank-webui/src/pages/OperationState/state.ts @@ -31,7 +31,7 @@ import { useTranslationContext, utils, } from "@gnu-taler/web-util/browser"; -import { useEffect, useState } from "preact/hooks"; +import { useEffect, useRef, useState } from "preact/hooks"; import { useSettingsContext } from "../../context/settings.js"; import { useWithdrawalDetails } from "../../hooks/account.js"; import { useBankState } from "../../hooks/bank-state.js"; @@ -58,8 +58,10 @@ export function useComponentState({ TalerCoreBankErrorsByMethod<"createWithdrawal"> | undefined >(); const amount = settings.defaultSuggestedAmount; + const creationGeneration = useRef(0); async function doSilentStart() { + const generation = ++creationGeneration.current; // FIXME: if amount is not enough use balance const parsedAmount = Amounts.parseOrThrow(`${config.currency}:${amount}`); if (!creds) return; @@ -73,6 +75,7 @@ export function useComponentState({ }; const resp = await bank.createWithdrawal(creds, params); + if (generation !== creationGeneration.current) return; if (resp.type === "fail") { setFailure(resp); return; @@ -82,12 +85,14 @@ export function useComponentState({ const withdrawalOperationId = bankState.currentWithdrawalOperationId; useEffect(() => { - // FIXME: should we tell the user about current operation? - // if (withdrawalOperationId === undefined) { - // override existing operation - doSilentStart(); - // } - }, [preference.fastWithdrawalForm, amount]); + if (withdrawalOperationId === undefined) { + setFailure(undefined); + void doSilentStart(); + } + return () => { + creationGeneration.current++; + }; + }, [withdrawalOperationId, preference.fastWithdrawalForm, amount]); if (failure) { return { @@ -109,22 +114,22 @@ export function useComponentState({ withdrawalOperationId: withdrawalOperationId, }; - return (): utils.RecursiveState<State> => { + return function WithdrawalState(): utils.RecursiveState<State> { const result = useWithdrawalDetails(withdrawalOperationId); const { i18n } = useTranslationContext(); const shouldCreateNewOperation = result && - (result instanceof TalerError || - result.type === "fail" || + !(result instanceof TalerError) && + (result.type === "fail" || result.body.status === "aborted" || result.body.status === "confirmed"); useEffect(() => { if (shouldCreateNewOperation) { - doSilentStart(); + updateBankState("currentWithdrawalOperationId", undefined); } - }, [shouldCreateNewOperation]); + }, [shouldCreateNewOperation, withdrawalOperationId]); if (!result) { return { status: "loading", diff --git a/packages/libeufin-bank-webui/src/pages/OperationState/views.tsx b/packages/libeufin-bank-webui/src/pages/OperationState/views.tsx @@ -180,7 +180,14 @@ export function NeedConfirmationView({ case PaytoType.TalerReserveHttp: case PaytoType.TalerReserve: { // FIXME: support wire transfer to wallet - return <div>not yet supported</div>; + return ( + <div> + <i18n.Translate> + Transfers to this wallet account type are not + supported yet. + </i18n.Translate> + </div> + ); } case PaytoType.IBAN: { const name = details.account.params["receiver-name"]; @@ -350,7 +357,13 @@ export function NeedConfirmationView({ ); } case PaytoType.Void: - return <p>void account not suported</p>; + return ( + <p> + <i18n.Translate> + Void payment targets are not supported. + </i18n.Translate> + </p> + ); default: { assertUnreachable(details.account); } @@ -461,7 +474,12 @@ export function FailedView({ error }: State.Failed) { } export function AbortedView() { - return <div>aborted</div>; + const { i18n } = useTranslationContext(); + return ( + <div> + <i18n.Translate>Withdrawal aborted</i18n.Translate> + </div> + ); } export function ConfirmedView({ routeClose }: State.Confirmed) { @@ -509,7 +527,7 @@ export function ConfirmedView({ routeClose }: State.Confirmed) { <span class="flex flex-grow flex-col"> <span class="text-sm text-black font-medium leading-6 " - id="availability-label" + id="hide-withdrawal-success-label" > <i18n.Translate>Do not show this again</i18n.Translate> </span> @@ -520,9 +538,8 @@ export function ConfirmedView({ routeClose }: State.Confirmed) { data-enabled={!settings.showWithdrawalSuccess} class="bg-indigo-600 data-[enabled=false]:bg-gray-200 relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-indigo-600 focus:ring-offset-2" role="switch" - aria-checked="false" - aria-labelledby="availability-label" - aria-describedby="availability-description" + aria-checked={!settings.showWithdrawalSuccess} + aria-labelledby="hide-withdrawal-success-label" onClick={() => { updateSettings( "showWithdrawalSuccess", diff --git a/packages/libeufin-bank-webui/src/pages/WalletWithdrawForm.tsx b/packages/libeufin-bank-webui/src/pages/WalletWithdrawForm.tsx @@ -51,6 +51,13 @@ const TALER_SCREEN_ID = 112; const RefAmount = forwardRef(InputAmount); +export function isWithdrawalWithinLimit( + limit: IntAmountJson, + amount: AmountJson, +): boolean { + return !limit.negative && !limit.saturated && Amounts.cmp(limit, amount) >= 0; +} + function OldWithdrawalForm({ onOperationCreated, limit, @@ -94,7 +101,7 @@ function OldWithdrawalForm({ ? i18n.str`Required` : !parsedAmount ? i18n.str`Invalid` - : Amounts.cmp(limit, parsedAmount) === -1 + : !isWithdrawalWithinLimit(limit, parsedAmount) ? i18n.str`Balance is not enough` : undefined, }); @@ -115,10 +122,11 @@ function OldWithdrawalForm({ onSuccess: (success) => { const uri = TalerUris.parse(success.taler_withdraw_uri); if (uri.tag === "error" || uri.value.type !== TalerUriAction.Withdraw) { - displayError( - i18n.str`The server replied with an invalid taler://withdraw URI`, - i18n.str`Withdraw URI: ${success.taler_withdraw_uri}`, - ); + // Translators: taler://withdraw is a protocol URI, not an email or phone address. + const invalidWithdrawUriTitle = i18n.str`The server replied with an invalid taler://withdraw URI`; + // Translators: The placeholder is the malformed taler://withdraw URI returned by the bank. + const invalidWithdrawUriDetail = i18n.str`Withdraw URI: ${success.taler_withdraw_uri}`; + displayError(invalidWithdrawUriTitle, invalidWithdrawUriDetail); return; } else { updateBankState( diff --git a/packages/libeufin-bank-webui/src/pages/WithdrawalConfirmationQuestion.tsx b/packages/libeufin-bank-webui/src/pages/WithdrawalConfirmationQuestion.tsx @@ -199,7 +199,14 @@ export function WithdrawalConfirmationQuestion({ case PaytoType.TalerReserveHttp: case PaytoType.TalerReserve: { // FIXME: support wire transfer to wallet - return <div>not yet supported</div>; + return ( + <div> + <i18n.Translate> + Transfers to this wallet account type are + not supported yet. + </i18n.Translate> + </div> + ); } case PaytoType.IBAN: { const name = @@ -377,7 +384,13 @@ export function WithdrawalConfirmationQuestion({ ); } case PaytoType.Void: - return <p>void payto is not supported</p>; + return ( + <p> + <i18n.Translate> + Void payment targets are not supported. + </i18n.Translate> + </p> + ); default: { assertUnreachable(details.account); }