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:
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);
}