taler-typescript-core

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

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:
Mpackages/libeufin-bank-webui/src/Routing.tsx | 3---
Mpackages/libeufin-bank-webui/src/pages/OperationState/OperationState.test.ts | 132+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/libeufin-bank-webui/src/pages/OperationState/index.ts | 24++++++++++++++++--------
Mpackages/libeufin-bank-webui/src/pages/OperationState/state.ts | 333+++++++++++++++++++++++++++++++++++++++++++++----------------------------------
Mpackages/libeufin-bank-webui/src/pages/OperationState/stories.tsx | 169++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mpackages/libeufin-bank-webui/src/pages/OperationState/views.tsx | 78++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------
Dpackages/libeufin-bank-webui/src/pages/QrCodeSection.stories.tsx | 39---------------------------------------
Dpackages/libeufin-bank-webui/src/pages/QrCodeSection.tsx | 160-------------------------------------------------------------------------------
Dpackages/libeufin-bank-webui/src/pages/WithdrawalConfirmationQuestion.tsx | 511-------------------------------------------------------------------------------
Mpackages/libeufin-bank-webui/src/pages/WithdrawalOperationPage.tsx | 48+++++-------------------------------------------
Dpackages/libeufin-bank-webui/src/pages/WithdrawalQRCode.tsx | 315-------------------------------------------------------------------------------
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 &quot;{payto}&quot;</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 &quot;{uri}&quot;</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 &quot; - {reserve}&quot; + {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> - ); -}