commit b6fbc0166e877001b8e48663fd7b3e437f1805fb
parent 7ed51e7b87547e731e73339eca69ed35ff2dd5da
Author: Florian Dold <dold@taler.net>
Date: Mon, 24 Aug 2026 02:31:00 +0200
bank web UI: use one component for withdrawal operations
Diffstat:
11 files changed, 577 insertions(+), 1235 deletions(-)
diff --git a/packages/libeufin-bank-webui/src/Routing.tsx b/packages/libeufin-bank-webui/src/Routing.tsx
@@ -196,7 +196,6 @@ function PublicRounting({
return (
<WithdrawalOperationPage
operationId={location.values.wopid}
- origin="from-wallet-ui"
onOperationAborted={() => navigateTo(publicPages.login.url({}))}
routeClose={publicPages.login}
/>
@@ -319,7 +318,6 @@ function PrivateRouting({
return (
<WithdrawalOperationPage
operationId={location.values.wopid}
- origin="from-wallet-ui"
onOperationAborted={() => navigateTo(privatePages.home.url({}))}
routeClose={privatePages.home}
/>
@@ -329,7 +327,6 @@ function PrivateRouting({
return (
<WithdrawalOperationPage
operationId={location.values.wopid}
- origin="from-bank-ui"
onOperationAborted={() => navigateTo(privatePages.home.url({}))}
routeClose={privatePages.home}
/>
diff --git a/packages/libeufin-bank-webui/src/pages/OperationState/OperationState.test.ts b/packages/libeufin-bank-webui/src/pages/OperationState/OperationState.test.ts
@@ -22,6 +22,11 @@
import assert from "node:assert";
import { describe, it } from "node:test";
import { isWithdrawalWithinLimit } from "../WalletWithdrawForm.js";
+import { IntAmounts } from "../regional/CreateCashout.js";
+import {
+ buildWithdrawalOperationState,
+ isTerminalWithdrawalResult,
+} from "./state.js";
describe("withdrawal limits", () => {
const amount = { currency: "EUR", value: 5, fraction: 0 };
@@ -60,3 +65,130 @@ describe("withdrawal limits", () => {
);
});
});
+
+describe("withdrawal operation lifecycle", () => {
+ const routeClose = {} as any;
+ const common = {
+ operationId: "withdrawal-1",
+ bankIntegrationApiBaseUrl: "https://bank.example/api/" as any,
+ routeClose,
+ onAbort: () => undefined,
+ loadingErrorTitle: "loading failed" as any,
+ onRetry: () => undefined,
+ };
+
+ function success(body: Record<string, unknown>) {
+ return { type: "ok", body } as any;
+ }
+
+ it("maps pending operations to the wallet handoff state", () => {
+ const state = buildWithdrawalOperationState({
+ ...common,
+ result: success({ status: "pending", username: "alice" }),
+ });
+
+ assert.equal(state.status, "ready");
+ assert.deepEqual(state.status === "ready" ? state.uri : undefined, {
+ type: "withdraw",
+ bankIntegrationApiBaseUrl: "https://bank.example/api/",
+ withdrawalOperationId: "withdrawal-1",
+ });
+ });
+
+ it("preserves aborted and confirmed terminal states", () => {
+ const aborted = success({ status: "aborted", username: "alice" });
+ const confirmed = success({ status: "confirmed", username: "alice" });
+
+ assert.equal(
+ buildWithdrawalOperationState({ ...common, result: aborted }).status,
+ "aborted",
+ );
+ assert.equal(
+ buildWithdrawalOperationState({ ...common, result: confirmed }).status,
+ "confirmed",
+ );
+ assert.equal(isTerminalWithdrawalResult(aborted), true);
+ assert.equal(isTerminalWithdrawalResult(confirmed), true);
+ });
+
+ it("rejects incomplete or malformed wallet selections", () => {
+ const noReserve = success({ status: "selected", username: "alice" });
+ const badPayto = success({
+ status: "selected",
+ username: "alice",
+ selected_reserve_pub: "reserve-pub",
+ selected_exchange_account: "not-a-payto",
+ });
+
+ assert.equal(
+ buildWithdrawalOperationState({ ...common, result: noReserve }).status,
+ "invalid-reserve",
+ );
+ assert.equal(
+ buildWithdrawalOperationState({ ...common, result: badPayto }).status,
+ "invalid-payto",
+ );
+ assert.equal(isTerminalWithdrawalResult(noReserve), false);
+ });
+
+ it("maps a complete wallet selection to bank confirmation", () => {
+ const state = buildWithdrawalOperationState({
+ ...common,
+ result: success({
+ status: "selected",
+ username: "alice",
+ amount: "EUR:5",
+ selected_reserve_pub: "reserve-pub",
+ selected_exchange_account:
+ "payto://iban/DE02120300000000202051?receiver-name=Exchange",
+ }),
+ });
+
+ assert.equal(state.status, "need-confirmation");
+ if (state.status !== "need-confirmation") return;
+ assert.equal(state.account, "alice");
+ assert.equal(state.details.reserve, "reserve-pub");
+ assert.equal(state.details.account.targetType, "iban");
+ assert.deepEqual(state.details.amount, {
+ currency: "EUR",
+ value: 5,
+ fraction: 0,
+ });
+ });
+});
+
+describe("signed account balances", () => {
+ const two = { currency: "EUR", value: 2, fraction: 0 };
+ const five = { currency: "EUR", value: 5, fraction: 0 };
+
+ it("crosses zero while adding and subtracting amounts", () => {
+ const credit = IntAmounts.toIntAmount(five);
+ assert.deepEqual(credit.deduce(two).result, {
+ ...two,
+ value: 3,
+ negative: false,
+ saturated: false,
+ });
+ const debit = credit.deduce({ ...two, value: 8 });
+ assert.equal(debit.result.value, 3);
+ assert.equal(debit.result.negative, true);
+ assert.equal(debit.increment(five).result.negative, false);
+ });
+
+ it("merges signed amounts and clamps negative results to zero", () => {
+ const debit = IntAmounts.toIntAmount(five, true);
+ assert.equal(debit.getResultZeroIfNegative().value, 0);
+ assert.equal(
+ debit.merge({ ...two, negative: true, saturated: false }).result.value,
+ 7,
+ );
+ assert.equal(
+ IntAmounts.toIntAmount(two).merge({
+ ...five,
+ negative: false,
+ saturated: false,
+ }).result.value,
+ 7,
+ );
+ });
+});
diff --git a/packages/libeufin-bank-webui/src/pages/OperationState/index.ts b/packages/libeufin-bank-webui/src/pages/OperationState/index.ts
@@ -22,16 +22,12 @@ import {
TranslatedString,
TalerWithdrawUri,
} from "@gnu-taler/taler-util";
-import {
- ErrorLoading,
- Loading,
- RouteDefinition,
- utils,
-} from "@gnu-taler/web-util/browser";
+import { Loading, RouteDefinition, utils } from "@gnu-taler/web-util/browser";
import { VNode } from "preact";
import { Paytos } from "@gnu-taler/taler-util";
-import { useComponentState } from "./state.js";
+import { useComponentState, useWithdrawalOperationState } from "./state.js";
+import { RetryableError } from "../../components/RetryableError.js";
import {
AbortedView,
ConfirmedView,
@@ -49,6 +45,11 @@ export interface Props {
focus?: boolean;
}
+export interface WithdrawalOperationProps extends Props {
+ operationId: string;
+ clearWhenTerminal?: boolean;
+}
+
export type State =
| State.Loading
| State.LoadingError
@@ -76,6 +77,7 @@ export namespace State {
status: "loading-error";
error: TalerError;
title: TranslatedString;
+ onRetry(): void;
}
/**
@@ -150,7 +152,7 @@ const viewMapping: utils.StateViewMap<State> = {
"need-confirmation": NeedConfirmationView,
aborted: AbortedView,
confirmed: ConfirmedView,
- "loading-error": ErrorLoading,
+ "loading-error": RetryableError,
ready: ReadyView,
};
@@ -158,3 +160,9 @@ export const OperationState: (p: Props) => VNode = utils.compose(
(p: Props) => useComponentState(p),
viewMapping,
);
+
+export const WithdrawalOperation: (p: WithdrawalOperationProps) => VNode =
+ utils.compose(
+ (p: WithdrawalOperationProps) => useWithdrawalOperationState(p),
+ viewMapping,
+ );
diff --git a/packages/libeufin-bank-webui/src/pages/OperationState/state.ts b/packages/libeufin-bank-webui/src/pages/OperationState/state.ts
@@ -23,7 +23,6 @@ import {
TalerCorebankApi,
TalerError,
TalerUriAction,
- TalerWithdrawUri,
assertUnreachable,
} from "@gnu-taler/taler-util";
import {
@@ -31,13 +30,126 @@ import {
useTranslationContext,
utils,
} from "@gnu-taler/web-util/browser";
-import { useEffect, useRef, useState } from "preact/hooks";
+import { useCallback, useEffect, useRef, useState } from "preact/hooks";
import { useSettingsContext } from "../../context/settings.js";
-import { useWithdrawalDetails } from "../../hooks/account.js";
+import {
+ revalidateWithdrawalDetails,
+ useWithdrawalDetails,
+} from "../../hooks/account.js";
import { useBankState } from "../../hooks/bank-state.js";
import { usePreferences } from "../../hooks/preferences.js";
import { useSessionState } from "../../hooks/session.js";
-import { Props, State } from "./index.js";
+import { Props, State, WithdrawalOperationProps } from "./index.js";
+
+type WithdrawalDetailsResult = Exclude<
+ ReturnType<typeof useWithdrawalDetails>,
+ undefined
+>;
+
+interface WithdrawalStateInput extends Omit<
+ WithdrawalOperationProps,
+ "clearWhenTerminal"
+> {
+ result: WithdrawalDetailsResult;
+ bankIntegrationApiBaseUrl: HostPortPath;
+ loadingErrorTitle: State.LoadingError["title"];
+ onRetry(): void;
+}
+
+export function isTerminalWithdrawalResult(
+ result: WithdrawalDetailsResult,
+): boolean {
+ return (
+ !(result instanceof TalerError) &&
+ (result.type === "fail" ||
+ result.body.status === "aborted" ||
+ result.body.status === "confirmed")
+ );
+}
+
+export function buildWithdrawalOperationState({
+ result,
+ operationId,
+ bankIntegrationApiBaseUrl,
+ routeClose,
+ onAbort,
+ focus,
+ loadingErrorTitle,
+ onRetry,
+}: WithdrawalStateInput): State {
+ if (result instanceof TalerError) {
+ return {
+ status: "loading-error",
+ error: result,
+ title: loadingErrorTitle,
+ onRetry,
+ };
+ }
+ if (result.type === "fail") {
+ switch (result.case) {
+ case HttpStatusCode.BadRequest:
+ case HttpStatusCode.NotFound:
+ return { status: "aborted", error: undefined, routeClose };
+ default:
+ assertUnreachable(result);
+ }
+ }
+
+ const { body: data } = result;
+ if (data.status === "aborted") {
+ return { status: "aborted", error: undefined, routeClose };
+ }
+ if (data.status === "confirmed") {
+ return { status: "confirmed", error: undefined, routeClose };
+ }
+
+ if (data.status === "pending") {
+ return {
+ status: "ready",
+ error: undefined,
+ uri: {
+ type: TalerUriAction.Withdraw,
+ bankIntegrationApiBaseUrl,
+ withdrawalOperationId: operationId,
+ },
+ routeClose,
+ focus,
+ operationId,
+ onAbort,
+ };
+ }
+ if (!data.selected_reserve_pub) {
+ return {
+ status: "invalid-reserve",
+ error: undefined,
+ reserve: data.selected_reserve_pub,
+ };
+ }
+
+ const account = !data.selected_exchange_account
+ ? undefined
+ : Paytos.fromString(data.selected_exchange_account);
+ if (!account || account.tag === "error" || !account.value.targetType) {
+ return {
+ status: "invalid-payto",
+ error: undefined,
+ payto: data.selected_exchange_account,
+ };
+ }
+ return {
+ status: "need-confirmation",
+ error: undefined,
+ details: {
+ account: account.value,
+ reserve: data.selected_reserve_pub,
+ username: data.username,
+ amount: !data.amount ? undefined : Amounts.parse(data.amount),
+ },
+ account: data.username,
+ operationId,
+ onAbort,
+ };
+}
export function useComponentState({
routeClose,
@@ -60,39 +172,49 @@ export function useComponentState({
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;
- const params: TalerCorebankApi.BankAccountCreateWithdrawalRequest =
- preference.fastWithdrawalForm
- ? {
- suggested_amount: Amounts.stringify(parsedAmount),
- }
- : {
- amount: Amounts.stringify(parsedAmount),
- };
-
- const resp = await bank.createWithdrawal(creds, params);
- if (generation !== creationGeneration.current) return;
- if (resp.type === "fail") {
- setFailure(resp);
- return;
- }
- updateBankState("currentWithdrawalOperationId", resp.body.withdrawal_id);
- }
+ const doSilentStart = useCallback(
+ async (generation: number) => {
+ // FIXME: if amount is not enough use balance
+ const parsedAmount = Amounts.parseOrThrow(`${config.currency}:${amount}`);
+ if (!creds) return;
+ const params: TalerCorebankApi.BankAccountCreateWithdrawalRequest =
+ preference.fastWithdrawalForm
+ ? {
+ suggested_amount: Amounts.stringify(parsedAmount),
+ }
+ : {
+ amount: Amounts.stringify(parsedAmount),
+ };
+
+ const resp = await bank.createWithdrawal(creds, params);
+ if (generation !== creationGeneration.current) return;
+ if (resp.type === "fail") {
+ setFailure(resp);
+ return;
+ }
+ updateBankState("currentWithdrawalOperationId", resp.body.withdrawal_id);
+ },
+ [
+ amount,
+ bank,
+ config.currency,
+ creds,
+ preference.fastWithdrawalForm,
+ updateBankState,
+ ],
+ );
const withdrawalOperationId = bankState.currentWithdrawalOperationId;
useEffect(() => {
+ const generation = ++creationGeneration.current;
if (withdrawalOperationId === undefined) {
setFailure(undefined);
- void doSilentStart();
+ void doSilentStart(generation);
}
return () => {
- creationGeneration.current++;
+ creationGeneration.current = generation + 1;
};
- }, [withdrawalOperationId, preference.fastWithdrawalForm, amount]);
+ }, [doSilentStart, withdrawalOperationId]);
if (failure) {
return {
@@ -108,123 +230,50 @@ export function useComponentState({
};
}
- const parsedUri: TalerWithdrawUri = {
- type: TalerUriAction.Withdraw,
- bankIntegrationApiBaseUrl: bank.getIntegrationAPI().href as HostPortPath,
- withdrawalOperationId: withdrawalOperationId,
- };
-
return function WithdrawalState(): utils.RecursiveState<State> {
- const result = useWithdrawalDetails(withdrawalOperationId);
- const { i18n } = useTranslationContext();
-
- const shouldCreateNewOperation =
- result &&
- !(result instanceof TalerError) &&
- (result.type === "fail" ||
- result.body.status === "aborted" ||
- result.body.status === "confirmed");
-
- useEffect(() => {
- if (shouldCreateNewOperation) {
- updateBankState("currentWithdrawalOperationId", undefined);
- }
- }, [shouldCreateNewOperation, withdrawalOperationId]);
- if (!result) {
- return {
- status: "loading",
- error: undefined,
- };
- }
- if (result instanceof TalerError) {
- return {
- status: "loading-error",
- error: result,
- title: i18n.str`Failed to load withdrawal details.`,
- };
- }
-
- if (result.type === "fail") {
- switch (result.case) {
- case HttpStatusCode.BadRequest:
- case HttpStatusCode.NotFound: {
- return {
- status: "aborted",
- error: undefined,
- routeClose,
- };
- }
- default:
- assertUnreachable(result);
- }
- }
-
- const { body: data } = result;
- if (data.status === "aborted") {
- return {
- status: "aborted",
- error: undefined,
- routeClose,
- };
- }
-
- if (data.status === "confirmed") {
- if (!preference.showWithdrawalSuccess) {
- updateBankState("currentWithdrawalOperationId", undefined);
- // onClose()
- }
- return {
- status: "confirmed",
- error: undefined,
- routeClose,
- };
- }
-
- if (data.status === "pending") {
- return {
- status: "ready",
- error: undefined,
- uri: parsedUri,
- routeClose,
- focus,
- operationId: withdrawalOperationId,
- onAbort,
- };
- }
+ return useWithdrawalOperationState({
+ operationId: withdrawalOperationId,
+ routeClose,
+ onAbort,
+ focus,
+ clearWhenTerminal: true,
+ });
+ };
+}
- if (!data.selected_reserve_pub) {
- return {
- status: "invalid-reserve",
- error: undefined,
- reserve: data.selected_reserve_pub,
- };
- }
+export function useWithdrawalOperationState({
+ operationId,
+ routeClose,
+ onAbort,
+ focus,
+ clearWhenTerminal = false,
+}: WithdrawalOperationProps): State {
+ const result = useWithdrawalDetails(operationId);
+ const { i18n } = useTranslationContext();
+ const [, updateBankState] = useBankState();
+ const {
+ lib: { bank },
+ } = useBankCoreApiContext();
- const account = !data.selected_exchange_account
- ? undefined
- : Paytos.fromString(data.selected_exchange_account);
+ const terminal = result !== undefined && isTerminalWithdrawalResult(result);
- if (!account || account.tag === "error" || !account.value.targetType) {
- return {
- status: "invalid-payto",
- error: undefined,
- payto: data.selected_exchange_account,
- };
+ useEffect(() => {
+ if (clearWhenTerminal && terminal) {
+ updateBankState("currentWithdrawalOperationId", undefined);
}
+ }, [clearWhenTerminal, terminal, updateBankState]);
- return {
- status: "need-confirmation",
- error: undefined,
- details: {
- account: account.value,
- reserve: data.selected_reserve_pub,
- username: data.username,
- amount: !data.amount ? undefined : Amounts.parse(data.amount),
- },
-
- account: data.username,
- operationId: withdrawalOperationId,
- onAbort,
- };
- };
+ if (!result) {
+ return { status: "loading", error: undefined };
+ }
+ return buildWithdrawalOperationState({
+ result,
+ operationId,
+ bankIntegrationApiBaseUrl: bank.getIntegrationAPI().href as HostPortPath,
+ loadingErrorTitle: i18n.str`Failed to load withdrawal details.`,
+ onRetry: () => void revalidateWithdrawalDetails(),
+ routeClose,
+ onAbort,
+ focus,
+ });
}
diff --git a/packages/libeufin-bank-webui/src/pages/OperationState/stories.tsx b/packages/libeufin-bank-webui/src/pages/OperationState/stories.tsx
@@ -20,8 +20,22 @@
*/
import * as tests from "@gnu-taler/web-util/testing";
-import { ReadyView } from "./views.js";
-import { HostPortPath, TalerUriAction } from "@gnu-taler/taler-util";
+import {
+ AbortedView,
+ ConfirmedView,
+ InvalidPaytoView,
+ InvalidReserveView,
+ InvalidWithdrawalView,
+ NeedConfirmationView,
+ ReadyView,
+} from "./views.js";
+import {
+ Amounts,
+ HostPortPath,
+ PaytoType,
+ Paytos,
+ TalerUriAction,
+} from "@gnu-taler/taler-util";
import { urlPattern } from "@gnu-taler/web-util/browser";
export default {
@@ -38,3 +52,154 @@ export const Ready = tests.createExample(ReadyView, {
routeClose: urlPattern(/.*/, () => "#"),
onAbort: () => undefined,
});
+
+export const Aborted = tests.createExample(AbortedView, {
+ routeClose: urlPattern(/.*/, () => "#"),
+});
+
+export const Confirmed = tests.createExample(ConfirmedView, {
+ routeClose: urlPattern(/.*/, () => "#"),
+});
+
+export const InvalidPayto = tests.createExample(InvalidPaytoView, {
+ payto: "not-a-payto-uri",
+});
+
+export const InvalidReserve = tests.createExample(InvalidReserveView, {
+ reserve: undefined,
+});
+
+export const InvalidWithdrawal = tests.createExample(InvalidWithdrawalView, {
+ uri: "not-a-taler-withdraw-uri",
+});
+
+const exchangeAccount = Paytos.fromString(
+ "payto://iban/DE02120300000000202051?receiver-name=Exchange",
+);
+if (exchangeAccount.tag === "error") {
+ throw new Error("The operation story contains an invalid payto URI");
+}
+
+export const NeedConfirmation = tests.createExample(
+ NeedConfirmationView,
+ {
+ account: "alice",
+ operationId: "withdrawal-1",
+ onAbort: () => undefined,
+ details: {
+ account: exchangeAccount.value,
+ reserve: "reserve-pub",
+ username: "alice",
+ amount: Amounts.parseOrThrow("EUR:5"),
+ },
+ },
+ { loggedIn: true },
+);
+
+const confirmationStory = {
+ account: "alice",
+ operationId: "withdrawal-1",
+ onAbort: () => undefined,
+};
+
+export const NeedTalerBankConfirmation = tests.createExample(
+ NeedConfirmationView,
+ {
+ ...confirmationStory,
+ details: {
+ account: {
+ targetType: PaytoType.TalerBank,
+ host: "bank.example",
+ account: "exchange",
+ params: { "receiver-name": "Exchange" },
+ } as any,
+ reserve: "reserve-pub",
+ username: "alice",
+ },
+ },
+ { loggedIn: true },
+);
+
+export const NeedBitcoinConfirmation = tests.createExample(
+ NeedConfirmationView,
+ {
+ ...confirmationStory,
+ details: {
+ account: {
+ targetType: PaytoType.Bitcoin,
+ address: "bc1qexample",
+ params: {},
+ } as any,
+ reserve: "reserve-pub",
+ username: "alice",
+ },
+ },
+ { loggedIn: true },
+);
+
+export const NeedEthereumConfirmation = tests.createExample(
+ NeedConfirmationView,
+ {
+ ...confirmationStory,
+ details: {
+ account: {
+ targetType: PaytoType.Ethereum,
+ address: "0x0000000000000000000000000000000000000000",
+ params: { "receiver-name": "Exchange" },
+ } as any,
+ reserve: "reserve-pub",
+ username: "alice",
+ },
+ },
+ { loggedIn: true },
+);
+
+export const UnsupportedWalletConfirmation = tests.createExample(
+ NeedConfirmationView,
+ {
+ ...confirmationStory,
+ details: {
+ account: {
+ targetType: PaytoType.TalerReserve,
+ params: {},
+ } as any,
+ reserve: "reserve-pub",
+ username: "alice",
+ },
+ },
+ { loggedIn: true },
+);
+
+export const NeedCyclosConfirmation = tests.createExample(
+ NeedConfirmationView,
+ {
+ ...confirmationStory,
+ details: {
+ account: {
+ targetType: PaytoType.Cyclos,
+ url: "https://cyclos.example/",
+ account: "exchange",
+ params: {},
+ } as any,
+ reserve: "reserve-pub",
+ username: "alice",
+ },
+ },
+ { loggedIn: true },
+);
+
+export const NeedVoidConfirmation = tests.createExample(
+ NeedConfirmationView,
+ {
+ ...confirmationStory,
+ details: {
+ account: {
+ targetType: PaytoType.Void,
+ params: {},
+ } as any,
+ reserve: "reserve-pub",
+ username: "alice",
+ },
+ },
+ { loggedIn: true },
+);
diff --git a/packages/libeufin-bank-webui/src/pages/OperationState/views.tsx b/packages/libeufin-bank-webui/src/pages/OperationState/views.tsx
@@ -33,33 +33,82 @@ import {
useTalerWalletIntegrationAPI,
useTranslationContext,
} from "@gnu-taler/web-util/browser";
-import { Fragment, VNode, h } from "preact";
+import { ComponentChildren, Fragment, VNode, h } from "preact";
import { useEffect } from "preact/hooks";
import { QR } from "../../components/QR.js";
import { usePreferences } from "../../hooks/preferences.js";
import { LoggedIn, useSessionState } from "../../hooks/session.js";
import { useBankChallengeHandlerContext } from "../../context/challenge.js";
-import { ShouldBeSameUser } from "../WithdrawalConfirmationQuestion.js";
+import { LoginForm } from "../LoginForm.js";
import { State } from "./index.js";
-const TALER_SCREEN_ID = 6;
-
export function InvalidPaytoView({ payto }: State.InvalidPayto) {
- return <div>Payto from server is not valid "{payto}"</div>;
+ const { i18n } = useTranslationContext();
+ return (
+ <div>
+ {i18n.str`The payto URI returned by the server is invalid: "${payto}".`}
+ </div>
+ );
}
export function InvalidWithdrawalView({ uri }: State.InvalidWithdrawal) {
- return <div>Withdrawal uri from server is not valid "{uri}"</div>;
+ const { i18n } = useTranslationContext();
+ return (
+ <div>
+ {i18n.str`The withdrawal URI returned by the server is invalid: "${uri}".`}
+ </div>
+ );
}
export function InvalidReserveView({ reserve }: State.InvalidReserve) {
+ const { i18n } = useTranslationContext();
return (
<div>
- Reserve from server is not valid "
- {reserve}"
+ {i18n.str`The reserve public key returned by the server is invalid: "${reserve}".`}
</div>
);
}
+export function ShouldBeSameUser({
+ username,
+ children,
+}: {
+ username: string;
+ children: ComponentChildren;
+}): VNode {
+ const { state: credentials } = useSessionState();
+ const { i18n } = useTranslationContext();
+ if (credentials.status === "loggedOut") {
+ return (
+ <Fragment>
+ <Attention type="info" title={i18n.str`Authentication required`} />
+ <LoginForm currentUser={username} fixedUser />
+ </Fragment>
+ );
+ }
+ if (credentials.status === "expired") {
+ return <LoginForm currentUser={username} fixedUser />;
+ }
+ if (credentials.username !== username) {
+ return (
+ <Fragment>
+ <Attention
+ type="warning"
+ title={i18n.str`This operation was created with another username`}
+ >
+ <p>
+ <i18n.Translate>
+ You are currently logged in with user "{credentials.username}" and
+ the operation was made with user "{username}"
+ </i18n.Translate>
+ </p>
+ </Attention>
+ <LoginForm currentUser={username} fixedUser />
+ </Fragment>
+ );
+ }
+ return <Fragment>{children}</Fragment>;
+}
+
export function NeedConfirmationView({
onAbort,
account,
@@ -575,13 +624,12 @@ export function ReadyView({
operationId,
}: State.Ready): VNode {
const { i18n } = useTranslationContext();
- const walletInegrationApi = useTalerWalletIntegrationAPI();
+ const { publishTalerAction } = useTalerWalletIntegrationAPI();
const { showError } = useNotificationContext();
const { state: credentials } = useSessionState();
const creds = credentials.status !== "loggedIn" ? undefined : credentials;
const {
- config,
lib: { bank },
} = useBankCoreApiContext();
@@ -590,9 +638,15 @@ export function ReadyView({
bankIntegrationApiBaseUrl: uri.bankIntegrationApiBaseUrl,
withdrawalOperationId: uri.withdrawalOperationId,
});
+ const integrationBaseUrl = uri.bankIntegrationApiBaseUrl;
+ const withdrawalOperationId = uri.withdrawalOperationId;
useEffect(() => {
- walletInegrationApi.publishTalerAction(uri);
- }, []);
+ publishTalerAction({
+ type: TalerUriAction.Withdraw,
+ bankIntegrationApiBaseUrl: integrationBaseUrl,
+ withdrawalOperationId,
+ });
+ }, [integrationBaseUrl, publishTalerAction, withdrawalOperationId]);
// i18n.str`abort withdrawal`,
const abort = useNotifiedOperation<
diff --git a/packages/libeufin-bank-webui/src/pages/QrCodeSection.stories.tsx b/packages/libeufin-bank-webui/src/pages/QrCodeSection.stories.tsx
@@ -1,39 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2022-2024 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/>
- */
-
-/**
- *
- * @author Sebastian Javier Marchano (sebasjm)
- */
-
-import * as tests from "@gnu-taler/web-util/testing";
-import { QrCodeSection } from "./QrCodeSection.js";
-import { TalerUriAction } from "@gnu-taler/taler-util";
-import { HostPortPath } from "@gnu-taler/taler-util";
-
-export default {
- title: "Qr Code Selection",
-};
-
-export const SimpleExample = tests.createExample(QrCodeSection, {
- withdrawUri: {
- bankIntegrationApiBaseUrl: "http://asd/" as HostPortPath,
- type: TalerUriAction.Withdraw,
- withdrawalOperationId: "123",
- externalConfirmation: false,
- },
- onAborted: () => undefined,
-});
diff --git a/packages/libeufin-bank-webui/src/pages/QrCodeSection.tsx b/packages/libeufin-bank-webui/src/pages/QrCodeSection.tsx
@@ -1,160 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2022-2024 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 {
- assertUnreachable,
- HttpStatusCode,
- TalerUris,
- UserAndToken,
- TalerWithdrawUri,
-} from "@gnu-taler/taler-util";
-import {
- AsyncButton,
- useBankCoreApiContext,
- useNotificationContext,
- useNotifiedOperation,
- useTalerWalletIntegrationAPI,
- useTranslationContext,
-} from "@gnu-taler/web-util/browser";
-import { Fragment, h, VNode } from "preact";
-import { useEffect } from "preact/hooks";
-import { QR } from "../components/QR.js";
-import { useSessionState } from "../hooks/session.js";
-
-const TALER_SCREEN_ID = 109;
-
-export function QrCodeSection({
- withdrawUri,
- onAborted,
-}: {
- withdrawUri: TalerWithdrawUri;
- onAborted: () => void;
-}): VNode {
- const { i18n } = useTranslationContext();
- const walletInegrationApi = useTalerWalletIntegrationAPI();
- const talerWithdrawUri = TalerUris.stringify(withdrawUri);
- const { state: credentials } = useSessionState();
- const creds = credentials.status !== "loggedIn" ? undefined : credentials;
-
- useEffect(() => {
- walletInegrationApi.publishTalerAction(withdrawUri);
- }, []);
-
- const { showError } = useNotificationContext();
-
- const {
- lib: { bank: api },
- } = useBankCoreApiContext();
-
- // i18n.str`abort withdrawal`,
- const abort = useNotifiedOperation<
- Awaited<ReturnType<typeof api.abortWithdrawalById>>,
- [UserAndToken]
- >(
- (ct, creds: UserAndToken) =>
- api.abortWithdrawalById(creds, withdrawUri.withdrawalOperationId),
- {
- onSuccess: onAborted,
- onFail: showError(i18n.str`Failed to abort withdrawal.`, (fail) => {
- switch (fail.case) {
- case HttpStatusCode.BadRequest:
- return i18n.str`The operation ID is invalid.`;
- case HttpStatusCode.NotFound:
- return i18n.str`The operation was not found.`;
- case HttpStatusCode.Conflict:
- return i18n.str`The reserve operation has been confirmed previously and can't be aborted`;
- default:
- assertUnreachable(fail);
- }
- }),
- },
- );
-
- return (
- <Fragment>
- <div class="bg-white shadow-xl sm:rounded-lg">
- <div class="px-4 py-5 sm:p-6">
- <h3 class="text-base font-semibold leading-6 text-gray-900">
- <i18n.Translate>
- If you have a Taler wallet installed on this device
- </i18n.Translate>
- </h3>
- <div class="mt-4 mb-4 text-sm text-gray-500">
- <p>
- <i18n.Translate>
- Your wallet will display the details of the transaction
- including the fees (if applicable). If you do not yet have a
- wallet, please follow the instructions
- </i18n.Translate>{" "}
- <a
- class="font-semibold text-indigo-600 hover:text-indigo-900"
- name="wallet page"
- href="https://taler.net/en/wallet.html"
- >
- <i18n.Translate>on this page</i18n.Translate>
- </a>
- .
- </p>
- </div>
- <div class="flex items-center justify-between gap-x-6 pt-2 mt-2 ">
- <AsyncButton
- name="cancel"
- class="text-sm font-semibold leading-6 text-gray-900"
- disabled={!creds}
- onClick={() => abort.run(creds!)}
- >
- <i18n.Translate>Cancel</i18n.Translate>
- </AsyncButton>
- <a
- href={talerWithdrawUri}
- name="withdraw"
- class="inline-flex items-center disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
- >
- <i18n.Translate>Withdraw</i18n.Translate>
- </a>
- </div>
- </div>
- </div>
-
- <div class="bg-white shadow-xl sm:rounded-lg mt-8">
- <div class="px-4 py-5 sm:p-6">
- <h3 class="text-base font-semibold leading-6 text-gray-900">
- <i18n.Translate>
- In case you have a Taler wallet on another device
- </i18n.Translate>
- </h3>
- <div class="mt-4 max-w-xl text-sm text-gray-500">
- <i18n.Translate>
- Scan the QR code below to start the withdrawal.
- </i18n.Translate>
- </div>
- <div class="mt-2 max-w-md ml-auto mr-auto">
- <QR text={talerWithdrawUri} />
- </div>
- </div>
- <div class="flex items-center justify-center gap-x-6 border-t border-gray-900/10 px-4 py-4 sm:px-8">
- <AsyncButton
- class="text-sm font-semibold leading-6 text-gray-900"
- disabled={!creds}
- onClick={() => abort.run(creds!)}
- >
- <i18n.Translate>Cancel</i18n.Translate>
- </AsyncButton>
- </div>
- </div>
- </Fragment>
- );
-}
diff --git a/packages/libeufin-bank-webui/src/pages/WithdrawalConfirmationQuestion.tsx b/packages/libeufin-bank-webui/src/pages/WithdrawalConfirmationQuestion.tsx
@@ -1,511 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2022-2024 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 {
- AmountJson,
- Amounts,
- HttpStatusCode,
- PaytoType,
- Paytos,
- TalerErrorCode,
- TalerWithdrawUri,
- assertUnreachable,
-} from "@gnu-taler/taler-util";
-import {
- Attention,
- AsyncButton,
- RenderAmount,
- useBankCoreApiContext,
- useNotificationContext,
- useNotifiedOperation,
- useTranslationContext,
-} from "@gnu-taler/web-util/browser";
-import { ComponentChildren, Fragment, VNode, h } from "preact";
-import { mutate } from "swr";
-import { useBankChallengeHandlerContext } from "../context/challenge.js";
-import { LoggedIn, useSessionState } from "../hooks/session.js";
-import { LoginForm } from "./LoginForm.js";
-
-const TALER_SCREEN_ID = 114;
-
-interface Props {
- withdrawUri: TalerWithdrawUri;
- details: {
- account: Paytos.URI;
- reserve: string;
- username: string;
- amount?: AmountJson;
- };
-}
-
-function useComponentState(opid: string) {
- const { state: credentials } = useSessionState();
- const creds = credentials.status !== "loggedIn" ? undefined : credentials;
- const { i18n } = useTranslationContext();
- const { showError } = useNotificationContext();
- const mfa = useBankChallengeHandlerContext();
-
- const {
- config,
- lib: { bank: api },
- } = useBankCoreApiContext();
-
- const wireFee =
- config.wire_transfer_fees === undefined
- ? Amounts.zeroOfCurrency(config.currency)
- : Amounts.parseOrThrow(config.wire_transfer_fees);
-
- // i18n.str`confirm withdrawal`,
- const confirm = useNotifiedOperation<
- Awaited<ReturnType<typeof api.confirmWithdrawalById>>,
- [LoggedIn, challengeIds?: string[]]
- >(
- (ct, creds: LoggedIn, challengeIds?: string[]) =>
- api.confirmWithdrawalById(creds, {}, opid, {
- challengeIds,
- }),
- {
- onSuccess: () => {
- mfa.cancel();
- mutate(() => true); // clean any info that we have
- },
- onFail: showError(
- i18n.str`Failed to confirm the withdrawal.`,
- (fail, creds) => {
- switch (fail.case) {
- case HttpStatusCode.Accepted:
- mfa.onNewChallenge(
- i18n.str`Withdrawal confirmation`,
- creds.username,
- fail.body,
- {
- running: confirm.running,
- cancel: confirm.cancel,
- run: (challengeIds) => confirm.run(creds, challengeIds),
- },
- );
- return undefined;
- case HttpStatusCode.BadRequest:
- return i18n.str`The server did not understand the request.`;
- case HttpStatusCode.NotFound:
- return i18n.str`The operation was not found.`;
- case TalerErrorCode.BANK_UNALLOWED_DEBIT:
- return i18n.str`The account does not have sufficient funds or the amount is outside the limits.`;
- case TalerErrorCode.BANK_CONFIRM_ABORT_CONFLICT:
- return i18n.str`The withdrawal has been aborted and can not be confirmed.`;
- case TalerErrorCode.BANK_CONFIRM_INCOMPLETE:
- return i18n.str`The withdrawal has no exchange and reserve public selected.`;
- case TalerErrorCode.BANK_AMOUNT_DIFFERS:
- return i18n.str`The starting withdrawal amount and the confirmation amount differ.`;
- case TalerErrorCode.BANK_AMOUNT_REQUIRED:
- return i18n.str`The bank requires a bank account which has not been specified yet.`;
- default:
- assertUnreachable(fail);
- }
- },
- ),
- },
- );
-
- // i18n.str`abort withdrawal`,
- const abort = useNotifiedOperation<
- Awaited<ReturnType<typeof api.abortWithdrawalById>>,
- [LoggedIn, string]
- >((ct, s, id) => api.abortWithdrawalById(s, id), {
- onSuccess: () => {
- mutate(() => true); // clean any info that we have
- },
- onFail: showError(i18n.str`Failed to abort the withdrawal.`, (fail) => {
- switch (fail.case) {
- case HttpStatusCode.BadRequest:
- return i18n.str`The server did not understand the request.`;
- case HttpStatusCode.NotFound:
- return i18n.str`The operation was not found.`;
- case HttpStatusCode.Conflict:
- return i18n.str`The withdrawal operation has been confirmed previously and can not be aborted.`;
- default:
- assertUnreachable(fail);
- }
- }),
- });
-
- const spec = config.currency_specification;
-
- return {
- wireFee,
- spec,
- creds,
- abort,
- confirm,
- };
-}
-
-/**
- * Additional authentication required to complete the operation.
- * Not providing a back button, only abort.
- */
-export function WithdrawalConfirmationQuestion({
- details,
- withdrawUri,
-}: Props): VNode {
- const { i18n } = useTranslationContext();
- const { wireFee, spec, creds, abort, confirm } = useComponentState(
- withdrawUri.withdrawalOperationId,
- );
-
- return (
- <Fragment>
- <div class="bg-white shadow sm:rounded-lg">
- <div class="px-4 py-5 sm:p-6">
- <h3 class="text-base font-semibold text-gray-900">
- <i18n.Translate>Confirm the withdrawal operation</i18n.Translate>
- </h3>
- <div class="mt-3 text-sm leading-6">
- <ShouldBeSameUser username={details.username}>
- <div class="grid grid-cols-1 gap-x-8 gap-y-8 pt-4 md:grid-cols-2 bg-gray-100 my-4 px-4 pb-4 rounded-lg">
- <form
- class="bg-white shadow-sm ring-1 ring-gray-900/5 sm:rounded-xl md:col-span-2"
- autoCapitalize="none"
- autoCorrect="off"
- onSubmit={(e) => {
- e.preventDefault();
- }}
- >
- <div class="px-4 mt-4">
- <div class="w-full">
- <div class="px-4 sm:px-0 text-sm">
- <p>
- <i18n.Translate>Wire transfer details</i18n.Translate>
- </p>
- </div>
- <div class="mt-6 border-t border-gray-100">
- <dl class="divide-y divide-gray-100">
- {((): VNode => {
- switch (details.account.targetType) {
- case undefined:
- case PaytoType.TalerReserveHttp:
- case PaytoType.TalerReserve: {
- // FIXME: support wire transfer to wallet
- 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"];
- return (
- <Fragment>
- <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0">
- <dt class="text-sm font-medium leading-6 text-gray-900">
- <i18n.Translate>
- Payment Service Provider's account
- number
- </i18n.Translate>
- </dt>
- <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0">
- {details.account.iban}
- </dd>
- </div>
- {name && (
- <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0">
- <dt class="text-sm font-medium leading-6 text-gray-900">
- <i18n.Translate>
- Payment Service Provider's name
- </i18n.Translate>
- </dt>
- <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0">
- {name}
- </dd>
- </div>
- )}
- </Fragment>
- );
- }
- case PaytoType.TalerBank: {
- const name =
- details.account.params["receiver-name"];
- return (
- <Fragment>
- <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0">
- <dt class="text-sm font-medium leading-6 text-gray-900">
- <i18n.Translate>
- Payment Service Provider's account
- bank hostname
- </i18n.Translate>
- </dt>
- <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0">
- {details.account.host}
- </dd>
- </div>
- <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0">
- <dt class="text-sm font-medium leading-6 text-gray-900">
- <i18n.Translate>
- Payment Service Provider's account id
- </i18n.Translate>
- </dt>
- <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0">
- {details.account.account}
- </dd>
- </div>
- {name && (
- <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0">
- <dt class="text-sm font-medium leading-6 text-gray-900">
- <i18n.Translate>
- Payment Service Provider's name
- </i18n.Translate>
- </dt>
- <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0">
- {name}
- </dd>
- </div>
- )}
- </Fragment>
- );
- }
- case PaytoType.Bitcoin: {
- const name =
- details.account.params["receiver-name"];
- return (
- <Fragment>
- <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0">
- <dt class="text-sm font-medium leading-6 text-gray-900">
- <i18n.Translate>
- Payment Service Provider's account
- address
- </i18n.Translate>
- </dt>
- <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0">
- {details.account.address}
- </dd>
- </div>
- {name && (
- <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0">
- <dt class="text-sm font-medium leading-6 text-gray-900">
- <i18n.Translate>
- Payment Service Provider's name
- </i18n.Translate>
- </dt>
- <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0">
- {name}
- </dd>
- </div>
- )}
- </Fragment>
- );
- }
- case PaytoType.Ethereum: {
- const name =
- details.account.params["receiver-name"];
- return (
- <Fragment>
- <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0">
- <dt class="text-sm font-medium leading-6 text-gray-900">
- <i18n.Translate>
- Payment Service Provider's account
- address
- </i18n.Translate>
- </dt>
- <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0">
- {details.account.address}
- </dd>
- </div>
- {name && (
- <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0">
- <dt class="text-sm font-medium leading-6 text-gray-900">
- <i18n.Translate>
- Payment Service Provider's name
- </i18n.Translate>
- </dt>
- <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0">
- {name}
- </dd>
- </div>
- )}
- </Fragment>
- );
- }
- case PaytoType.Cyclos: {
- const name =
- details.account.params["receiver-name"];
- return (
- <Fragment>
- <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0">
- <dt class="text-sm font-medium leading-6 text-gray-900">
- <i18n.Translate>
- Payment Service Provider's account
- cyclos hostname
- </i18n.Translate>
- </dt>
- <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0">
- {details.account.url}
- </dd>
- </div>
- <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0">
- <dt class="text-sm font-medium leading-6 text-gray-900">
- <i18n.Translate>
- Payment Service Provider's account id
- </i18n.Translate>
- </dt>
- <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0">
- {details.account.account}
- </dd>
- </div>
- {name && (
- <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0">
- <dt class="text-sm font-medium leading-6 text-gray-900">
- <i18n.Translate>
- Payment Service Provider's name
- </i18n.Translate>
- </dt>
- <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0">
- {name}
- </dd>
- </div>
- )}
- </Fragment>
- );
- }
- case PaytoType.Void:
- return (
- <p>
- <i18n.Translate>
- Void payment targets are not supported.
- </i18n.Translate>
- </p>
- );
- default: {
- assertUnreachable(details.account);
- }
- }
- })()}
- <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0">
- <dt class="text-sm font-medium leading-6 text-gray-900">
- <i18n.Translate>Amount</i18n.Translate>
- </dt>
- <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0">
- {details.amount !== undefined ? (
- <RenderAmount
- value={details.amount}
- spec={spec}
- />
- ) : (
- <i18n.Translate>
- No amount has yet been determined.
- </i18n.Translate>
- )}
- </dd>
- </div>
- {Amounts.isZero(wireFee) ? undefined : (
- <Fragment>
- <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0">
- <dt class="text-sm font-medium leading-6 text-gray-900">
- <i18n.Translate>Cost</i18n.Translate>
- </dt>
- <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0">
- <RenderAmount
- value={wireFee}
- negative
- withColor
- spec={spec}
- />
- </dd>
- </div>
- </Fragment>
- )}
- </dl>
- </div>
- </div>
- </div>
-
- <div class="flex items-center justify-between gap-x-6 border-t border-gray-900/10 px-4 py-4 sm:px-8">
- <AsyncButton
- name="cancel"
- class="text-sm font-semibold leading-6 text-gray-900"
- disabled={!creds}
- onClick={() =>
- abort.run(creds!, withdrawUri.withdrawalOperationId)
- }
- >
- <i18n.Translate>Cancel</i18n.Translate>
- </AsyncButton>
- <AsyncButton
- submit
- name="transfer"
- class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
- disabled={!creds}
- onClick={() => confirm.run(creds!)}
- >
- <i18n.Translate>Transfer</i18n.Translate>
- </AsyncButton>
- </div>
- </form>
- </div>
- </ShouldBeSameUser>
- </div>
- </div>
- </div>
- </Fragment>
- );
-}
-
-export function ShouldBeSameUser({
- username,
- children,
-}: {
- username: string;
- children: ComponentChildren;
-}): VNode {
- const { state: credentials } = useSessionState();
- const { i18n } = useTranslationContext();
- if (credentials.status === "loggedOut") {
- return (
- <Fragment>
- <Attention
- type="info"
- title={i18n.str`Authentication required`}
- ></Attention>
- <LoginForm currentUser={username} fixedUser />
- </Fragment>
- );
- }
- if (credentials.status === "expired") {
- return <LoginForm currentUser={username} fixedUser />;
- }
- if (credentials.username !== username) {
- return (
- <Fragment>
- <Attention
- type="warning"
- title={i18n.str`This operation was created with another username`}
- >
- <p>
- <i18n.Translate>
- You are currently logged in with user "{credentials.username}" and
- the operation was made with user "{username}"
- </i18n.Translate>
- </p>
- </Attention>
- <LoginForm currentUser={username} fixedUser />
- </Fragment>
- );
- }
- return <Fragment>{children}</Fragment>;
-}
diff --git a/packages/libeufin-bank-webui/src/pages/WithdrawalOperationPage.tsx b/packages/libeufin-bank-webui/src/pages/WithdrawalOperationPage.tsx
@@ -14,63 +14,25 @@
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
-import {
- HostPortPath,
- TalerUri,
- TalerUriAction,
- TalerUris,
-} from "@gnu-taler/taler-util";
-import {
- Attention,
- RouteDefinition,
- useBankCoreApiContext,
- useTranslationContext,
-} from "@gnu-taler/web-util/browser";
+import { RouteDefinition } from "@gnu-taler/web-util/browser";
import { VNode, h } from "preact";
import { useBankState } from "../hooks/bank-state.js";
-import { WithdrawalQRCode } from "./WithdrawalQRCode.js";
-
-const TALER_SCREEN_ID = 115;
+import { WithdrawalOperation } from "./OperationState/index.js";
export function WithdrawalOperationPage({
operationId,
onOperationAborted,
routeClose,
- origin,
}: {
operationId: string;
- origin: "from-bank-ui" | "from-wallet-ui";
onOperationAborted: () => void;
routeClose: RouteDefinition;
}): VNode {
- const {
- lib: { bank: api },
- } = useBankCoreApiContext();
- const parsedUri: TalerUri = {
- type: TalerUriAction.Withdraw,
- bankIntegrationApiBaseUrl: api.getIntegrationAPI().href as HostPortPath,
- withdrawalOperationId: operationId,
- };
- const uri = TalerUris.stringify(parsedUri);
- const { i18n } = useTranslationContext();
const [, updateBankState] = useBankState();
-
- if (!parsedUri) {
- return (
- <Attention
- type="danger"
- title={i18n.str`The Withdrawal URI is not valid`}
- >
- {uri}
- </Attention>
- );
- }
-
return (
- <WithdrawalQRCode
- withdrawUri={parsedUri}
- origin={origin}
- onOperationAborted={() => {
+ <WithdrawalOperation
+ operationId={operationId}
+ onAbort={() => {
updateBankState("currentWithdrawalOperationId", undefined);
onOperationAborted();
}}
diff --git a/packages/libeufin-bank-webui/src/pages/WithdrawalQRCode.tsx b/packages/libeufin-bank-webui/src/pages/WithdrawalQRCode.tsx
@@ -1,315 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2022-2024 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 {
- Amounts,
- HttpStatusCode,
- TalerError,
- TalerWithdrawUri,
- assertUnreachable,
-} from "@gnu-taler/taler-util";
-import {
- Attention,
- ErrorLoading,
- Loading,
- RouteDefinition,
- useTranslationContext,
-} from "@gnu-taler/web-util/browser";
-import { VNode, h } from "preact";
-
-import { Paytos, TalerUris } from "@gnu-taler/taler-util";
-import { useWithdrawalDetails } from "../hooks/account.js";
-import { QrCodeSection } from "./QrCodeSection.js";
-import { WithdrawalConfirmationQuestion } from "./WithdrawalConfirmationQuestion.js";
-
-const TALER_SCREEN_ID = 116;
-
-interface Props {
- withdrawUri: TalerWithdrawUri;
- origin: "from-bank-ui" | "from-wallet-ui";
- onOperationAborted: () => void;
- routeClose: RouteDefinition;
-}
-/**
- * Offer the QR code (and a clickable taler://-link) to
- * permit the passing of exchange and reserve details to
- * the bank. Poll the backend until such operation is done.
- */
-export function WithdrawalQRCode({
- withdrawUri,
- onOperationAborted,
- routeClose,
- origin,
-}: Props): VNode {
- const { i18n } = useTranslationContext();
- const result = useWithdrawalDetails(withdrawUri.withdrawalOperationId);
-
- if (!result) {
- return <Loading />;
- }
- if (result instanceof TalerError) {
- return (
- <ErrorLoading
- error={result}
- title={i18n.str`Failed to load withdrawal details.`}
- />
- );
- }
- if (result.type === "fail") {
- switch (result.case) {
- case HttpStatusCode.BadRequest:
- case HttpStatusCode.NotFound:
- return <OperationNotFound routeClose={routeClose} />;
- default:
- assertUnreachable(result);
- }
- }
-
- const { body: data } = result;
-
- if (data.status === "aborted") {
- return (
- <div class="relative ml-auto mr-auto transform overflow-hidden rounded-lg bg-white px-4 pb-4 pt-5 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-sm sm:p-6">
- <div>
- <div class="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-yellow-100">
- <svg
- class="h-5 w-5 text-yellow-400"
- viewBox="0 0 20 20"
- fill="currentColor"
- aria-hidden="true"
- >
- <path
- fill-rule="evenodd"
- d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495zM10 5a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 0110 5zm0 9a1 1 0 100-2 1 1 0 000 2z"
- clip-rule="evenodd"
- />
- </svg>
- </div>
- <div class="mt-3 text-center sm:mt-5">
- <h3
- class="text-base font-semibold leading-6 text-gray-900"
- id="modal-title"
- >
- <i18n.Translate>Operation aborted</i18n.Translate>
- </h3>
- <div class="mt-2">
- <p class="text-sm text-gray-500">
- <i18n.Translate>
- The wire transfer to the Payment Service Provider's account
- was aborted from somewhere else, your balance was not
- affected.
- </i18n.Translate>
- </p>
- </div>
- </div>
- </div>
- <div class="mt-5 sm:mt-6">
- <a
- href={routeClose.url({})}
- name="continue"
- class="inline-flex w-full justify-center rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
- >
- <i18n.Translate>Continue</i18n.Translate>
- </a>
- </div>
- </div>
- );
- }
- const talerWithdrawUri = TalerUris.stringify(withdrawUri);
-
- if (data.status === "confirmed") {
- return (
- <div class="relative ml-auto mr-auto transform overflow-hidden rounded-lg bg-white px-4 pb-4 pt-5 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-sm sm:p-6">
- <div>
- <div class="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-green-100">
- <svg
- class="h-6 w-6 text-green-600"
- fill="none"
- viewBox="0 0 24 24"
- stroke-width="1.5"
- stroke="currentColor"
- aria-hidden="true"
- >
- <path
- stroke-linecap="round"
- stroke-linejoin="round"
- d="M4.5 12.75l6 6 9-13.5"
- />
- </svg>
- </div>
- <div class="mt-3 text-center sm:mt-5">
- <h3
- class="text-base font-semibold leading-6 text-gray-900"
- id="modal-title"
- >
- <i18n.Translate>Withdrawal confirmed</i18n.Translate>
- </h3>
- <div class="mt-2">
- <p class="text-sm text-gray-500">
- <i18n.Translate>
- The wire transfer to the Payment Service Provider has been
- initiated. You will shortly receive the requested amount in
- your Taler wallet.{" "}
- </i18n.Translate>
- </p>
- </div>
- </div>
- </div>
- <div class="mt-5 sm:mt-6 items-center justify-between gap-x-2 flex">
- <a
- href={routeClose.url({})}
- name="done"
- class="inline-flex justify-center rounded-md bg-white-600 px-3 py-2 text-sm font-semibold text-black shadow-sm "
- >
- <i18n.Translate>Close</i18n.Translate>
- </a>
- {/* FIXME: kept until the wallet-ui hand-off is settled. */}
- {/* eslint-disable-next-line no-constant-condition */}
- {origin === "from-wallet-ui" && false ? (
- <a
- href={talerWithdrawUri}
- name="done"
- class="inline-flex justify-center rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
- >
- <i18n.Translate>Go to your wallet now</i18n.Translate>
- </a>
- ) : undefined}
- </div>
- </div>
- );
- }
-
- if (data.status === "pending") {
- return (
- <QrCodeSection withdrawUri={withdrawUri} onAborted={onOperationAborted} />
- );
- }
-
- const account = !data.selected_exchange_account
- ? undefined
- : Paytos.fromString(data.selected_exchange_account);
-
- if (!account || account.tag === "error") {
- if (!data.selected_reserve_pub) {
- return (
- <Attention
- type="danger"
- title={i18n.str`The operation is marked as selected, but a process during the withdrawal failed`}
- >
- <i18n.Translate>
- A withdrawal reserve ID was not found and no account has been
- selected.
- </i18n.Translate>
- </Attention>
- );
- }
- return (
- <Attention
- type="danger"
- title={i18n.str`The operation is marked as selected, but a process during the withdrawal failed`}
- >
- <i18n.Translate>
- There is a withdrawal reserve ID but no account has been selected or
- the selected account is invalid.
- </i18n.Translate>
- </Attention>
- );
- }
-
- if (!data.selected_reserve_pub) {
- return (
- <Attention
- type="danger"
- title={i18n.str`The operation is marked as selected, but a process during the withdrawal failed`}
- >
- <i18n.Translate>
- The account was selected, but no withdrawal reserve ID was found.
- </i18n.Translate>
- </Attention>
- );
- }
-
- return (
- <WithdrawalConfirmationQuestion
- withdrawUri={withdrawUri}
- details={{
- username: data.username,
- account: account.value,
- reserve: data.selected_reserve_pub,
- amount: !data.amount ? undefined : Amounts.parseOrThrow(data.amount),
- }}
- />
- );
-}
-
-export function OperationNotFound({
- routeClose,
-}: {
- routeClose: RouteDefinition | undefined;
-}): VNode {
- const { i18n } = useTranslationContext();
- return (
- <div class="relative ml-auto mr-auto transform overflow-hidden rounded-lg bg-white px-4 pb-4 pt-5 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-sm sm:p-6">
- <div>
- <div class="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-red-100 ">
- <svg
- class="h-6 w-6 text-red-600"
- fill="none"
- viewBox="0 0 24 24"
- stroke-width="1.5"
- stroke="currentColor"
- aria-hidden="true"
- >
- <path
- stroke-linecap="round"
- stroke-linejoin="round"
- d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"
- />
- </svg>
- </div>
-
- <div class="mt-3 text-center sm:mt-5">
- <h3
- class="text-base font-semibold leading-6 text-gray-900"
- id="modal-title"
- >
- <i18n.Translate>Operation not found</i18n.Translate>
- </h3>
- <div class="mt-2">
- <p class="text-sm text-gray-500">
- <i18n.Translate>
- This process is not known to the server. The process ID is
- incorrect or the server has deleted the process information
- before it arrived here.
- </i18n.Translate>
- </p>
- </div>
- </div>
- </div>
- {routeClose && (
- <div class="mt-5 sm:mt-6">
- <a
- href={routeClose.url({})}
- name="continue to dashboard"
- class="inline-flex w-full justify-center rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
- >
- <i18n.Translate>Continue to dashboard</i18n.Translate>
- </a>
- </div>
- )}
- </div>
- );
-}