diff options
author | Sebastian <sebasjm@gmail.com> | 2023-10-19 02:55:57 -0300 |
---|---|---|
committer | Sebastian <sebasjm@gmail.com> | 2023-10-19 02:56:15 -0300 |
commit | 366cccb8fcae6a9971a1e8a9143d821e289339d1 (patch) | |
tree | fcaa481f7053ef11c92e988d3fb84bf3cedbaba3 /packages | |
parent | a67518ab1a865fc79374a19bce6513b0caa2eab6 (diff) | |
download | wallet-core-366cccb8fcae6a9971a1e8a9143d821e289339d1.tar.gz wallet-core-366cccb8fcae6a9971a1e8a9143d821e289339d1.tar.bz2 wallet-core-366cccb8fcae6a9971a1e8a9143d821e289339d1.zip |
integrate bank into the new taler-util API
Diffstat (limited to 'packages')
42 files changed, 1559 insertions, 2738 deletions
diff --git a/packages/demobank-ui/src/components/Cashouts/index.ts b/packages/demobank-ui/src/components/Cashouts/index.ts index 05ef1f3b4..ae020cef6 100644 --- a/packages/demobank-ui/src/components/Cashouts/index.ts +++ b/packages/demobank-ui/src/components/Cashouts/index.ts @@ -18,7 +18,7 @@ import { HttpError, utils } from "@gnu-taler/web-util/browser"; import { Loading } from "../Loading.js"; // import { compose, StateViewMap } from "../../utils/index.js"; // import { wxApi } from "../../wxApi.js"; -import { AbsoluteTime, AmountJson } from "@gnu-taler/taler-util"; +import { AbsoluteTime, AmountJson, TalerCorebankApi, TalerError } from "@gnu-taler/taler-util"; import { useComponentState } from "./state.js"; import { LoadingUriView, ReadyView } from "./views.js"; @@ -37,7 +37,7 @@ export namespace State { export interface LoadingUriError { status: "loading-error"; - error: HttpError<SandboxBackend.SandboxError>; + error: TalerError; } export interface BaseInfo { @@ -46,7 +46,7 @@ export namespace State { export interface Ready extends BaseInfo { status: "ready"; error: undefined; - cashouts: SandboxBackend.Circuit.CashoutStatusResponseWithId[]; + cashouts: (TalerCorebankApi.CashoutStatusResponse & { id: string })[]; onSelected: (id: string) => void; } } diff --git a/packages/demobank-ui/src/components/Cashouts/state.ts b/packages/demobank-ui/src/components/Cashouts/state.ts index 124f9bf9c..47ad0a297 100644 --- a/packages/demobank-ui/src/components/Cashouts/state.ts +++ b/packages/demobank-ui/src/components/Cashouts/state.ts @@ -14,18 +14,19 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ +import { TalerError } from "@gnu-taler/taler-util"; import { useCashouts } from "../../hooks/circuit.js"; import { Props, State } from "./index.js"; export function useComponentState({ account, onSelected }: Props): State { const result = useCashouts(account); - if (result.loading) { + if (!result) { return { status: "loading", error: undefined, }; } - if (!result.ok) { + if (result instanceof TalerError) { return { status: "loading-error", error: result, @@ -35,7 +36,7 @@ export function useComponentState({ account, onSelected }: Props): State { return { status: "ready", error: undefined, - cashouts: result.data, + cashouts: result.body.cashouts, onSelected, }; } diff --git a/packages/demobank-ui/src/components/Cashouts/views.tsx b/packages/demobank-ui/src/components/Cashouts/views.tsx index a32deb266..0602f507e 100644 --- a/packages/demobank-ui/src/components/Cashouts/views.tsx +++ b/packages/demobank-ui/src/components/Cashouts/views.tsx @@ -57,10 +57,10 @@ export function ReadyView({ cashouts, onSelected }: State.Ready): VNode { {cashouts.map((item, idx) => { return ( <tr key={idx}> - <td>{format(item.creation_time, "dd/MM/yyyy HH:mm:ss")}</td> + <td>{item.creation_time.t_s === "never" ? i18n.str`never` : format(item.creation_time.t_s, "dd/MM/yyyy HH:mm:ss")}</td> <td> {item.confirmation_time - ? format(item.confirmation_time, "dd/MM/yyyy HH:mm:ss") + ? item.confirmation_time.t_s === "never" ? i18n.str`never` : format(item.confirmation_time.t_s, "dd/MM/yyyy HH:mm:ss") : "-"} </td> <td><RenderAmount value={Amounts.parseOrThrow(item.amount_debit)} /></td> diff --git a/packages/demobank-ui/src/components/ErrorLoading.tsx b/packages/demobank-ui/src/components/ErrorLoading.tsx index ee62671ce..84e72c5a1 100644 --- a/packages/demobank-ui/src/components/ErrorLoading.tsx +++ b/packages/demobank-ui/src/components/ErrorLoading.tsx @@ -15,15 +15,106 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ -import { HttpError, useTranslationContext } from "@gnu-taler/web-util/browser"; -import { h, VNode } from "preact"; +import { TalerError, TalerErrorCode } from "@gnu-taler/taler-util"; +import { useTranslationContext } from "@gnu-taler/web-util/browser"; +import { Fragment, VNode, h } from "preact"; import { Attention } from "./Attention.js"; -import { TranslatedString } from "@gnu-taler/taler-util"; +import { assertUnreachable } from "./Routing.js"; -export function ErrorLoading({ error }: { error: HttpError<SandboxBackend.SandboxError> }): VNode { +export function ErrorLoading({ error, showDetail }: { error: TalerError, showDetail?: boolean }): VNode { const { i18n } = useTranslationContext() - return (<Attention type="danger" title={error.message as TranslatedString}> - <p class="text-sm font-medium text-red-800">Got status "{error.info.status}" on {error.info.url}</p> - </Attention> - ); + switch (error.errorDetail.code) { + ////////////////// + // Every error that can be produce in a Http Request + ////////////////// + case TalerErrorCode.WALLET_HTTP_REQUEST_GENERIC_TIMEOUT: { + if (error.hasErrorCode(TalerErrorCode.WALLET_HTTP_REQUEST_GENERIC_TIMEOUT)) { + const { requestMethod, requestUrl, timeoutMs } = error.errorDetail + return <Attention type="danger" title={i18n.str`The request reached a timeout, check your connection.`}> + {error.message} + {showDetail && + <pre class="whitespace-break-spaces "> + {JSON.stringify({ requestMethod, requestUrl, timeoutMs }, undefined, 2)} + </pre> + } + </Attention> + } + assertUnreachable(1 as never) + } + case TalerErrorCode.WALLET_HTTP_REQUEST_THROTTLED: { + if (error.hasErrorCode(TalerErrorCode.WALLET_HTTP_REQUEST_THROTTLED)) { + const { requestMethod, requestUrl, throttleStats } = error.errorDetail + return <Attention type="danger" title={i18n.str`A lot of request were made to the same server and this action was throttled`}> + {error.message} + {showDetail && + <pre class="whitespace-break-spaces "> + {JSON.stringify({ requestMethod, requestUrl, throttleStats }, undefined, 2)} + </pre> + } + </Attention> + } + assertUnreachable(1 as never) + } + case TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE: { + if (error.hasErrorCode(TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE)) { + const { requestMethod, requestUrl, httpStatusCode, validationError } = error.errorDetail + return <Attention type="danger" title={i18n.str`The response of the request is malformed.`}> + {error.message} + {showDetail && + <pre class="whitespace-break-spaces "> + {JSON.stringify({ requestMethod, requestUrl, httpStatusCode, validationError }, undefined, 2)} + </pre> + } + </Attention> + } + assertUnreachable(1 as never) + } + case TalerErrorCode.WALLET_NETWORK_ERROR: { + if (error.hasErrorCode(TalerErrorCode.WALLET_NETWORK_ERROR)) { + const { requestMethod, requestUrl } = error.errorDetail + return <Attention type="danger" title={i18n.str`Could not complete the request due to a network problem.`}> + {error.message} + {showDetail && + <pre class="whitespace-break-spaces "> + {JSON.stringify({ requestMethod, requestUrl }, undefined, 2)} + </pre> + } + </Attention> + } + assertUnreachable(1 as never) + } + case TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR: { + if (error.hasErrorCode(TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR)) { + const { requestMethod, requestUrl, httpStatusCode, errorResponse } = error.errorDetail + return <Attention type="danger" title={i18n.str`Unexpected request error`}> + {error.message} + {showDetail && + <pre class="whitespace-break-spaces "> + {JSON.stringify({ requestMethod, requestUrl, httpStatusCode, errorResponse }, undefined, 2)} + </pre> + } + </Attention> + } + assertUnreachable(1 as never) + } + ////////////////// + // Every other error + ////////////////// + // case TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR: { + // return <Attention type="danger" title={i18n.str``}> + // </Attention> + // } + ////////////////// + // Default message for unhandled case + ////////////////// + default: return <Attention type="danger" title={i18n.str`Unexpected error`}> + {error.message} + {showDetail && + <pre class="whitespace-break-spaces "> + {JSON.stringify(error.errorDetail, undefined, 2)} + </pre> + } + </Attention> + } } + diff --git a/packages/demobank-ui/src/components/Routing.tsx b/packages/demobank-ui/src/components/Routing.tsx index aafc95687..04cf96190 100644 --- a/packages/demobank-ui/src/components/Routing.tsx +++ b/packages/demobank-ui/src/components/Routing.tsx @@ -32,8 +32,8 @@ import { bankUiSettings } from "../settings.js"; export function Routing(): VNode { const history = createHashHistory(); const backend = useBackendContext(); - const {i18n} = useTranslationContext(); - + const { i18n } = useTranslationContext(); + if (backend.state.status === "loggedOut") { return <BankFrame > <Router history={history}> @@ -143,9 +143,6 @@ export function Routing(): VNode { onRegister={() => { route("/register"); }} - onLoadNotOk={() => { - route("/account"); - }} /> )} /> diff --git a/packages/demobank-ui/src/components/Transactions/index.ts b/packages/demobank-ui/src/components/Transactions/index.ts index 9df1a70e5..3c4fb5ce9 100644 --- a/packages/demobank-ui/src/components/Transactions/index.ts +++ b/packages/demobank-ui/src/components/Transactions/index.ts @@ -18,7 +18,7 @@ import { HttpError, utils } from "@gnu-taler/web-util/browser"; import { Loading } from "../Loading.js"; // import { compose, StateViewMap } from "../../utils/index.js"; // import { wxApi } from "../../wxApi.js"; -import { AbsoluteTime, AmountJson } from "@gnu-taler/taler-util"; +import { AbsoluteTime, AmountJson, TalerError } from "@gnu-taler/taler-util"; import { useComponentState } from "./state.js"; import { LoadingUriView, ReadyView } from "./views.js"; @@ -36,7 +36,7 @@ export namespace State { export interface LoadingUriError { status: "loading-error"; - error: HttpError<SandboxBackend.SandboxError>; + error: TalerError; } export interface BaseInfo { diff --git a/packages/demobank-ui/src/components/Transactions/state.ts b/packages/demobank-ui/src/components/Transactions/state.ts index 4b62b005e..c85fba85b 100644 --- a/packages/demobank-ui/src/components/Transactions/state.ts +++ b/packages/demobank-ui/src/components/Transactions/state.ts @@ -14,34 +14,34 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ -import { AbsoluteTime, Amounts, parsePaytoUri } from "@gnu-taler/taler-util"; +import { AbsoluteTime, Amounts, TalerError, parsePaytoUri } from "@gnu-taler/taler-util"; import { useTransactions } from "../../hooks/access.js"; import { Props, State, Transaction } from "./index.js"; export function useComponentState({ account }: Props): State { const result = useTransactions(account); - if (result.loading) { + if (!result) { return { status: "loading", error: undefined, }; } - if (!result.ok) { + if (result instanceof TalerError) { return { status: "loading-error", error: result, }; } - const transactions = result.data.transactions + const transactions = result.data.type === "fail" ? [] : result.data.body.transactions .map((tx) => { const negative = tx.direction === "debit"; const cp = parsePaytoUri(negative ? tx.creditor_payto_uri : tx.debtor_payto_uri); const counterpart = (cp === undefined || !cp.isKnown ? undefined : - cp.targetType === "iban" ? cp.iban : - cp.targetType === "x-taler-bank" ? cp.account : - cp.targetType === "bitcoin" ? `${cp.targetPath.substring(0, 6)}...` : undefined) ?? + cp.targetType === "iban" ? cp.iban : + cp.targetType === "x-taler-bank" ? cp.account : + cp.targetType === "bitcoin" ? `${cp.targetPath.substring(0, 6)}...` : undefined) ?? "unkown"; const when = AbsoluteTime.fromProtocolTimestamp(tx.date); @@ -61,7 +61,7 @@ export function useComponentState({ account }: Props): State { status: "ready", error: undefined, transactions, - onNext: result.isReachingEnd ? undefined : result.loadMore, - onPrev: result.isReachingStart ? undefined : result.loadMorePrev, + onNext: result.isLastPage ? undefined : result.loadMore, + onPrev: result.isFirstPage ? undefined : result.loadMorePrev, }; } diff --git a/packages/demobank-ui/src/components/Transactions/test.ts b/packages/demobank-ui/src/components/Transactions/test.ts index 9b713bbc5..a206d9f52 100644 --- a/packages/demobank-ui/src/components/Transactions/test.ts +++ b/packages/demobank-ui/src/components/Transactions/test.ts @@ -26,7 +26,7 @@ import { expect } from "chai"; import { TRANSACTION_API_EXAMPLE } from "../../endpoints.js"; import { Props } from "./index.js"; import { useComponentState } from "./state.js"; -import { HttpStatusCode } from "@gnu-taler/taler-util"; +import { HttpStatusCode, TalerError, TalerErrorCode } from "@gnu-taler/taler-util"; describe("Transaction states", () => { it("should query backend and render transactions", async () => { @@ -116,47 +116,47 @@ describe("Transaction states", () => { expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" }); }); - it("should show error message on not found", async () => { - const env = new SwrMockEnvironment(); - - const props: Props = { - account: "myAccount", - }; - - env.addRequestExpectation(TRANSACTION_API_EXAMPLE.LIST_NOT_FOUND, { - response: { - error: { - description: "Transaction page 0 could not be retrieved.", - }, - }, - }); - - const hookBehavior = await tests.hookBehaveLikeThis( - useComponentState, - props, - [ - ({ status, error }) => { - expect(status).equals("loading"); - expect(error).undefined; - }, - ({ status, error }) => { - expect(status).equals("loading-error"); - if (error === undefined || error.type !== ErrorType.CLIENT) { - throw Error("not the expected error"); - } - expect(error.payload).deep.equal({ - error: { - description: "Transaction page 0 could not be retrieved.", - }, - }); - }, - ], - env.buildTestingContext(), - ); - - expect(hookBehavior).deep.eq({ result: "ok" }); - expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" }); - }); + // it("should show error message on not found", async () => { + // const env = new SwrMockEnvironment(); + + // const props: Props = { + // account: "myAccount", + // }; + + // env.addRequestExpectation(TRANSACTION_API_EXAMPLE.LIST_NOT_FOUND, { + // response: { + // error: { + // description: "Transaction page 0 could not be retrieved.", + // }, + // }, + // }); + + // const hookBehavior = await tests.hookBehaveLikeThis( + // useComponentState, + // props, + // [ + // ({ status, error }) => { + // expect(status).equals("loading"); + // expect(error).undefined; + // }, + // ({ status, error }) => { + // expect(status).equals("loading-error"); + // if (error === undefined || error.type !== ErrorType.CLIENT) { + // throw Error("not the expected error"); + // } + // expect(error.payload).deep.equal({ + // error: { + // description: "Transaction page 0 could not be retrieved.", + // }, + // }); + // }, + // ], + // env.buildTestingContext(), + // ); + + // expect(hookBehavior).deep.eq({ result: "ok" }); + // expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" }); + // }); it("should show error message on server error", async () => { const env = new SwrMockEnvironment(); @@ -168,7 +168,7 @@ describe("Transaction states", () => { env.addRequestExpectation(TRANSACTION_API_EXAMPLE.LIST_ERROR, { response: { error: { - description: "Transaction page 0 could not be retrieved.", + code: TalerErrorCode.WALLET_HTTP_REQUEST_THROTTLED, }, }, }); @@ -183,14 +183,10 @@ describe("Transaction states", () => { }, ({ status, error }) => { expect(status).equals("loading-error"); - if (error === undefined || error.type !== ErrorType.SERVER) { + if (error === undefined || !error.hasErrorCode(TalerErrorCode.WALLET_HTTP_REQUEST_THROTTLED)) { throw Error("not the expected error"); } - expect(error.payload).deep.equal({ - error: { - description: "Transaction page 0 could not be retrieved.", - }, - }); + expect(error.errorDetail.code).deep.equal(TalerErrorCode.WALLET_HTTP_REQUEST_THROTTLED); }, ], env.buildTestingContext(), diff --git a/packages/demobank-ui/src/components/app.tsx b/packages/demobank-ui/src/components/app.tsx index 7cf658681..beb24da57 100644 --- a/packages/demobank-ui/src/components/app.tsx +++ b/packages/demobank-ui/src/components/app.tsx @@ -15,47 +15,26 @@ */ import { - LibtoolVersion, + canonicalizeBaseUrl, getGlobalLogLevel, - setGlobalLogLevelFromString, + setGlobalLogLevelFromString } from "@gnu-taler/taler-util"; -import { TranslationProvider, useApiContext } from "@gnu-taler/web-util/browser"; -import { ComponentChildren, Fragment, FunctionalComponent, VNode, h } from "preact"; +import { TranslationProvider } from "@gnu-taler/web-util/browser"; +import { Fragment, FunctionalComponent, h } from "preact"; import { SWRConfig } from "swr"; -import { BackendStateProvider, useBackendContext } from "../context/backend.js"; +import { BackendStateProvider } from "../context/backend.js"; +import { BankCoreApiProvider } from "../context/config.js"; import { strings } from "../i18n/strings.js"; +import { bankUiSettings } from "../settings.js"; import { Routing } from "./Routing.js"; -import { useEffect, useState } from "preact/hooks"; -import { Loading } from "./Loading.js"; -import { getInitialBackendBaseURL } from "../hooks/backend.js"; -import { BANK_INTEGRATION_PROTOCOL_VERSION, useConfigState } from "../hooks/config.js"; -import { ErrorLoading } from "./ErrorLoading.js"; -import { BankFrame } from "../pages/BankFrame.js"; -import { ConfigStateProvider } from "../context/config.js"; const WITH_LOCAL_STORAGE_CACHE = false; -/** - * FIXME: - * - * - INPUT elements have their 'required' attribute ignored. - * - * - the page needs a "home" button that either redirects to - * the profile page (when the user is logged in), or to - * the very initial home page. - * - * - histories 'pages' are grouped in UL elements that cause - * the rendering to visually separate each UL. History elements - * should instead line up without any separation caused by - * a implementation detail. - * - * - Many strings need to be i18n-wrapped. - */ - const App: FunctionalComponent = () => { + const baseUrl = getInitialBackendBaseURL(); return ( <TranslationProvider source={strings}> <BackendStateProvider> - <VersionCheck> + <BankCoreApiProvider baseUrl={baseUrl}> <SWRConfig value={{ provider: WITH_LOCAL_STORAGE_CACHE @@ -65,34 +44,15 @@ const App: FunctionalComponent = () => { > <Routing /> </SWRConfig> - </VersionCheck> + </BankCoreApiProvider> </BackendStateProvider> </TranslationProvider > ); }; + (window as any).setGlobalLogLevelFromString = setGlobalLogLevelFromString; (window as any).getGlobalLevel = getGlobalLogLevel; -function VersionCheck({ children }: { children: ComponentChildren }): VNode { - const checked = useConfigState() - - if (checked === undefined) { - return <Loading /> - } - if (checked.type === "wrong") { - return <BankFrame> - the bank backend is not supported. supported version "{BANK_INTEGRATION_PROTOCOL_VERSION}", server version "{checked}" - </BankFrame> - } - if (checked.type === "ok") { - return <ConfigStateProvider value={checked.result}>{children}</ConfigStateProvider> - } - - return <BankFrame> - <ErrorLoading error={checked.result} /> - </BankFrame> -} - function localStorageProvider(): Map<unknown, unknown> { const map = new Map(JSON.parse(localStorage.getItem("app-cache") || "[]")); @@ -104,3 +64,31 @@ function localStorageProvider(): Map<unknown, unknown> { } export default App; + +function getInitialBackendBaseURL(): string { + const overrideUrl = + typeof localStorage !== "undefined" + ? localStorage.getItem("bank-base-url") + : undefined; + let result: string; + if (!overrideUrl) { + //normal path + if (!bankUiSettings.backendBaseURL) { + console.error( + "ERROR: backendBaseURL was overridden by a setting file and missing. Setting value to 'window.origin'", + ); + result = window.origin + } else { + result = bankUiSettings.backendBaseURL; + } + } else { + // testing/development path + result = overrideUrl + } + try { + return canonicalizeBaseUrl(result) + } catch (e) { + //fall back + return canonicalizeBaseUrl(window.origin) + } +}
\ No newline at end of file diff --git a/packages/demobank-ui/src/context/config.ts b/packages/demobank-ui/src/context/config.ts index a2cde18eb..013d8922e 100644 --- a/packages/demobank-ui/src/context/config.ts +++ b/packages/demobank-ui/src/context/config.ts @@ -14,36 +14,71 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ +import { TalerCorebankApi, TalerCoreBankHttpClient, TalerError } from "@gnu-taler/taler-util"; +import { BrowserHttpLib, useTranslationContext } from "@gnu-taler/web-util/browser"; import { ComponentChildren, createContext, h, VNode } from "preact"; -import { useContext } from "preact/hooks"; +import { useContext, useEffect, useState } from "preact/hooks"; +import { ErrorLoading } from "../components/ErrorLoading.js"; /** * * @author Sebastian Javier Marchano (sebasjm) */ -export type Type = Required<SandboxBackend.Config>; - -const initial: Type = { - name: "", - version: "0:0:0", - currency_fraction_digits: 2, - currency_fraction_limit: 2, - fiat_currency: "", - have_cashout: false, +export type Type = { + url: URL, + config: TalerCorebankApi.Config, + api: TalerCoreBankHttpClient, }; -const Context = createContext<Type>(initial); -export const useConfigContext = (): Type => useContext(Context); +const Context = createContext<Type>(undefined as any); + +export const useBankCoreApiContext = (): Type => useContext(Context); + +export type ConfigResult = undefined + | { type: "ok", config: TalerCorebankApi.Config } + | { type: "incompatible", result: TalerCorebankApi.Config, supported: string } + | { type: "error", error: TalerError } -export const ConfigStateProvider = ({ - value, +export const BankCoreApiProvider = ({ + baseUrl, children, }: { - value: Type, + baseUrl: string, children: ComponentChildren; }): VNode => { + const [checked, setChecked] = useState<ConfigResult>() + const { i18n } = useTranslationContext(); + const url = new URL(baseUrl) + const api = new TalerCoreBankHttpClient(url.href, new BrowserHttpLib()) + useEffect(() => { + api.getConfig() + .then((resp) => { + if (api.isCompatible(resp.body.version)) { + setChecked({ type: "ok", config: resp.body }); + } else { + setChecked({ type: "incompatible", result: resp.body, supported: api.PROTOCOL_VERSION }) + } + }) + .catch((error: unknown) => { + if (error instanceof TalerError) { + setChecked({ type: "error", error }); + } + }); + }, []); + if (checked === undefined) { + return h("div", {}, "loading...") + } + if (checked.type === "error") { + return h(ErrorLoading, { error: checked.error, showDetail: true }) + } + if (checked.type === "incompatible") { + return h("div", {}, i18n.str`the bank backend is not supported. supported version "${checked.supported}", server version "${checked.result.version}"`) + } + const value: Type = { + url, config: checked.config, api + } return h(Context.Provider, { value, children, diff --git a/packages/demobank-ui/src/declaration.d.ts b/packages/demobank-ui/src/declaration.d.ts index 5c55cfade..c8ba3d576 100644 --- a/packages/demobank-ui/src/declaration.d.ts +++ b/packages/demobank-ui/src/declaration.d.ts @@ -31,525 +31,5 @@ declare module "*.png" { export default content; } -/********************************************** - * Type definitions for states and API calls. * - *********************************************/ - -/** - * Request body of POST /transactions. - * - * If the amount appears twice: both as a Payto parameter and - * in the JSON dedicate field, the one on the Payto URI takes - * precedence. - */ -interface TransactionRequestType { - paytoUri: string; - amount?: string; // with currency. -} - -/** - * Request body of /register. - */ -interface CredentialsRequestType { - username?: string; - password?: string; - repeatPassword?: string; -} - -/** - * Request body of /register. - */ -// interface LoginRequestType { -// username: string; -// password: string; -// } - -interface WireTransferRequestType { - iban?: string; - subject?: string; - amount?: string; -} - -type HashCode = string; -type EddsaPublicKey = string; -type EddsaSignature = string; -type WireTransferIdentifierRawP = string; -type RelativeTime = { - d_us: number | "forever" -}; -type ImageDataUrl = string; - -interface WithId { - id: string; -} - -interface Timestamp { - // Milliseconds since epoch, or the special - // value "forever" to represent an event that will - // never happen. - t_s: number | "never"; -} -interface Duration { - d_us: number | "forever"; -} - -interface WithId { - id: string; -} - -type Amount = string; -type UUID = string; -type Integer = number; - -namespace SandboxBackend { - export interface Config { - // Name of this API, always "circuit". - name: string; - // API version in the form $n:$n:$n - version: string; - // If 'true', the server provides local currency - // conversion support. - // If missing or false, some parts of the API - // are not supported and return 404. - have_cashout?: boolean; - - // Fiat currency. That is the currency in which - // cash-out operations ultimately wire money. - // Only applicable if have_cashout=true. - fiat_currency?: string; - - // How many digits should the amounts be rendered - // with by default. Small capitals should - // be used to render fractions beyond the number - // given here (like on gas stations). - currency_fraction_digits?: number; - - // How many decimal digits an operation can - // have. Wire transfers with more decimal - // digits will not be accepted. - currency_fraction_limit?: number; - } - interface RatiosAndFees { - // Exchange rate to buy the circuit currency from fiat. - buy_at_ratio: number; - // Exchange rate to sell the circuit currency for fiat. - sell_at_ratio: number; - // Fee to subtract after applying the buy ratio. - buy_in_fee: number; - // Fee to subtract after applying the sell ratio. - sell_out_fee: number; - } - - export interface SandboxError { - error?: SandboxErrorDetail; - } - interface SandboxErrorDetail { - // String enum classifying the error. - type: ErrorType; - - // Human-readable error description. - description: string; - } - enum ErrorType { - /** - * This error can be related to a business operation, - * a non-existent object requested by the client, or - * even when the bank itself fails. - */ - SandboxError = "sandbox-error", - - /** - * It is the error type thrown by helper functions - * from the Util library. Those are used by both - * Sandbox and Nexus, therefore the actual meaning - * must be carried by the error 'message' field. - */ - UtilError = "util-error", - } - - - type EmailAddress = string; - type PhoneNumber = string; - - namespace CoreBank { - - interface BankAccountCreateWithdrawalRequest { - // Amount to withdraw. - amount: Amount; - } - interface BankAccountCreateWithdrawalResponse { - // ID of the withdrawal, can be used to view/modify the withdrawal operation. - withdrawal_id: string; - - // URI that can be passed to the wallet to initiate the withdrawal. - taler_withdraw_uri: string; - } - interface BankAccountGetWithdrawalResponse { - // Amount that will be withdrawn with this withdrawal operation. - amount: Amount; - - // Was the withdrawal aborted? - aborted: boolean; - - // Has the withdrawal been confirmed by the bank? - // The wire transfer for a withdrawal is only executed once - // both confirmation_done is true and selection_done is true. - confirmation_done: boolean; - - // Did the wallet select reserve details? - selection_done: boolean; - - // Reserve public key selected by the exchange, - // only non-null if selection_done is true. - selected_reserve_pub: string | null; - - // Exchange account selected by the wallet, or by the bank - // (with the default exchange) in case the wallet did not provide one - // through the Integration API. - selected_exchange_account: string | null; - } - - interface BankAccountTransactionsResponse { - transactions: BankAccountTransactionInfo[]; - } - - interface BankAccountTransactionInfo { - creditor_payto_uri: string; - debtor_payto_uri: string; - - amount: Amount; - direction: "debit" | "credit"; - - subject: string; - - // Transaction unique ID. Matches - // $transaction_id from the URI. - row_id: number; - date: Timestamp; - } - - interface CreateBankAccountTransactionCreate { - // Address in the Payto format of the wire transfer receiver. - // It needs at least the 'message' query string parameter. - payto_uri: string; - - // Transaction amount (in the $currency:x.y format), optional. - // However, when not given, its value must occupy the 'amount' - // query string parameter of the 'payto' field. In case it - // is given in both places, the paytoUri's takes the precedence. - amount?: string; - } - - interface RegisterAccountRequest { - // Username - username: string; - - // Password. - password: string; - - // Legal name of the account owner - name: string; - - // Defaults to false. - is_public?: boolean; - - // Is this a taler exchange account? - // If true: - // - incoming transactions to the account that do not - // have a valid reserve public key are automatically - // - the account provides the taler-wire-gateway-api endpoints - // Defaults to false. - is_taler_exchange?: boolean; - - // Addresses where to send the TAN for transactions. - // Currently only used for cashouts. - // If missing, cashouts will fail. - // In the future, might be used for other transactions - // as well. - challenge_contact_data?: ChallengeContactData; - - // 'payto' address pointing a bank account - // external to the libeufin-bank. - // Payments will be sent to this bank account - // when the user wants to convert the local currency - // back to fiat currency outside libeufin-bank. - cashout_payto_uri?: string; - - // Internal payto URI of this bank account. - // Used mostly for testing. - internal_payto_uri?: string; - } - interface ChallengeContactData { - - // E-Mail address - email?: EmailAddress; - - // Phone number. - phone?: PhoneNumber; - } - - interface AccountReconfiguration { - - // Addresses where to send the TAN for transactions. - // Currently only used for cashouts. - // If missing, cashouts will fail. - // In the future, might be used for other transactions - // as well. - challenge_contact_data?: ChallengeContactData; - - // 'payto' address pointing a bank account - // external to the libeufin-bank. - // Payments will be sent to this bank account - // when the user wants to convert the local currency - // back to fiat currency outside libeufin-bank. - cashout_address?: string; - - // Legal name associated with $username. - // When missing, the old name is kept. - name?: string; - - // If present, change the is_exchange configuration. - // See RegisterAccountRequest - is_exchange?: boolean; - } - - - interface AccountPasswordChange { - - // New password. - new_password: string; - } - interface PublicAccountsResponse { - public_accounts: PublicAccount[]; - } - interface PublicAccount { - payto_uri: string; - - balance: Balance; - - // The account name (=username) of the - // libeufin-bank account. - account_name: string; - } - - interface ListBankAccountsResponse { - accounts: AccountMinimalData[]; - } - interface Balance { - amount: Amount; - credit_debit_indicator: "credit" | "debit"; - } - interface AccountMinimalData { - // Username - username: string; - - // Legal name of the account owner. - name: string; - - // current balance of the account - balance: Balance; - - // Number indicating the max debit allowed for the requesting user. - debit_threshold: Amount; - } - - interface AccountData { - // Legal name of the account owner. - name: string; - - // Available balance on the account. - balance: Balance; - - // payto://-URI of the account. - payto_uri: string; - - // Number indicating the max debit allowed for the requesting user. - debit_threshold: Amount; - - contact_data?: ChallengeContactData; - - // 'payto' address pointing the bank account - // where to send cashouts. This field is optional - // because not all the accounts are required to participate - // in the merchants' circuit. One example is the exchange: - // that never cashouts. Registering these accounts can - // be done via the access API. - cashout_payto_uri?: string; - } - - } - - namespace Circuit { - interface CircuitAccountRequest { - // Username - username: string; - - // Password. - password: string; - - // Addresses where to send the TAN. If - // this field is missing, then the cashout - // won't succeed. - contact_data: CircuitContactData; - - // Legal subject owning the account. - name: string; - - // 'payto' address pointing the bank account - // where to send payments, in case the user - // wants to convert the local currency back - // to fiat. - cashout_address: string; - - // IBAN of this bank account, which is therefore - // internal to the circuit. Randomly generated, - // when it is not given. - internal_iban?: string; - } - interface CircuitContactData { - // E-Mail address - email?: string; - - // Phone number. - phone?: string; - } - interface CircuitAccountReconfiguration { - // Addresses where to send the TAN. - contact_data: CircuitContactData; - - // 'payto' address pointing the bank account - // where to send payments, in case the user - // wants to convert the local currency back - // to fiat. - cashout_address: string; - } - interface AccountPasswordChange { - // New password. - new_password: string; - } - - interface CircuitAccounts { - customers: CircuitAccountMinimalData[]; - } - interface CircuitAccountMinimalData { - // Username - username: string; - - // Legal subject owning the account. - name: string; - - // current balance of the account - balance: Balance; - } - - interface CircuitAccountData { - // Username - username: string; - - // IBAN hosted at Libeufin Sandbox - iban: string; - - contact_data: CircuitContactData; - - // Legal subject owning the account. - name: string; - - // 'payto' address pointing the bank account - // where to send cashouts. - cashout_address: string; - } - interface CashoutEstimate { - // Amount that the user will get deducted from their regional - // bank account, according to the 'amount_credit' value. - amount_debit: Amount; - // Amount that the user will receive in their fiat - // bank account, according to 'amount_debit'. - amount_credit: Amount; - } - interface CashoutRequest { - // Optional subject to associate to the - // cashout operation. This data will appear - // as the incoming wire transfer subject in - // the user's external bank account. - subject?: string; - - // That is the plain amount that the user specified - // to cashout. Its $currency is the circuit currency. - amount_debit: Amount; - - // That is the amount that will effectively be - // transferred by the bank to the user's bank - // account, that is external to the circuit. - // It is expressed in the fiat currency and - // is calculated after the cashout fee and the - // exchange rate. See the /cashout-rates call. - amount_credit: Amount; - - // Which channel the TAN should be sent to. If - // this field is missing, it defaults to SMS. - // The default choice prefers to change the communication - // channel respect to the one used to issue this request. - tan_channel?: TanChannel; - } - interface CashoutPending { - // UUID identifying the operation being created - // and now waiting for the TAN confirmation. - uuid: string; - } - interface CashoutConfirm { - // the TAN that confirms $cashoutId. - tan: string; - } - interface Config { - // Name of this API, always "circuit". - name: string; - // API version in the form $n:$n:$n - version: string; - // Contains ratios and fees related to buying - // and selling the circuit currency. - ratios_and_fees: RatiosAndFees; - // Fiat currency. That is the currency in which - // cash-out operations ultimately wire money. - fiat_currency: string; - } - interface RatiosAndFees { - // Exchange rate to buy the circuit currency from fiat. - buy_at_ratio: float; - // Exchange rate to sell the circuit currency for fiat. - sell_at_ratio: float; - // Fee to subtract after applying the buy ratio. - buy_in_fee: float; - // Fee to subtract after applying the sell ratio. - sell_out_fee: float; - } - interface Cashouts { - // Every string represents a cash-out operation UUID. - cashouts: string[]; - } - interface CashoutStatusResponse { - status: CashoutStatus; - // Amount debited to the circuit bank account. - amount_debit: Amount; - // Amount credited to the external bank account. - amount_credit: Amount; - // Transaction subject. - subject: string; - // Circuit bank account that created the cash-out. - account: string; - // Fiat bank account that will receive the cashed out amount. - cashout_address: string; - // Ratios and fees related to this cash-out at the time - // when the operation was created. - ratios_and_fees: RatiosAndFees; - // Time when the cash-out was created. - creation_time: number; // milliseconds since the Unix epoch - // Time when the cash-out was confirmed via its TAN. - // Missing or null, when the operation wasn't confirmed yet. - confirmation_time?: number | null; // milliseconds since the Unix epoch - } - type CashoutStatusResponseWithId = CashoutStatusResponse & { id: string }; - } -} - declare const __VERSION__: string; declare const __GIT_HASH__: string; diff --git a/packages/demobank-ui/src/hooks/access.ts b/packages/demobank-ui/src/hooks/access.ts index 154c43ae6..2533d32fe 100644 --- a/packages/demobank-ui/src/hooks/access.ts +++ b/packages/demobank-ui/src/hooks/access.ts @@ -14,168 +14,31 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ -import { - HttpResponse, - HttpResponseOk, - HttpResponsePaginated, - RequestError, -} from "@gnu-taler/web-util/browser"; -import { useEffect, useState } from "preact/hooks"; -import { useBackendContext } from "../context/backend.js"; +import { AccessToken, TalerCoreBankResultByMethod, TalerHttpError } from "@gnu-taler/taler-util"; +import { useState } from "preact/hooks"; import { MAX_RESULT_SIZE, PAGE_SIZE } from "../utils.js"; -import { - useAuthenticatedBackend, - useMatchMutate, - usePublicBackend, -} from "./backend.js"; +import { useBackendState } from "./backend.js"; // FIX default import https://github.com/microsoft/TypeScript/issues/49189 import _useSWR, { SWRHook } from "swr"; -import { Amounts } from "@gnu-taler/taler-util"; +import { useBankCoreApiContext } from "../context/config.js"; const useSWR = _useSWR as unknown as SWRHook; -export function useAccessAPI(): AccessAPI { - const mutateAll = useMatchMutate(); - const { request } = useAuthenticatedBackend(); - const { state } = useBackendContext(); - if (state.status === "loggedOut") { - throw Error("access-api can't be used when the user is not logged In"); - } - const account = state.username; - - const createWithdrawal = async ( - data: SandboxBackend.CoreBank.BankAccountCreateWithdrawalRequest, - ): Promise< - HttpResponseOk<SandboxBackend.CoreBank.BankAccountCreateWithdrawalResponse> - > => { - const res = - await request<SandboxBackend.CoreBank.BankAccountCreateWithdrawalResponse>( - `accounts/${account}/withdrawals`, - { - method: "POST", - data, - contentType: "json", - }, - ); - return res; - }; - const createTransaction = async ( - data: SandboxBackend.CoreBank.CreateBankAccountTransactionCreate, - ): Promise<HttpResponseOk<void>> => { - const res = await request<void>( - `accounts/${account}/transactions`, - { - method: "POST", - data, - contentType: "json", - }, - ); - await mutateAll(/.*accounts\/.*/); - return res; - }; - const deleteAccount = async (): Promise<HttpResponseOk<void>> => { - const res = await request<void>(`accounts/${account}`, { - method: "DELETE", - contentType: "json", - }); - await mutateAll(/.*accounts\/.*/); - return res; - }; - - return { - createWithdrawal, - createTransaction, - deleteAccount, - }; -} - -export function useAccessAnonAPI(): AccessAnonAPI { - const mutateAll = useMatchMutate(); - const { request } = useAuthenticatedBackend(); - - const abortWithdrawal = async (id: string): Promise<HttpResponseOk<void>> => { - const res = await request<void>(`withdrawals/${id}/abort`, { - method: "POST", - contentType: "json", - }); - await mutateAll(/.*withdrawals\/.*/); - return res; - }; - const confirmWithdrawal = async ( - id: string, - ): Promise<HttpResponseOk<void>> => { - const res = await request<void>(`withdrawals/${id}/confirm`, { - method: "POST", - contentType: "json", - }); - await mutateAll(/.*withdrawals\/.*/); - return res; - }; - - return { - abortWithdrawal, - confirmWithdrawal, - }; -} - -export function useTestingAPI(): TestingAPI { - const mutateAll = useMatchMutate(); - const { request: noAuthRequest } = usePublicBackend(); - const register = async ( - data: SandboxBackend.CoreBank.RegisterAccountRequest, - ): Promise<HttpResponseOk<void>> => { - // FIXME: This API is deprecated. The normal account registration API should be used instead. - const res = await noAuthRequest<void>(`accounts`, { - method: "POST", - data, - contentType: "json", - }); - await mutateAll(/.*accounts\/.*/); - return res; - }; - - return { register }; -} - -export interface TestingAPI { - register: ( - data: SandboxBackend.CoreBank.RegisterAccountRequest, - ) => Promise<HttpResponseOk<void>>; -} - -export interface AccessAPI { - createWithdrawal: ( - data: SandboxBackend.CoreBank.BankAccountCreateWithdrawalRequest, - ) => Promise< - HttpResponseOk<SandboxBackend.CoreBank.BankAccountCreateWithdrawalResponse> - >; - createTransaction: ( - data: SandboxBackend.CoreBank.CreateBankAccountTransactionCreate, - ) => Promise<HttpResponseOk<void>>; - deleteAccount: () => Promise<HttpResponseOk<void>>; -} -export interface AccessAnonAPI { - abortWithdrawal: (wid: string) => Promise<HttpResponseOk<void>>; - confirmWithdrawal: (wid: string) => Promise<HttpResponseOk<void>>; -} export interface InstanceTemplateFilter { //FIXME: add filter to the template list position?: string; } -export function useAccountDetails( - account: string, -): HttpResponse< - SandboxBackend.CoreBank.AccountData, - SandboxBackend.SandboxError -> { - const { fetcher } = useAuthenticatedBackend(); - - const { data, error } = useSWR< - HttpResponseOk<SandboxBackend.CoreBank.AccountData>, - RequestError<SandboxBackend.SandboxError> - >([`accounts/${account}`], fetcher, { +export function useAccountDetails(account: string) { + const { state: credentials } = useBackendState(); + const { api } = useBankCoreApiContext(); + + async function fetcher([username, token]: [string, AccessToken]) { + return await api.getAccount({ username, token }) + } + const token = credentials.status !== "loggedIn" ? undefined : credentials.token + const { data, error } = useSWR<TalerCoreBankResultByMethod<"getAccount">, TalerHttpError>([account, token], fetcher, { refreshInterval: 0, refreshWhenHidden: false, revalidateOnFocus: false, @@ -187,26 +50,22 @@ export function useAccountDetails( keepPreviousData: true, }); - if (data) { - return data; - } - if (error) return error.cause; - return { loading: true }; + if (data) return data + if (error) return error; + return undefined; } // FIXME: should poll -export function useWithdrawalDetails( - wid: string, -): HttpResponse< - SandboxBackend.CoreBank.BankAccountGetWithdrawalResponse, - SandboxBackend.SandboxError -> { - const { fetcher } = useAuthenticatedBackend(); - - const { data, error } = useSWR< - HttpResponseOk<SandboxBackend.CoreBank.BankAccountGetWithdrawalResponse>, - RequestError<SandboxBackend.SandboxError> - >([`withdrawals/${wid}`], fetcher, { +export function useWithdrawalDetails(wid: string) { + // const { state: credentials } = useBackendState(); + const { api } = useBankCoreApiContext(); + + async function fetcher(wid: string) { + return await api.getWithdrawalById(wid) + } + + const { data, error } = useSWR<TalerCoreBankResultByMethod<"getWithdrawalById">, TalerHttpError>( + [wid], fetcher, { refreshInterval: 1000, refreshWhenHidden: false, revalidateOnFocus: false, @@ -218,25 +77,22 @@ export function useWithdrawalDetails( keepPreviousData: true, }); - // if (isValidating) return { loading: true, data: data?.data }; if (data) return data; - if (error) return error.cause; - return { loading: true }; + if (error) return error; + return undefined; } -export function useTransactionDetails( - account: string, - tid: string, -): HttpResponse< - SandboxBackend.CoreBank.BankAccountTransactionInfo, - SandboxBackend.SandboxError -> { - const { paginatedFetcher } = useAuthenticatedBackend(); - - const { data, error } = useSWR< - HttpResponseOk<SandboxBackend.CoreBank.BankAccountTransactionInfo>, - RequestError<SandboxBackend.SandboxError> - >([`accounts/${account}/transactions/${tid}`], paginatedFetcher, { +export function useTransactionDetails(account: string, tid: number) { + const { state: credentials } = useBackendState(); + const token = credentials.status !== "loggedIn" ? undefined : credentials.token + const { api } = useBankCoreApiContext(); + + async function fetcher([username, token, txid]: [string, AccessToken, number]) { + return await api.getTransactionById({ username, token }, txid) + } + + const { data, error } = useSWR<TalerCoreBankResultByMethod<"getTransactionById">, TalerHttpError>( + [account, token, tid], fetcher, { refreshInterval: 0, refreshWhenHidden: false, revalidateOnFocus: false, @@ -248,60 +104,37 @@ export function useTransactionDetails( keepPreviousData: true, }); - // if (isValidating) return { loading: true, data: data?.data }; if (data) return data; - if (error) return error.cause; - return { loading: true }; + if (error) return error; + return undefined; } -interface PaginationFilter { - // page: number; -} +export function usePublicAccounts(initial?: number) { + const [offset, setOffset] = useState<number | undefined>(initial); + const { api } = useBankCoreApiContext(); + + async function fetcher(txid: number | undefined) { + return await api.getPublicAccounts({ + limit: MAX_RESULT_SIZE, + offset: txid ? String(txid) : undefined, + order: "asc" + }) + } + + const { data, error } = useSWR<TalerCoreBankResultByMethod<"getPublicAccounts">, TalerHttpError>([offset], fetcher); -export function usePublicAccounts( - args?: PaginationFilter, -): HttpResponsePaginated< - SandboxBackend.CoreBank.PublicAccountsResponse, - SandboxBackend.SandboxError -> { - const { paginatedFetcher } = usePublicBackend(); - - const [page, setPage] = useState(1); - - const { - data: afterData, - error: afterError, - isValidating: loadingAfter, - } = useSWR< - HttpResponseOk<SandboxBackend.CoreBank.PublicAccountsResponse>, - RequestError<SandboxBackend.SandboxError> - >([`public-accounts`, page, PAGE_SIZE], paginatedFetcher); - - const [lastAfter, setLastAfter] = useState< - HttpResponse< - SandboxBackend.CoreBank.PublicAccountsResponse, - SandboxBackend.SandboxError - > - >({ loading: true }); - - useEffect(() => { - if (afterData) setLastAfter(afterData); - }, [afterData]); - - if (afterError) return afterError.cause; - - // if the query returns less that we ask, then we have reach the end or beginning - const isReachingEnd = - afterData && afterData.data.public_accounts.length < PAGE_SIZE; - const isReachingStart = false; + const isLastPage = + data && data.body.public_accounts.length < PAGE_SIZE; + const isFirstPage = !initial; const pagination = { - isReachingEnd, - isReachingStart, + isLastPage, + isFirstPage, loadMore: () => { - if (!afterData || isReachingEnd) return; - if (afterData.data.public_accounts.length < MAX_RESULT_SIZE) { - setPage(page + 1); + if (isLastPage || data?.type !== "ok") return; + const list = data.body.public_accounts + if (list.length < MAX_RESULT_SIZE) { + // setOffset(list[list.length-1].account_name); } }, loadMorePrev: () => { @@ -309,43 +142,39 @@ export function usePublicAccounts( }, }; - const public_accounts = !afterData - ? [] - : (afterData || lastAfter).data.public_accounts; - if (loadingAfter) return { loading: true, data: { public_accounts } }; - if (afterData) { - return { ok: true, data: { public_accounts }, ...pagination }; + // const public_accountslist = data?.type !== "ok" ? [] : data.body.public_accounts; + if (data) { + return { ok: true, data: data.body, ...pagination } } - return { loading: true }; + if (error) { + return error; + } + return undefined; } /** - * FIXME: mutate result when balance change (transaction ) + * @param account * @param args * @returns */ -export function useTransactions( - account: string, - args?: PaginationFilter, -): HttpResponsePaginated< - SandboxBackend.CoreBank.BankAccountTransactionsResponse, - SandboxBackend.SandboxError -> { - const { paginatedFetcher } = useAuthenticatedBackend(); - - const [start, setStart] = useState<string>(); - - const { - data: afterData, - error: afterError, - isValidating: loadingAfter, - } = useSWR< - HttpResponseOk<SandboxBackend.CoreBank.BankAccountTransactionsResponse>, - RequestError<SandboxBackend.SandboxError> - >( - [`accounts/${account}/transactions`, start, PAGE_SIZE], - paginatedFetcher, { +export function useTransactions(account: string, initial?: number) { + const { state: credentials } = useBackendState(); + const token = credentials.status !== "loggedIn" ? undefined : credentials.token + + const [offset, setOffset] = useState<number | undefined>(initial); + const { api } = useBankCoreApiContext(); + + async function fetcher([username, token, txid]: [string, AccessToken, number | undefined]) { + return await api.getTransactions({ username, token }, { + limit: MAX_RESULT_SIZE, + offset: txid ? String(txid) : undefined, + order: "dec" + }) + } + + const { data, error } = useSWR<TalerCoreBankResultByMethod<"getTransactions">, TalerHttpError>( + [account, token, offset], fetcher, { refreshInterval: 0, refreshWhenHidden: false, refreshWhenOffline: false, @@ -356,50 +185,30 @@ export function useTransactions( } ); - const [lastAfter, setLastAfter] = useState< - HttpResponse< - SandboxBackend.CoreBank.BankAccountTransactionsResponse, - SandboxBackend.SandboxError - > - >({ loading: true }); - - useEffect(() => { - if (afterData) setLastAfter(afterData); - }, [afterData]); - - if (afterError) { - return afterError.cause; - } - - // if the query returns less that we ask, then we have reach the end or beginning - const isReachingEnd = - afterData && afterData.data.transactions.length < PAGE_SIZE; - const isReachingStart = start == undefined; + const isLastPage = + data && data.type === "ok" && data.body.transactions.length < PAGE_SIZE; + const isFirstPage = true; const pagination = { - isReachingEnd, - isReachingStart, + isLastPage, + isFirstPage, loadMore: () => { - if (!afterData || isReachingEnd) return; - // if (afterData.data.transactions.length < MAX_RESULT_SIZE) { - const l = afterData.data.transactions[afterData.data.transactions.length-1] - setStart(String(l.row_id)); - // } + if (isLastPage || data?.type !== "ok") return; + const list = data.body.transactions + if (list.length < MAX_RESULT_SIZE) { + setOffset(list[list.length - 1].row_id); + } }, loadMorePrev: () => { - if (!afterData || isReachingStart) return; - // if (afterData.data.transactions.length < MAX_RESULT_SIZE) { - setStart(undefined) - // } + null; }, }; - const transactions = !afterData - ? [] - : (afterData || lastAfter).data.transactions; - if (loadingAfter) return { loading: true, data: { transactions } }; - if (afterData) { - return { ok: true, data: { transactions }, ...pagination }; + if (data) { + return { ok: true, data, ...pagination } + } + if (error) { + return error; } - return { loading: true }; + return undefined; } diff --git a/packages/demobank-ui/src/hooks/backend.ts b/packages/demobank-ui/src/hooks/backend.ts index 889618646..589d7fab0 100644 --- a/packages/demobank-ui/src/hooks/backend.ts +++ b/packages/demobank-ui/src/hooks/backend.ts @@ -15,6 +15,7 @@ */ import { + AccessToken, Codec, buildCodecForObject, buildCodecForUnion, @@ -24,23 +25,11 @@ import { codecForString, } from "@gnu-taler/taler-util"; import { - ErrorType, - HttpError, - RequestError, buildStorageKey, - useLocalStorage, + useLocalStorage } from "@gnu-taler/web-util/browser"; -import { - HttpResponse, - HttpResponseOk, - RequestOptions, -} from "@gnu-taler/web-util/browser"; -import { useApiContext } from "@gnu-taler/web-util/browser"; -import { useCallback, useEffect, useState } from "preact/hooks"; import { useSWRConfig } from "swr"; -import { useBackendContext } from "../context/backend.js"; import { bankUiSettings } from "../settings.js"; -import { AccessToken } from "./useCredentialsChecker.js"; /** * Has the information to reach and @@ -91,34 +80,6 @@ export const codecForBackendState = (): Codec<BackendState> => .alternative("expired", codecForBackendStateExpired()) .build("BackendState"); -export function getInitialBackendBaseURL(): string { - const overrideUrl = - typeof localStorage !== "undefined" - ? localStorage.getItem("bank-base-url") - : undefined; - let result: string; - if (!overrideUrl) { - //normal path - if (!bankUiSettings.backendBaseURL) { - console.error( - "ERROR: backendBaseURL was overridden by a setting file and missing. Setting value to 'window.origin'", - ); - result = window.origin - } else { - result = bankUiSettings.backendBaseURL; - } - } else { - // testing/development path - result = overrideUrl - } - try { - return canonicalizeBaseUrl(result) - } catch (e) { - //fall back - return canonicalizeBaseUrl(window.origin) - } -} - export const defaultState: BackendState = { status: "loggedOut", }; @@ -127,7 +88,7 @@ export interface BackendStateHandler { state: BackendState; logOut(): void; expired(): void; - logIn(info: {username: string, token: AccessToken}): void; + logIn(info: { username: string, token: AccessToken }): void; } const BACKEND_STATE_KEY = buildStorageKey( @@ -174,226 +135,6 @@ export function useBackendState(): BackendStateHandler { }; } -interface useBackendType { - request: <T>( - path: string, - options?: RequestOptions, - ) => Promise<HttpResponseOk<T>>; - fetcher: <T>(endpoint: string) => Promise<HttpResponseOk<T>>; - multiFetcher: <T>(endpoint: string[][]) => Promise<HttpResponseOk<T>[]>; - paginatedFetcher: <T>( - args: [string, string | undefined, number], - ) => Promise<HttpResponseOk<T>>; - sandboxAccountsFetcher: <T>( - args: [string, number, number, string], - ) => Promise<HttpResponseOk<T>>; - sandboxCashoutFetcher: <T>(endpoint: string[]) => Promise<HttpResponseOk<T>>; -} -export function usePublicBackend(): useBackendType { - const { request: requestHandler } = useApiContext(); - - const baseUrl = getInitialBackendBaseURL(); - - const request = useCallback( - function requestImpl<T>( - path: string, - options: RequestOptions = {}, - ): Promise<HttpResponseOk<T>> { - return requestHandler<T>(baseUrl, path, options); - }, - [baseUrl], - ); - - const fetcher = useCallback( - function fetcherImpl<T>(endpoint: string): Promise<HttpResponseOk<T>> { - return requestHandler<T>(baseUrl, endpoint); - }, - [baseUrl], - ); - const paginatedFetcher = useCallback( - function fetcherImpl<T>([endpoint, start, size]: [ - string, - string | undefined, - number, - ]): Promise<HttpResponseOk<T>> { - const delta = -1 * size //descending order - const params = start ? { delta, start } : { delta } - return requestHandler<T>(baseUrl, endpoint, { - params, - }); - }, - [baseUrl], - ); - const multiFetcher = useCallback( - function multiFetcherImpl<T>([endpoints]: string[][]): Promise< - HttpResponseOk<T>[] - > { - return Promise.all( - endpoints.map((endpoint) => requestHandler<T>(baseUrl, endpoint)), - ); - }, - [baseUrl], - ); - const sandboxAccountsFetcher = useCallback( - function fetcherImpl<T>([endpoint, page, size, account]: [ - string, - number, - number, - string, - ]): Promise<HttpResponseOk<T>> { - return requestHandler<T>(baseUrl, endpoint, { - params: { page: page || 1, size }, - }); - }, - [baseUrl], - ); - const sandboxCashoutFetcher = useCallback( - function fetcherImpl<T>([endpoint, account]: string[]): Promise< - HttpResponseOk<T> - > { - return requestHandler<T>(baseUrl, endpoint); - }, - [baseUrl], - ); - return { - request, - fetcher, - paginatedFetcher, - multiFetcher, - sandboxAccountsFetcher, - sandboxCashoutFetcher, - }; -} - -type CheckResult = ValidResult | RequestInvalidResult | InvalidationResult; - -interface ValidResult { - valid: true; -} -interface RequestInvalidResult { - valid: false; - requestError: true; - cause: RequestError<any>["cause"]; -} -interface InvalidationResult { - valid: false; - requestError: false; - error: unknown; -} - -export function useAuthenticatedBackend(): useBackendType { - const { state } = useBackendContext(); - const { request: requestHandler } = useApiContext(); - - // FIXME: libeufin returns 400 insteand of 401 if there is no auth token - const creds = state.status === "loggedIn" ? state.token : "secret-token:a"; - const baseUrl = getInitialBackendBaseURL(); - - const request = useCallback( - function requestImpl<T>( - path: string, - options: RequestOptions = {}, - ): Promise<HttpResponseOk<T>> { - return requestHandler<T>(baseUrl, path, { token: creds, ...options }); - }, - [baseUrl, creds], - ); - - const fetcher = useCallback( - function fetcherImpl<T>(endpoint: string): Promise<HttpResponseOk<T>> { - return requestHandler<T>(baseUrl, endpoint, { token: creds }); - }, - [baseUrl, creds], - ); - const paginatedFetcher = useCallback( - function fetcherImpl<T>([endpoint, start, size]: [ - string, - string | undefined, - number, - ]): Promise<HttpResponseOk<T>> { - const delta = -1 * size //descending order - const params = start ? { delta, start } : { delta } - return requestHandler<T>(baseUrl, endpoint, { - token: creds, - params, - }); - }, - [baseUrl, creds], - ); - const multiFetcher = useCallback( - function multiFetcherImpl<T>([endpoints]: string[][]): Promise< - HttpResponseOk<T>[] - > { - return Promise.all( - endpoints.map((endpoint) => - requestHandler<T>(baseUrl, endpoint, { token: creds }), - ), - ); - }, - [baseUrl, creds], - ); - const sandboxAccountsFetcher = useCallback( - function fetcherImpl<T>([endpoint, page, size, account]: [ - string, - number, - number, - string, - ]): Promise<HttpResponseOk<T>> { - return requestHandler<T>(baseUrl, endpoint, { - token: creds, - params: { page: page || 1, size }, - }); - }, - [baseUrl], - ); - - const sandboxCashoutFetcher = useCallback( - function fetcherImpl<T>([endpoint, account]: string[]): Promise< - HttpResponseOk<T> - > { - return requestHandler<T>(baseUrl, endpoint, { - token: creds, - params: { account }, - }); - }, - [baseUrl, creds], - ); - return { - request, - fetcher, - paginatedFetcher, - multiFetcher, - sandboxAccountsFetcher, - sandboxCashoutFetcher, - }; -} -/** - * - * @deprecated - */ -export function useBackendConfig(): HttpResponse< - SandboxBackend.Config, - SandboxBackend.SandboxError -> { - const { request } = usePublicBackend(); - - type Type = SandboxBackend.Config; - - const [result, setResult] = useState< - HttpResponse<Type, SandboxBackend.SandboxError> - >({ loading: true }); - - useEffect(() => { - request<Type>(`/config`) - .then((data) => setResult(data)) - .catch((error: RequestError<SandboxBackend.SandboxError>) => - setResult(error.cause), - ); - }, [request]); - - return result; -} - export function useMatchMutate(): ( re: RegExp, value?: unknown, diff --git a/packages/demobank-ui/src/hooks/circuit.ts b/packages/demobank-ui/src/hooks/circuit.ts index 5dba60951..208663f8b 100644 --- a/packages/demobank-ui/src/hooks/circuit.ts +++ b/packages/demobank-ui/src/hooks/circuit.ts @@ -14,239 +14,18 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ -import { - HttpResponse, - HttpResponseOk, - HttpResponsePaginated, - RequestError, - useApiContext, -} from "@gnu-taler/web-util/browser"; -import { useEffect, useMemo, useState } from "preact/hooks"; +import { useState } from "preact/hooks"; import { useBackendContext } from "../context/backend.js"; import { MAX_RESULT_SIZE, PAGE_SIZE } from "../utils.js"; -import { - getInitialBackendBaseURL, - useAuthenticatedBackend, - useMatchMutate, -} from "./backend.js"; +import { useBackendState } from "./backend.js"; // FIX default import https://github.com/microsoft/TypeScript/issues/49189 +import { AccessToken, AmountJson, Amounts, OperationOk, TalerCoreBankResultByMethod, TalerCorebankApi, TalerError, TalerHttpError } from "@gnu-taler/taler-util"; import _useSWR, { SWRHook } from "swr"; -import { AmountJson, Amounts } from "@gnu-taler/taler-util"; -import { AccessToken } from "./useCredentialsChecker.js"; -const useSWR = _useSWR as unknown as SWRHook; - -export function useAdminAccountAPI(): AdminAccountAPI { - const { request } = useAuthenticatedBackend(); - const mutateAll = useMatchMutate(); - const { state, logIn } = useBackendContext(); - if (state.status === "loggedOut") { - throw Error("access-api can't be used when the user is not logged In"); - } - - const createAccount = async ( - data: SandboxBackend.Circuit.CircuitAccountRequest, - ): Promise<HttpResponseOk<void>> => { - const res = await request<void>(`circuit-api/accounts`, { - method: "POST", - data, - contentType: "json", - }); - await mutateAll(/.*circuit-api\/accounts.*/); - return res; - }; - - const updateAccount = async ( - account: string, - data: SandboxBackend.Circuit.CircuitAccountReconfiguration, - ): Promise<HttpResponseOk<void>> => { - const res = await request<void>(`circuit-api/accounts/${account}`, { - method: "PATCH", - data, - contentType: "json", - }); - await mutateAll(/.*circuit-api\/accounts.*/); - return res; - }; - const deleteAccount = async ( - account: string, - ): Promise<HttpResponseOk<void>> => { - const res = await request<void>(`circuit-api/accounts/${account}`, { - method: "DELETE", - contentType: "json", - }); - await mutateAll(/.*circuit-api\/accounts.*/); - return res; - }; - const changePassword = async ( - account: string, - data: SandboxBackend.Circuit.AccountPasswordChange, - ): Promise<HttpResponseOk<void>> => { - const res = await request<void>(`circuit-api/accounts/${account}/auth`, { - method: "PATCH", - data, - contentType: "json", - }); - if (account === state.username) { - await mutateAll(/.*/); - logIn({ - username: account, - //FIXME: change password api - token: data.new_password as AccessToken, - }); - } - return res; - }; - - return { createAccount, deleteAccount, updateAccount, changePassword }; -} - -export function useCircuitAccountAPI(): CircuitAccountAPI { - const { request } = useAuthenticatedBackend(); - const mutateAll = useMatchMutate(); - const { state } = useBackendContext(); - if (state.status === "loggedOut") { - throw Error("access-api can't be used when the user is not logged In"); - } - const account = state.username; - - const updateAccount = async ( - data: SandboxBackend.Circuit.CircuitAccountReconfiguration, - ): Promise<HttpResponseOk<void>> => { - const res = await request<void>(`circuit-api/accounts/${account}`, { - method: "PATCH", - data, - contentType: "json", - }); - await mutateAll(/.*circuit-api\/accounts.*/); - return res; - }; - const changePassword = async ( - data: SandboxBackend.Circuit.AccountPasswordChange, - ): Promise<HttpResponseOk<void>> => { - const res = await request<void>(`circuit-api/accounts/${account}/auth`, { - method: "PATCH", - data, - contentType: "json", - }); - return res; - }; - - const createCashout = async ( - data: SandboxBackend.Circuit.CashoutRequest, - ): Promise<HttpResponseOk<SandboxBackend.Circuit.CashoutPending>> => { - const res = await request<SandboxBackend.Circuit.CashoutPending>( - `circuit-api/cashouts`, - { - method: "POST", - data, - contentType: "json", - }, - ); - return res; - }; - - const confirmCashout = async ( - cashoutId: string, - data: SandboxBackend.Circuit.CashoutConfirm, - ): Promise<HttpResponseOk<void>> => { - const res = await request<void>( - `circuit-api/cashouts/${cashoutId}/confirm`, - { - method: "POST", - data, - contentType: "json", - }, - ); - await mutateAll(/.*circuit-api\/cashout.*/); - return res; - }; - - const abortCashout = async ( - cashoutId: string, - ): Promise<HttpResponseOk<void>> => { - const res = await request<void>(`circuit-api/cashouts/${cashoutId}/abort`, { - method: "POST", - contentType: "json", - }); - await mutateAll(/.*circuit-api\/cashout.*/); - return res; - }; - - return { - updateAccount, - changePassword, - createCashout, - confirmCashout, - abortCashout, - }; -} +import { useBankCoreApiContext } from "../context/config.js"; +import { assertUnreachable } from "../pages/HomePage.js"; -export interface AdminAccountAPI { - createAccount: ( - data: SandboxBackend.Circuit.CircuitAccountRequest, - ) => Promise<HttpResponseOk<void>>; - deleteAccount: (account: string) => Promise<HttpResponseOk<void>>; - - updateAccount: ( - account: string, - data: SandboxBackend.Circuit.CircuitAccountReconfiguration, - ) => Promise<HttpResponseOk<void>>; - changePassword: ( - account: string, - data: SandboxBackend.Circuit.AccountPasswordChange, - ) => Promise<HttpResponseOk<void>>; -} - -export interface CircuitAccountAPI { - updateAccount: ( - data: SandboxBackend.Circuit.CircuitAccountReconfiguration, - ) => Promise<HttpResponseOk<void>>; - changePassword: ( - data: SandboxBackend.Circuit.AccountPasswordChange, - ) => Promise<HttpResponseOk<void>>; - createCashout: ( - data: SandboxBackend.Circuit.CashoutRequest, - ) => Promise<HttpResponseOk<SandboxBackend.Circuit.CashoutPending>>; - confirmCashout: ( - id: string, - data: SandboxBackend.Circuit.CashoutConfirm, - ) => Promise<HttpResponseOk<void>>; - abortCashout: (id: string) => Promise<HttpResponseOk<void>>; -} - -async function getBusinessStatus( - request: ReturnType<typeof useApiContext>["request"], - username: string, - token: AccessToken, -): Promise<boolean> { - try { - const url = getInitialBackendBaseURL(); - const result = await request<SandboxBackend.Circuit.CircuitAccountData>( - url, - `circuit-api/accounts/${username}`, - { token }, - ); - return result.ok; - } catch (error) { - return false; - } -} - -async function getEstimationByCredit( - request: ReturnType<typeof useApiContext>["request"], - basicAuth: { username: string; password: string }, -): Promise<boolean> { - try { - const url = getInitialBackendBaseURL(); - const result = await request< - HttpResponseOk<SandboxBackend.Circuit.CircuitAccountData> - >(url, `circuit-api/accounts/${basicAuth.username}`, { basicAuth }); - return result.ok; - } catch (error) { - return false; - } -} +const useSWR = _useSWR as unknown as SWRHook; export type TransferCalculation = { debit: AmountJson; @@ -266,37 +45,27 @@ type CashoutEstimators = { export function useEstimator(): CashoutEstimators { const { state } = useBackendContext(); - const { request } = useApiContext(); + const { api } = useBankCoreApiContext(); const creds = state.status !== "loggedIn" ? undefined : state.token; return { estimateByCredit: async (amount, fee, rate) => { - const zeroBalance = Amounts.zeroOfCurrency(fee.currency); - const zeroFiat = Amounts.zeroOfCurrency(fee.currency); - const zeroCalc = { - debit: zeroBalance, - credit: zeroFiat, - beforeFee: zeroBalance, - }; - const url = getInitialBackendBaseURL(); - const result = await request<SandboxBackend.Circuit.CashoutEstimate>( - url, - `circuit-api/cashouts/estimates`, - { - token: creds, - params: { - amount_credit: Amounts.stringify(amount), - }, - }, - ); - // const credit = Amounts.parseOrThrow(result.data.data.amount_credit); + const resp = await api.getCashoutRate({ + credit: amount + }); + if (resp.type === "fail") { + // can't happen + // not-supported: it should not be able to call this function + // wrong-calculation: we are using just one parameter + throw TalerError.fromDetail(resp.detail.code, {}, resp.detail.hint) + } const credit = amount; const _credit = { ...credit, currency: fee.currency }; const beforeFee = Amounts.sub(_credit, fee).amount; - const debit = Amounts.parseOrThrow(result.data.amount_debit); + const debit = Amounts.parseOrThrow(resp.body.amount_debit); return { debit, beforeFee, @@ -311,18 +80,14 @@ export function useEstimator(): CashoutEstimators { credit: zeroFiat, beforeFee: zeroBalance, }; - const url = getInitialBackendBaseURL(); - const result = await request<SandboxBackend.Circuit.CashoutEstimate>( - url, - `circuit-api/cashouts/estimates`, - { - token: creds, - params: { - amount_debit: Amounts.stringify(amount), - }, - }, - ); - const credit = Amounts.parseOrThrow(result.data.amount_credit); + const resp = await api.getCashoutRate({ debit: amount }); + if (resp.type === "fail") { + // can't happen + // not-supported: it should not be able to call this function + // wrong-calculation: we are using just one parameter + throw TalerError.fromDetail(resp.detail.code, {}, resp.detail.hint) + } + const credit = Amounts.parseOrThrow(resp.body.amount_credit); const _credit = { ...credit, currency: fee.currency }; const debit = amount; const beforeFee = Amounts.sub(_credit, fee).amount; @@ -335,67 +100,15 @@ export function useEstimator(): CashoutEstimators { }; } -export function useBusinessAccountFlag(): boolean | undefined { - const [isBusiness, setIsBusiness] = useState<boolean | undefined>(); - const { state } = useBackendContext(); - const { request } = useApiContext(); - const creds = - state.status !== "loggedIn" - ? undefined - : {user: state.username, token: state.token}; - - useEffect(() => { - if (!creds) return; - getBusinessStatus(request, creds.user, creds.token) - .then((result) => { - setIsBusiness(result); - }) - .catch((error) => { - setIsBusiness(false); - }); - }); - - return isBusiness; -} - -export function useBusinessAccountDetails( - account: string, -): HttpResponse< - SandboxBackend.Circuit.CircuitAccountData, - SandboxBackend.SandboxError -> { - const { fetcher } = useAuthenticatedBackend(); - - const { data, error } = useSWR< - HttpResponseOk<SandboxBackend.Circuit.CircuitAccountData>, - RequestError<SandboxBackend.SandboxError> - >([`circuit-api/accounts/${account}`], fetcher, { - refreshInterval: 0, - refreshWhenHidden: false, - revalidateOnFocus: false, - revalidateOnReconnect: false, - refreshWhenOffline: false, - errorRetryCount: 0, - errorRetryInterval: 1, - shouldRetryOnError: false, - keepPreviousData: true, - }); - - if (data) return data; - if (error) return error.cause; - return { loading: true }; -} - -export function useRatiosAndFeeConfig(): HttpResponse< - SandboxBackend.Circuit.Config, - SandboxBackend.SandboxError -> { - const { fetcher } = useAuthenticatedBackend(); +export function useRatiosAndFeeConfig() { + const { api } = useBankCoreApiContext(); + function fetcher() { + return api.getConversionRates() + } const { data, error } = useSWR< - HttpResponseOk<SandboxBackend.Circuit.Config>, - RequestError<SandboxBackend.SandboxError> - >([`circuit-api/config`], fetcher, { + TalerCoreBankResultByMethod<"getConversionRates">, TalerHttpError + >([], fetcher, { refreshInterval: 60 * 1000, refreshWhenHidden: false, revalidateOnFocus: false, @@ -409,8 +122,8 @@ export function useRatiosAndFeeConfig(): HttpResponse< }); if (data) return data; - if (error) return error.cause; - return { loading: true }; + if (error) return error; + return undefined; } interface PaginationFilter { @@ -418,58 +131,49 @@ interface PaginationFilter { page?: number; } -export function useBusinessAccounts( - args?: PaginationFilter, -): HttpResponsePaginated< - SandboxBackend.Circuit.CircuitAccounts, - SandboxBackend.SandboxError -> { - const { sandboxAccountsFetcher } = useAuthenticatedBackend(); - const [page, setPage] = useState(0); +export function useBusinessAccounts() { + const { state: credentials } = useBackendState(); + const token = credentials.status !== "loggedIn" ? undefined : credentials.token + const { api } = useBankCoreApiContext(); - const { - data: afterData, - error: afterError, - // isValidating: loadingAfter, - } = useSWR< - HttpResponseOk<SandboxBackend.Circuit.CircuitAccounts>, - RequestError<SandboxBackend.SandboxError> - >( - [`accounts`, args?.page, PAGE_SIZE, args?.account], - sandboxAccountsFetcher, - { - refreshInterval: 0, - refreshWhenHidden: false, - revalidateOnFocus: false, - revalidateOnReconnect: false, - refreshWhenOffline: false, - errorRetryCount: 0, - errorRetryInterval: 1, - shouldRetryOnError: false, - keepPreviousData: true, - }, - ); + const [offset, setOffset] = useState<string | undefined>(); - // const [lastAfter, setLastAfter] = useState< - // HttpResponse<SandboxBackend.Circuit.CircuitAccounts, SandboxBackend.SandboxError> - // >({ loading: true }); + function fetcher(token: AccessToken, offset?: string) { + return api.getAccounts(token, { + limit: MAX_RESULT_SIZE, + offset, + order: "asc" + }) + } - // useEffect(() => { - // if (afterData) setLastAfter(afterData); - // }, [afterData]); + const { data, error } = useSWR<TalerCoreBankResultByMethod<"getAccounts">, TalerHttpError>( + [token, offset], fetcher, { + refreshInterval: 0, + refreshWhenHidden: false, + revalidateOnFocus: false, + revalidateOnReconnect: false, + refreshWhenOffline: false, + errorRetryCount: 0, + errorRetryInterval: 1, + shouldRetryOnError: false, + keepPreviousData: true, + }, + ); - // if the query returns less that we ask, then we have reach the end or beginning - const isReachingEnd = - afterData && afterData.data?.customers?.length < PAGE_SIZE; - const isReachingStart = false; + const isLastPage = + data && data.type === "ok" && data.body.accounts.length < PAGE_SIZE; + const isFirstPage = false; const pagination = { - isReachingEnd, - isReachingStart, + isLastPage, + isFirstPage, loadMore: () => { - if (!afterData || isReachingEnd) return; - if (afterData.data?.customers?.length < MAX_RESULT_SIZE) { - setPage(page + 1); + if (isLastPage || data?.type !== "ok") return; + const list = data.body.accounts + if (list.length < MAX_RESULT_SIZE) { + //FIXME: define pagination + + // setOffset(list[list.length - 1].row_id); } }, loadMorePrev: () => { @@ -477,85 +181,65 @@ export function useBusinessAccounts( }, }; - const result = useMemo(() => { - const customers = !afterData ? [] : afterData?.data?.customers ?? []; - return { ok: true as const, data: { customers }, ...pagination }; - }, [afterData?.data]); - - if (afterError) return afterError.cause; - if (afterData) { - return result; - } + if (data) return { ok: true, data, ...pagination }; + if (error) return error; + return undefined; +} - // if (loadingAfter) - // return { loading: true, data: { customers } }; - // if (afterData) { - // return { ok: true, data: { customers }, ...pagination }; - // } - return { loading: true }; +type CashoutWithId = TalerCorebankApi.CashoutStatusResponse & { id: string } +function notUndefined(c: CashoutWithId | undefined): c is CashoutWithId { + return c !== undefined } +export function useCashouts(account: string) { + const { state: credentials } = useBackendState(); + const token = credentials.status !== "loggedIn" ? undefined : credentials.token + const { api } = useBankCoreApiContext(); + + async function fetcher([username, token]: [string, AccessToken]) { + const list = await api.getAccountCashouts({ username, token }) + if (list.type !== "ok") { + assertUnreachable(list.type) + } + const all: Array<CashoutWithId | undefined> = await Promise.all(list.body.cashouts.map(c => { + return api.getCashoutById({ username, token }, c.cashout_id).then(r => { + if (r.type === "fail") return undefined + return { ...r.body, id: c.cashout_id } + }) + })) -export function useCashouts( - account: string, -): HttpResponse< - SandboxBackend.Circuit.CashoutStatusResponseWithId[], - SandboxBackend.SandboxError -> { - const { sandboxCashoutFetcher, multiFetcher } = useAuthenticatedBackend(); + const cashouts = all.filter(notUndefined) + return { type: "ok" as const, body: { cashouts } } + } - const { data: list, error: listError } = useSWR< - HttpResponseOk<SandboxBackend.Circuit.Cashouts>, - RequestError<SandboxBackend.SandboxError> - >([`circuit-api/cashouts`, account], sandboxCashoutFetcher, { + const { data, error } = useSWR<OperationOk<{ cashouts: CashoutWithId[] }>, TalerHttpError>( + [account, token], fetcher, { refreshInterval: 0, refreshWhenHidden: false, revalidateOnFocus: false, revalidateOnReconnect: false, refreshWhenOffline: false, + errorRetryCount: 0, + errorRetryInterval: 1, + shouldRetryOnError: false, + keepPreviousData: true, }); - const paths = ((list?.data && list?.data.cashouts) || []).map( - (cashoutId) => `circuit-api/cashouts/${cashoutId}`, - ); - const { data: cashouts, error: productError } = useSWR< - HttpResponseOk<SandboxBackend.Circuit.CashoutStatusResponse>[], - RequestError<SandboxBackend.SandboxError> - >([paths], multiFetcher, { - refreshInterval: 0, - refreshWhenHidden: false, - revalidateOnFocus: false, - revalidateOnReconnect: false, - refreshWhenOffline: false, - }); + if (data) return data; + if (error) return error; + return undefined; +} - if (listError) return listError.cause; - if (productError) return productError.cause; +export function useCashoutDetails(cashoutId: string) { + const { state: credentials } = useBackendState(); + const creds = credentials.status !== "loggedIn" ? undefined : credentials + const { api } = useBankCoreApiContext(); - if (cashouts) { - const dataWithId = cashouts.map((d) => { - //take the id from the queried url - return { - ...d.data, - id: d.info?.url.replace(/.*\/cashouts\//, "") || "", - }; - }); - return { ok: true, data: dataWithId }; + async function fetcher([username, token, id]: [string, AccessToken, string]) { + return api.getCashoutById({ username, token }, id) } - return { loading: true }; -} - -export function useCashoutDetails( - id: string, -): HttpResponse< - SandboxBackend.Circuit.CashoutStatusResponse, - SandboxBackend.SandboxError -> { - const { fetcher } = useAuthenticatedBackend(); - const { data, error } = useSWR< - HttpResponseOk<SandboxBackend.Circuit.CashoutStatusResponse>, - RequestError<SandboxBackend.SandboxError> - >([`circuit-api/cashouts/${id}`], fetcher, { + const { data, error } = useSWR<TalerCoreBankResultByMethod<"getCashoutById">, TalerHttpError>( + [creds?.username, creds?.token, cashoutId], fetcher, { refreshInterval: 0, refreshWhenHidden: false, revalidateOnFocus: false, @@ -568,6 +252,6 @@ export function useCashoutDetails( }); if (data) return data; - if (error) return error.cause; - return { loading: true }; + if (error) return error; + return undefined; } diff --git a/packages/demobank-ui/src/hooks/config.ts b/packages/demobank-ui/src/hooks/config.ts deleted file mode 100644 index a3bd294db..000000000 --- a/packages/demobank-ui/src/hooks/config.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { LibtoolVersion } from "@gnu-taler/taler-util"; -import { ErrorType, HttpError, HttpResponseServerError, RequestError, useApiContext } from "@gnu-taler/web-util/browser"; -import { useEffect, useState } from "preact/hooks"; -import { getInitialBackendBaseURL } from "./backend.js"; - -/** - * Protocol version spoken with the bank. - * - * Uses libtool's current:revision:age versioning. - */ -export const BANK_INTEGRATION_PROTOCOL_VERSION = "0:0:0"; - -async function getConfigState( - request: ReturnType<typeof useApiContext>["request"], -): Promise<SandboxBackend.Config> { - const url = getInitialBackendBaseURL(); - const result = await request<SandboxBackend.Config>(url, `config`); - return result.data; -} - -export type ConfigResult = undefined - | { type: "ok", result: Required<SandboxBackend.Config> } - | { type: "wrong", result: SandboxBackend.Config } - | { type: "error", result: HttpError<SandboxBackend.SandboxError> } - -export function useConfigState(): ConfigResult { - const [checked, setChecked] = useState<ConfigResult>() - const { request } = useApiContext(); - - useEffect(() => { - getConfigState(request) - .then((result) => { - const r = LibtoolVersion.compare(BANK_INTEGRATION_PROTOCOL_VERSION, result.version) - if (r?.compatible) { - const complete: Required<SandboxBackend.Config> = { - currency_fraction_digits: result.currency_fraction_digits ?? 2, - currency_fraction_limit: result.currency_fraction_limit ?? 2, - fiat_currency: "", - have_cashout: result.have_cashout ?? false, - name: result.name, - version: result.version, - } - setChecked({ type: "ok", result: complete }); - } else { - setChecked({ type: "wrong", result }) - } - }) - .catch((error: unknown) => { - if (error instanceof RequestError) { - const result = error.cause - setChecked({ type: "error", result }); - } - }); - }, []); - - return checked; -} - - diff --git a/packages/demobank-ui/src/hooks/useCredentialsChecker.ts b/packages/demobank-ui/src/hooks/useCredentialsChecker.ts deleted file mode 100644 index b3dedb654..000000000 --- a/packages/demobank-ui/src/hooks/useCredentialsChecker.ts +++ /dev/null @@ -1,135 +0,0 @@ -import { AbsoluteTime, HttpStatusCode } from "@gnu-taler/taler-util"; -import { ErrorType, HttpError, RequestError, useApiContext } from "@gnu-taler/web-util/browser"; -import { getInitialBackendBaseURL } from "./backend.js"; - -export function useCredentialsChecker() { - const { request } = useApiContext(); - const baseUrl = getInitialBackendBaseURL(); - //check against instance details endpoint - //while merchant backend doesn't have a login endpoint - async function requestNewLoginToken( - username: string, - password: string, - ): Promise<LoginResult> { - const data: LoginTokenRequest = { - scope: "readwrite" as "write", //FIX: different than merchant - duration: { - // d_us: "forever" //FIX: should return shortest - d_us: 60 * 60 * 24 * 7 * 1000 * 1000 - }, - refreshable: true, - } - try { - const response = await request<LoginTokenSuccessResponse>(baseUrl, `accounts/${username}/token`, { - method: "POST", - basicAuth: { - username, - password, - }, - data, - contentType: "json" - }); - return { valid: true, token: `secret-token:${response.data.access_token}` as AccessToken, expiration: response.data.expiration }; - } catch (error) { - if (error instanceof RequestError) { - return { valid: false, cause: error.cause }; - } - - return { - valid: false, cause: { - type: ErrorType.UNEXPECTED, - loading: false, - info: { - hasToken: true, - status: 0, - options: {}, - url: `/private/token`, - payload: {} - }, - exception: error, - message: (error instanceof Error ? error.message : "unpexepected error") - } - }; - } - }; - - async function refreshLoginToken( - baseUrl: string, - token: LoginToken - ): Promise<LoginResult> { - - if (AbsoluteTime.isExpired(AbsoluteTime.fromProtocolTimestamp(token.expiration))) { - return { - valid: false, cause: { - type: ErrorType.CLIENT, - status: HttpStatusCode.Unauthorized, - message: "login token expired, login again.", - info: { - hasToken: true, - status: 401, - options: {}, - url: `/private/token`, - payload: {} - }, - payload: {} - }, - } - } - - return requestNewLoginToken(baseUrl, token.token) - } - return { requestNewLoginToken, refreshLoginToken } -} - -export interface LoginToken { - token: AccessToken, - expiration: Timestamp, -} -// token used to get loginToken -// must forget after used -declare const __ac_token: unique symbol; -export type AccessToken = string & { - [__ac_token]: true; -}; - -type YesOrNo = "yes" | "no"; -export type LoginResult = { - valid: true; - token: AccessToken; - expiration: Timestamp; -} | { - valid: false; - cause: HttpError<{}>; -} - - -// DELETE /private/instances/$INSTANCE -export interface LoginTokenRequest { - // Scope of the token (which kinds of operations it will allow) - scope: "readonly" | "write"; - - // Server may impose its own upper bound - // on the token validity duration - duration?: RelativeTime; - - // Can this token be refreshed? - // Defaults to false. - refreshable?: boolean; -} -export interface LoginTokenSuccessResponse { - // The login token that can be used to access resources - // that are in scope for some time. Must be prefixed - // with "Bearer " when used in the "Authorization" HTTP header. - // Will already begin with the RFC 8959 prefix. - access_token: AccessToken; - - // Scope of the token (which kinds of operations it will allow) - scope: "readonly" | "write"; - - // Server may impose its own upper bound - // on the token validity duration - expiration: Timestamp; - - // Can this token be refreshed? - refreshable: boolean; -} diff --git a/packages/demobank-ui/src/pages/AccountPage/index.ts b/packages/demobank-ui/src/pages/AccountPage/index.ts index 9230fb6b1..ef6b4fede 100644 --- a/packages/demobank-ui/src/pages/AccountPage/index.ts +++ b/packages/demobank-ui/src/pages/AccountPage/index.ts @@ -14,20 +14,17 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ -import { HttpError, HttpResponseOk, HttpResponsePaginated, utils } from "@gnu-taler/web-util/browser"; -import { AbsoluteTime, AmountJson, PaytoUriIBAN, PaytoUriTalerBank } from "@gnu-taler/taler-util"; -import { Loading } from "../../components/Loading.js"; -import { useComponentState } from "./state.js"; -import { ReadyView, InvalidIbanView } from "./views.js"; +import { AbsoluteTime, AmountJson, TalerCorebankApi, TalerError, TalerErrorDetail } from "@gnu-taler/taler-util"; +import { HttpResponsePaginated, utils } from "@gnu-taler/web-util/browser"; import { VNode } from "preact"; -import { LoginForm } from "../LoginForm.js"; import { ErrorLoading } from "../../components/ErrorLoading.js"; +import { Loading } from "../../components/Loading.js"; +import { LoginForm } from "../LoginForm.js"; +import { useComponentState } from "./state.js"; +import { InvalidIbanView, ReadyView } from "./views.js"; export interface Props { account: string; - onLoadNotOk: <T>( - error: HttpResponsePaginated<T, SandboxBackend.SandboxError>, - ) => VNode; goToBusinessAccount: () => void; goToConfirmOperation: (id: string) => void; } @@ -42,7 +39,7 @@ export namespace State { export interface LoadingError { status: "loading-error"; - error: HttpError<SandboxBackend.SandboxError>; + error: TalerError; } export interface BaseInfo { @@ -60,12 +57,12 @@ export namespace State { export interface InvalidIban { status: "invalid-iban", - error: HttpResponseOk<SandboxBackend.CoreBank.AccountData>; + error: TalerCorebankApi.AccountData; } export interface UserNotFound { - status: "error-user-not-found", - error: HttpError<any>; + status: "login", + reason: "not-found" | "forbidden"; onRegister?: () => void; } } @@ -80,7 +77,7 @@ export interface Transaction { const viewMapping: utils.StateViewMap<State> = { loading: Loading, - "error-user-not-found": LoginForm, + "login": LoginForm, "invalid-iban": InvalidIbanView, "loading-error": ErrorLoading, ready: ReadyView, diff --git a/packages/demobank-ui/src/pages/AccountPage/state.ts b/packages/demobank-ui/src/pages/AccountPage/state.ts index ca7e1d447..96d45b7bd 100644 --- a/packages/demobank-ui/src/pages/AccountPage/state.ts +++ b/packages/demobank-ui/src/pages/AccountPage/state.ts @@ -14,54 +14,47 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ -import { Amounts, HttpStatusCode, parsePaytoUri } from "@gnu-taler/taler-util"; +import { Amounts, HttpStatusCode, TalerError, TalerErrorCode, parsePaytoUri } from "@gnu-taler/taler-util"; import { ErrorType, notifyError, useTranslationContext } from "@gnu-taler/web-util/browser"; -import { useBackendContext } from "../../context/backend.js"; import { useAccountDetails } from "../../hooks/access.js"; import { Props, State } from "./index.js"; +import { assertUnreachable } from "../HomePage.js"; export function useComponentState({ account, goToBusinessAccount, goToConfirmOperation }: Props): State { const result = useAccountDetails(account); - const backend = useBackendContext(); const { i18n } = useTranslationContext(); - if (result.loading) { + if (!result) { return { status: "loading", error: undefined, }; } - if (!result.ok) { - if (result.loading || result.type === ErrorType.TIMEOUT) { - return { - status: "loading-error", - error: result, - }; - } - //logout if there is any error, not if loading - // backend.logOut(); - if (result.status === HttpStatusCode.NotFound) { - notifyError(i18n.str`Username or account label "${account}" not found`, undefined); - return { - status: "error-user-not-found", - error: result, - }; - } - if (result.status === HttpStatusCode.Unauthorized) { - notifyError(i18n.str`Authorization denied`, i18n.str`Maybe the session has expired, login again.`); - return { - status: "error-user-not-found", - error: result, - }; - } + if (result instanceof TalerError) { return { status: "loading-error", error: result, }; } - const { data } = result; + if (result.type === "fail") { + switch (result.case) { + case "unauthorized": return { + status: "login", + reason: "forbidden" + } + case "not-found": return { + status: "login", + reason: "not-found", + } + default: { + assertUnreachable(result) + } + } + } + + const { body: data } = result; const balance = Amounts.parseOrThrow(data.balance.amount); @@ -71,7 +64,7 @@ export function useComponentState({ account, goToBusinessAccount, goToConfirmOpe if (!payto || !payto.isKnown || (payto.targetType !== "iban" && payto.targetType !== "x-taler-bank")) { return { status: "invalid-iban", - error: result + error: data }; } diff --git a/packages/demobank-ui/src/pages/AccountPage/views.tsx b/packages/demobank-ui/src/pages/AccountPage/views.tsx index 483cb579a..0604001e3 100644 --- a/packages/demobank-ui/src/pages/AccountPage/views.tsx +++ b/packages/demobank-ui/src/pages/AccountPage/views.tsx @@ -18,14 +18,13 @@ import { useTranslationContext } from "@gnu-taler/web-util/browser"; import { Fragment, VNode, h } from "preact"; import { Attention } from "../../components/Attention.js"; import { Transactions } from "../../components/Transactions/index.js"; -import { useBusinessAccountDetails } from "../../hooks/circuit.js"; import { useSettings } from "../../hooks/settings.js"; import { PaymentOptions } from "../PaymentOptions.js"; import { State } from "./index.js"; export function InvalidIbanView({ error }: State.InvalidIban) { return ( - <div>Payto from server is not valid "{error.data.payto_uri}"</div> + <div>Payto from server is not valid "{error.payto_uri}"</div> ); } @@ -75,19 +74,20 @@ function MaybeBusinessButton({ onClick: () => void; }): VNode { const { i18n } = useTranslationContext(); - const result = useBusinessAccountDetails(account); - if (!result.ok) return <Fragment />; - return ( - <div class="w-full flex justify-end"> - <button - 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" - onClick={(e) => { - e.preventDefault() - onClick() - }} - > - <i18n.Translate>Business Profile</i18n.Translate> - </button> - </div> - ); + return <Fragment /> + // const result = useBusinessAccountDetails(account); + // if (!result.ok) return <Fragment />; + // return ( + // <div class="w-full flex justify-end"> + // <button + // 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" + // onClick={(e) => { + // e.preventDefault() + // onClick() + // }} + // > + // <i18n.Translate>Business Profile</i18n.Translate> + // </button> + // </div> + // ); } diff --git a/packages/demobank-ui/src/pages/BankFrame.tsx b/packages/demobank-ui/src/pages/BankFrame.tsx index 6ab6ba3e4..c75964f8e 100644 --- a/packages/demobank-ui/src/pages/BankFrame.tsx +++ b/packages/demobank-ui/src/pages/BankFrame.tsx @@ -14,7 +14,7 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ -import { Amounts, Logger, TranslatedString, parsePaytoUri } from "@gnu-taler/taler-util"; +import { Amounts, Logger, TalerError, TranslatedString, parsePaytoUri } from "@gnu-taler/taler-util"; import { notifyError, notifyException, useNotifications, useTranslationContext } from "@gnu-taler/web-util/browser"; import { ComponentChildren, Fragment, VNode, h } from "preact"; import { useEffect, useErrorBoundary, useState } from "preact/hooks"; @@ -27,6 +27,7 @@ import { useAccountDetails } from "../hooks/access.js"; import { useSettings } from "../hooks/settings.js"; import { bankUiSettings } from "../settings.js"; import { RenderAmount } from "./PaytoWireTransferForm.js"; +import { Loading } from "../components/Loading.js"; const GIT_HASH = typeof __GIT_HASH__ !== "undefined" ? __GIT_HASH__ : undefined; const VERSION = typeof __VERSION__ !== "undefined" ? __VERSION__ : undefined; @@ -237,7 +238,6 @@ export function BankFrame({ </span> </span> <button type="button" data-enabled={settings.showDebugInfo} class="bg-indigo-600 data-[enabled=false]:bg-gray-200 relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-indigo-600 focus:ring-offset-2" role="switch" aria-checked="false" aria-labelledby="availability-label" aria-describedby="availability-description" - onClick={() => { updateSettings("showDebugInfo", !settings.showDebugInfo); }}> @@ -346,14 +346,6 @@ function StatusBanner(): VNode { </div> } <MaybeShowDebugInfo info={n.message.debug} /> - {/* <a href="#" class="text-gray-500"> - show debug info - </a> - {n.message.debug && - <div class="mt-2 text-sm text-red-700 font-mono break-all"> - {n.message.debug} - </div> - } */} </Attention> case "info": return <Attention type="success" title={n.message.title} onClose={() => { @@ -411,16 +403,22 @@ function WelcomeAccount({ account }: { account: string }): VNode { const { i18n } = useTranslationContext(); const result = useAccountDetails(account); - if (!result.ok) return <div /> + if (!result) { + return <Loading /> + } + if (result instanceof TalerError) { + return <div /> + } + if (result.type === "fail") return <div /> - const payto = parsePaytoUri(result.data.payto_uri) + const payto = parsePaytoUri(result.body.payto_uri) if (!payto) return <div /> const accountNumber = !payto.isKnown ? undefined : payto.targetType === "iban" ? payto.iban : payto.targetType === "x-taler-bank" ? payto.account : undefined; return <i18n.Translate> Welcome, {account} {accountNumber !== undefined ? <span> - (<a href={result.data.payto_uri}>{accountNumber}</a> <CopyButton getContent={() => result.data.payto_uri} />) + (<a href={result.body.payto_uri}>{accountNumber}</a> <CopyButton getContent={() => result.body.payto_uri} />) </span> : <Fragment />}! </i18n.Translate> @@ -429,10 +427,16 @@ function WelcomeAccount({ account }: { account: string }): VNode { function AccountBalance({ account }: { account: string }): VNode { const result = useAccountDetails(account); - if (!result.ok) return <div /> + if (!result) { + return <Loading /> + } + if (result instanceof TalerError) { + return <div /> + } + if (result.type === "fail") return <div /> return <RenderAmount - value={Amounts.parseOrThrow(result.data.balance.amount)} - negative={result.data.balance.credit_debit_indicator === "debit"} + value={Amounts.parseOrThrow(result.body.balance.amount)} + negative={result.body.balance.credit_debit_indicator === "debit"} /> } diff --git a/packages/demobank-ui/src/pages/HomePage.tsx b/packages/demobank-ui/src/pages/HomePage.tsx index 95144f086..bd85cea1e 100644 --- a/packages/demobank-ui/src/pages/HomePage.tsx +++ b/packages/demobank-ui/src/pages/HomePage.tsx @@ -31,12 +31,11 @@ import { } from "@gnu-taler/web-util/browser"; import { Fragment, VNode, h } from "preact"; import { Loading } from "../components/Loading.js"; -import { getInitialBackendBaseURL } from "../hooks/backend.js"; +import { useBankCoreApiContext } from "../context/config.js"; import { useSettings } from "../hooks/settings.js"; import { AccountPage } from "./AccountPage/index.js"; import { LoginForm } from "./LoginForm.js"; import { WithdrawalQRCode } from "./WithdrawalQRCode.js"; -import { route } from "preact-router"; const logger = new Logger("AccountPage"); @@ -68,7 +67,6 @@ export function HomePage({ account={account} goToConfirmOperation={goToConfirmOperation} goToBusinessAccount={goToBusinessAccount} - onLoadNotOk={handleNotOkResult(i18n)} /> ); } @@ -82,9 +80,9 @@ export function WithdrawalOperationPage({ }): VNode { //FIXME: libeufin sandbox should return show to create the integration api endpoint //or return withdrawal uri from response - const baseUrl = getInitialBackendBaseURL() + const { api } = useBankCoreApiContext() const uri = stringifyWithdrawUri({ - bankIntegrationApiBaseUrl: `${baseUrl}/taler-integration`, + bankIntegrationApiBaseUrl: api.getIntegrationAPI().baseUrl, withdrawalOperationId: operationId, }); const parsedUri = parseWithdrawUri(uri); @@ -110,76 +108,6 @@ export function WithdrawalOperationPage({ ); } -export function handleNotOkResult( - i18n: ReturnType<typeof useTranslationContext>["i18n"], -): <T>( - result: - | HttpResponsePaginated<T, SandboxBackend.SandboxError> - | HttpResponse<T, SandboxBackend.SandboxError>, -) => VNode { - return function handleNotOkResult2<T>( - result: - | HttpResponsePaginated<T, SandboxBackend.SandboxError | undefined> - | HttpResponse<T, SandboxBackend.SandboxError | undefined>, - ): VNode { - if (result.loading) return <Loading />; - if (!result.ok) { - switch (result.type) { - case ErrorType.TIMEOUT: { - notifyError(i18n.str`Request timeout, try again later.`, undefined); - break; - } - case ErrorType.CLIENT: { - if (result.status === HttpStatusCode.Unauthorized) { - notifyError(i18n.str`Wrong credentials`, undefined); - return <LoginForm />; - } - const errorData = result.payload; - notify({ - type: "error", - title: i18n.str`Could not load due to a request error`, - description: i18n.str`Request to url "${result.info.url}" returned ${result.info.status}`, - debug: JSON.stringify(result), - }); - break; - } - case ErrorType.SERVER: { - notify({ - type: "error", - title: i18n.str`Server returned with error`, - description: result.payload?.error?.description as TranslatedString, - debug: JSON.stringify(result.payload), - }); - break; - } - case ErrorType.UNREADABLE: { - notify({ - type: "error", - title: i18n.str`Unexpected error.`, - description: i18n.str`Response from ${result.info?.url} is unreadable, http status: ${result.status}`, - debug: JSON.stringify(result), - }); - break; - } - case ErrorType.UNEXPECTED: { - notify({ - type: "error", - title: i18n.str`Unexpected error.`, - description: i18n.str`Diagnostic from ${result.info?.url} is "${result.message}"`, - debug: JSON.stringify(result), - }); - break; - } - default: { - assertUnreachable(result); - } - } - // route("/") - return <div>error</div>; - } - return <div />; - }; -} export function assertUnreachable(x: never): never { throw new Error("Didn't expect to get here"); } diff --git a/packages/demobank-ui/src/pages/LoginForm.tsx b/packages/demobank-ui/src/pages/LoginForm.tsx index 3ea94b899..a8167cca5 100644 --- a/packages/demobank-ui/src/pages/LoginForm.tsx +++ b/packages/demobank-ui/src/pages/LoginForm.tsx @@ -14,28 +14,29 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ -import { HttpStatusCode, TranslatedString } from "@gnu-taler/taler-util"; -import { ErrorType, notifyError, useTranslationContext } from "@gnu-taler/web-util/browser"; +import { HttpStatusCode, TalerAuthentication, TranslatedString } from "@gnu-taler/taler-util"; +import { ErrorType, notify, notifyError, useTranslationContext } from "@gnu-taler/web-util/browser"; import { Fragment, VNode, h } from "preact"; import { useEffect, useRef, useState } from "preact/hooks"; import { ShowInputErrorLabel } from "../components/ShowInputErrorLabel.js"; import { useBackendContext } from "../context/backend.js"; -import { useCredentialsChecker } from "../hooks/useCredentialsChecker.js"; import { bankUiSettings } from "../settings.js"; import { undefinedIfEmpty } from "../utils.js"; import { doAutoFocus } from "./PaytoWireTransferForm.js"; +import { useBankCoreApiContext } from "../context/config.js"; +import { assertUnreachable } from "./HomePage.js"; /** * Collect and submit login data. */ -export function LoginForm({ onRegister }: { onRegister?: () => void }): VNode { +export function LoginForm({ reason, onRegister }: { reason?: "not-found" | "forbidden", onRegister?: () => void }): VNode { const backend = useBackendContext(); const currentUser = backend.state.status !== "loggedOut" ? backend.state.username : undefined const [username, setUsername] = useState<string | undefined>(currentUser); const [password, setPassword] = useState<string | undefined>(); const { i18n } = useTranslationContext(); - const { requestNewLoginToken, refreshLoginToken } = useCredentialsChecker(); + const { api } = useBankCoreApiContext(); /** @@ -70,10 +71,6 @@ export function LoginForm({ onRegister }: { onRegister?: () => void }): VNode { password: !password ? i18n.str`Missing password` : undefined, }) ?? busy; - function saveError({ title, description, debug }: { title: TranslatedString, description?: TranslatedString, debug?: any }) { - notifyError(title, description, debug) - } - async function doLogout() { backend.logOut() } @@ -81,63 +78,42 @@ export function LoginForm({ onRegister }: { onRegister?: () => void }): VNode { async function doLogin() { if (!username || !password) return; setBusy({}) - const result = await requestNewLoginToken(username, password); - if (result.valid) { - backend.logIn({ username, token: result.token }); + const data: TalerAuthentication.TokenRequest = { + // scope: "readwrite" as "write", //FIX: different than merchant + scope: "readwrite", + duration: { + d_us: "forever" //FIX: should return shortest + // d_us: 60 * 60 * 24 * 7 * 1000 * 1000 + }, + refreshable: true, + } + const resp = await api.getAuthenticationAPI(username).createAccessToken(password, { + // scope: "readwrite" as "write", //FIX: different than merchant + scope: "readwrite", + duration: { + d_us: "forever" //FIX: should return shortest + // d_us: 60 * 60 * 24 * 7 * 1000 * 1000 + }, + refreshable: true, + }) + if (resp.type === "ok") { + backend.logIn({ username, token: resp.body.access_token }); } else { - const { cause } = result; - switch (cause.type) { - case ErrorType.CLIENT: { - if (cause.status === HttpStatusCode.Unauthorized) { - saveError({ - title: i18n.str`Wrong credentials for "${username}"`, - }); - } else - if (cause.status === HttpStatusCode.NotFound) { - saveError({ - title: i18n.str`Account not found`, - }); - } else { - saveError({ - title: i18n.str`Could not load due to a request error`, - description: i18n.str`Request to url "${cause.info.url}" returned ${cause.info.status}`, - debug: JSON.stringify(cause.payload), - }); - } - break; - } - case ErrorType.SERVER: { - saveError({ - title: i18n.str`Server had a problem, try again later or report.`, - // description: cause.payload.error.description, - debug: JSON.stringify(cause.payload), - }); - break; - } - case ErrorType.TIMEOUT: { - saveError({ - title: i18n.str`Request timeout, try again later.`, - }); - break; - } - case ErrorType.UNREADABLE: { - saveError({ - title: i18n.str`Unexpected error.`, - description: `Response from ${cause.info?.url} is unreadable, http status: ${cause.status}` as TranslatedString, - debug: JSON.stringify(cause), - }); - break; - } - default: { - saveError({ - title: i18n.str`Unexpected error, please report.`, - description: `Diagnostic from ${cause.info?.url} is "${cause.message}"` as TranslatedString, - debug: JSON.stringify(cause), - }); - break; - } + switch (resp.case) { + case "wrong-credentials": return notify({ + type: "error", + title: i18n.str`Wrong credentials for "${username}"`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + }) + case "not-found": return notify({ + type: "error", + title: i18n.str`Account not found`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + }) + default: assertUnreachable(resp) } - // backend.logOut(); } setPassword(undefined); setBusy(undefined) diff --git a/packages/demobank-ui/src/pages/OperationState/index.ts b/packages/demobank-ui/src/pages/OperationState/index.ts index b347fd942..bc3555c48 100644 --- a/packages/demobank-ui/src/pages/OperationState/index.ts +++ b/packages/demobank-ui/src/pages/OperationState/index.ts @@ -14,8 +14,8 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ -import { AbsoluteTime, AmountJson, WithdrawUriResult } from "@gnu-taler/taler-util"; -import { HttpError, utils } from "@gnu-taler/web-util/browser"; +import { AbsoluteTime, AmountJson, TalerCoreBankErrorsByMethod, TalerError, TalerErrorDetail, TranslatedString, WithdrawUriResult } from "@gnu-taler/taler-util"; +import { utils } from "@gnu-taler/web-util/browser"; import { ErrorLoading } from "../../components/ErrorLoading.js"; import { Loading } from "../../components/Loading.js"; import { useComponentState } from "./state.js"; @@ -44,7 +44,7 @@ export namespace State { export interface LoadingError { status: "loading-error"; - error: HttpError<SandboxBackend.SandboxError>; + error: TalerError; } /** @@ -61,7 +61,7 @@ export namespace State { export interface InvalidPayto { status: "invalid-payto", error: undefined; - payto: string | null; + payto: string | undefined; onClose: () => void; } export interface InvalidWithdrawal { @@ -74,7 +74,7 @@ export namespace State { status: "invalid-reserve", error: undefined; onClose: () => void; - reserve: string | null; + reserve: string | undefined; } export interface NeedConfirmation { status: "need-confirmation", diff --git a/packages/demobank-ui/src/pages/OperationState/state.ts b/packages/demobank-ui/src/pages/OperationState/state.ts index 4be680377..148571ec9 100644 --- a/packages/demobank-ui/src/pages/OperationState/state.ts +++ b/packages/demobank-ui/src/pages/OperationState/state.ts @@ -14,20 +14,26 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ -import { Amounts, HttpStatusCode, TranslatedString, parsePaytoUri, parseWithdrawUri, stringifyWithdrawUri } from "@gnu-taler/taler-util"; +import { Amounts, HttpStatusCode, TalerError, TranslatedString, parsePaytoUri, parseWithdrawUri, stringifyWithdrawUri } from "@gnu-taler/taler-util"; import { RequestError, notify, notifyError, notifyInfo, useTranslationContext, utils } from "@gnu-taler/web-util/browser"; import { useEffect, useState } from "preact/hooks"; -import { useAccessAPI, useAccessAnonAPI, useWithdrawalDetails } from "../../hooks/access.js"; -import { getInitialBackendBaseURL } from "../../hooks/backend.js"; +import { useBankCoreApiContext } from "../../context/config.js"; +import { useWithdrawalDetails } from "../../hooks/access.js"; +import { useBackendState } from "../../hooks/backend.js"; import { useSettings } from "../../hooks/settings.js"; import { buildRequestErrorMessage } from "../../utils.js"; import { Props, State } from "./index.js"; +import { assertUnreachable } from "../HomePage.js"; +import { mutate } from "swr"; export function useComponentState({ currency, onClose }: Props): utils.RecursiveState<State> { const { i18n } = useTranslationContext(); const [settings, updateSettings] = useSettings() - const { createWithdrawal } = useAccessAPI(); - const { abortWithdrawal, confirmWithdrawal } = useAccessAnonAPI(); + const { state: credentials } = useBackendState() + const creds = credentials.status !== "loggedIn" ? undefined : credentials + const { api } = useBankCoreApiContext() + // const { createWithdrawal } = useAccessAPI(); + // const { abortWithdrawal, confirmWithdrawal } = useAccessAnonAPI(); const [busy, setBusy] = useState<Record<string, undefined>>() const amount = settings.maxWithdrawalAmount @@ -37,27 +43,33 @@ export function useComponentState({ currency, onClose }: Props): utils.Recursive const parsedAmount = Amounts.parseOrThrow(`${currency}:${amount}`) try { - const result = await createWithdrawal({ + if (!creds) return; + const resp = await api.createWithdrawal(creds, { amount: Amounts.stringify(parsedAmount), }); - const uri = parseWithdrawUri(result.data.taler_withdraw_uri); + if (resp.type === "fail") { + switch (resp.case) { + case "insufficient-funds": return notify({ + type: "error", + title: i18n.str`The operation was rejected due to insufficient funds`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + }); + default: assertUnreachable(resp.case) + } + } + + const uri = parseWithdrawUri(resp.body.taler_withdraw_uri); if (!uri) { return notifyError( i18n.str`Server responded with an invalid withdraw URI`, - i18n.str`Withdraw URI: ${result.data.taler_withdraw_uri}`); + i18n.str`Withdraw URI: ${resp.body.taler_withdraw_uri}`); } else { updateSettings("currentWithdrawalOperationId", uri.withdrawalOperationId) } } catch (error) { - if (error instanceof RequestError) { - notify( - buildRequestErrorMessage(i18n, error.cause, { - onClientError: (status) => - status === HttpStatusCode.Forbidden - ? i18n.str`The operation was rejected due to insufficient funds` - : undefined, - }), - ); + if (error instanceof TalerError) { + notify(buildRequestErrorMessage(i18n, error)) } else { notifyError( i18n.str`Operation failed, please report`, @@ -76,8 +88,6 @@ export function useComponentState({ currency, onClose }: Props): utils.Recursive } }, [settings.fastWithdrawal, amount]) - const baseUrl = getInitialBackendBaseURL() - if (!withdrawalOperationId) { return { status: "loading", @@ -90,18 +100,24 @@ export function useComponentState({ currency, onClose }: Props): utils.Recursive async function doAbort() { try { setBusy({}) - await abortWithdrawal(wid); - onClose(); + const resp = await api.abortWithdrawalById(wid); + setBusy(undefined) + if (resp.type === "ok") { + onClose(); + } else { + switch (resp.case) { + case "previously-confirmed": return notify({ + type: "error", + title: i18n.str`The reserve operation has been confirmed previously and can't be aborted`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + }) + default: assertUnreachable(resp.case) + } + } } catch (error) { - if (error instanceof RequestError) { - notify( - buildRequestErrorMessage(i18n, error.cause, { - onClientError: (status) => - status === HttpStatusCode.Conflict - ? i18n.str`The reserve operation has been confirmed previously and can't be aborted` - : undefined, - }), - ); + if (error instanceof TalerError) { + notify(buildRequestErrorMessage(i18n, error)) } else { notifyError( i18n.str`Operation failed, please report`, @@ -111,28 +127,38 @@ export function useComponentState({ currency, onClose }: Props): utils.Recursive ) } } - setBusy(undefined) } async function doConfirm() { try { setBusy({}) - await confirmWithdrawal(wid); - if (!settings.showWithdrawalSuccess) { - notifyInfo(i18n.str`Wire transfer completed!`) + const resp = await api.confirmWithdrawalById(wid); + if (resp.type === "ok") { + mutate(() => true)//clean withdrawal state + if (!settings.showWithdrawalSuccess) { + notifyInfo(i18n.str`Wire transfer completed!`) + } + setBusy(undefined) + } else { + switch (resp.case) { + case "previously-aborted": return notify({ + type: "error", + title: i18n.str`The withdrawal has been aborted previously and can't be confirmed`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + }) + case "no-exchange-or-reserve-selected": return notify({ + type: "error", + title: i18n.str`The withdraw operation cannot be confirmed because no exchange and reserve public key selection happened before`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + }) + default: assertUnreachable(resp) + } } } catch (error) { - if (error instanceof RequestError) { - notify( - buildRequestErrorMessage(i18n, error.cause, { - onClientError: (status) => - status === HttpStatusCode.Conflict - ? i18n.str`The withdrawal has been aborted previously and can't be confirmed` - : status === HttpStatusCode.UnprocessableEntity - ? i18n.str`The withdraw operation cannot be confirmed because no exchange and reserve public key selection happened before` - : undefined, - }), - ); + if (error instanceof TalerError) { + notify(buildRequestErrorMessage(i18n, error)) } else { notifyError( i18n.str`Operation failed, please report`, @@ -142,11 +168,10 @@ export function useComponentState({ currency, onClose }: Props): utils.Recursive ) } } - setBusy(undefined) } - const bankIntegrationApiBaseUrl = `${baseUrl}/taler-integration` + const uri = stringifyWithdrawUri({ - bankIntegrationApiBaseUrl, + bankIntegrationApiBaseUrl: api.getIntegrationAPI().baseUrl, withdrawalOperationId, }); const parsedUri = parseWithdrawUri(uri); @@ -161,32 +186,43 @@ export function useComponentState({ currency, onClose }: Props): utils.Recursive return (): utils.RecursiveState<State> => { const result = useWithdrawalDetails(withdrawalOperationId); - const shouldCreateNewOperation = !result.ok && !result.loading && result.info.status === HttpStatusCode.NotFound + const shouldCreateNewOperation = result && !(result instanceof TalerError) useEffect(() => { if (shouldCreateNewOperation) { doSilentStart() } }, []) - if (!result.ok) { - if (result.loading) { - return { - status: "loading", - error: undefined - } - } - if (result.info.status === HttpStatusCode.NotFound) { - return { - status: "loading", - error: undefined, - } + if (!result) { + return { + status: "loading", + error: undefined } + } + if (result instanceof TalerError) { return { status: "loading-error", error: result } } - const { data } = result; + + if (result.type === "fail") { + switch (result.case) { + case "not-found": { + return { + status: "aborted", + error: undefined, + onClose: async () => { + updateSettings("currentWithdrawalOperationId", undefined) + onClose() + }, + } + } + default: assertUnreachable(result.case) + } + } + + const { body: data } = result; if (data.aborted) { return { status: "aborted", @@ -247,8 +283,6 @@ export function useComponentState({ currency, onClose }: Props): utils.Recursive } } - - // goToConfirmOperation(withdrawalOperationId) return { status: "need-confirmation", error: undefined, diff --git a/packages/demobank-ui/src/pages/PaytoWireTransferForm.tsx b/packages/demobank-ui/src/pages/PaytoWireTransferForm.tsx index 52dbd4ff6..7861bb0b3 100644 --- a/packages/demobank-ui/src/pages/PaytoWireTransferForm.tsx +++ b/packages/demobank-ui/src/pages/PaytoWireTransferForm.tsx @@ -16,9 +16,11 @@ import { AmountJson, + AmountString, Amounts, HttpStatusCode, Logger, + TalerError, TranslatedString, buildPayto, parsePaytoUri, @@ -30,17 +32,18 @@ import { notifyError, useTranslationContext, } from "@gnu-taler/web-util/browser"; -import { h, VNode, Fragment, Ref } from "preact"; +import { Fragment, Ref, VNode, h } from "preact"; import { useEffect, useRef, useState } from "preact/hooks"; import { ShowInputErrorLabel } from "../components/ShowInputErrorLabel.js"; -import { useAccessAPI } from "../hooks/access.js"; import { buildRequestErrorMessage, undefinedIfEmpty, validateIBAN, } from "../utils.js"; -import { useConfigState } from "../hooks/config.js"; -import { useConfigContext } from "../context/config.js"; +import { useBankCoreApiContext } from "../context/config.js"; +import { useBackendState } from "../hooks/backend.js"; +import { assertUnreachable } from "./HomePage.js"; +import { mutate } from "swr"; const logger = new Logger("PaytoWireTransferForm"); @@ -59,6 +62,8 @@ export function PaytoWireTransferForm({ }): VNode { const [isRawPayto, setIsRawPayto] = useState(false); // FIXME: remove this + const { state: credentials } = useBackendState() + const { api } = useBankCoreApiContext(); const [iban, setIban] = useState<string | undefined>(); const [subject, setSubject] = useState<string | undefined>(); const [amount, setAmount] = useState<string | undefined>(); @@ -95,7 +100,7 @@ export function PaytoWireTransferForm({ : undefined, }); - const { createTransaction } = useAccessAPI(); + // const { createTransaction } = useAccessAPI(); const parsed = !rawPaytoInput ? undefined : parsePaytoUri(rawPaytoInput); @@ -119,36 +124,54 @@ export function PaytoWireTransferForm({ async function doSend() { let payto_uri: string | undefined; - + let sendingAmount: AmountString | undefined; if (rawPaytoInput) { - payto_uri = rawPaytoInput + const p = parsePaytoUri(rawPaytoInput) + if (!p) return; + sendingAmount = p.params.amount + delete p.params.amount + //it should have message + payto_uri = stringifyPaytoUri(p) } else { if (!iban || !subject) return; const ibanPayto = buildPayto("iban", iban, undefined); ibanPayto.params.message = encodeURIComponent(subject); payto_uri = stringifyPaytoUri(ibanPayto); + sendingAmount = `${limit.currency}:${trimmedAmountStr}` } try { - await createTransaction({ + if (credentials.status !== "loggedIn") return; + const res = await api.createTransaction(credentials, { payto_uri, - amount: `${limit.currency}:${amount}`, + amount: sendingAmount, }); + mutate(() => true) + if (res.type === "fail") { + switch (res.case) { + case "invalid-input": return notify({ + type: "error", + title: i18n.str`The request was invalid or the payto://-URI used unacceptable features.`, + description: res.detail.hint as TranslatedString, + debug: res.detail, + }) + case "unauthorized": return notify({ + type: "error", + title: i18n.str`Not enough permission to complete the operation.`, + description: res.detail.hint as TranslatedString, + debug: res.detail, + }) + default: assertUnreachable(res) + } + } onSuccess(); setAmount(undefined); setIban(undefined); setSubject(undefined); rawPaytoInputSetter(undefined) } catch (error) { - if (error instanceof RequestError) { - notify( - buildRequestErrorMessage(i18n, error.cause, { - onClientError: (status) => - status === HttpStatusCode.BadRequest - ? i18n.str`The request was invalid or the payto://-URI used unacceptable features.` - : undefined, - }), - ); + if (error instanceof TalerError) { + notify(buildRequestErrorMessage(i18n, error)) } else { notifyError( i18n.str`Operation failed, please report`, @@ -179,11 +202,13 @@ export function PaytoWireTransferForm({ if (amount) { setAmount(Amounts.stringifyValue(amount)) } - const subject = parsed.params["subject"] + const subject = parsed.params["message"] if (subject) { setSubject(subject) } } + //payto://iban/DE9714548806481?amount=LOCAL%3A2&message=011Y8V8KDCPFDEKPDZTHS7KZ14GHX7BVWKRDDPZ1N75TJ90166T0 + //payto://iban/DE9714548806481?receiver-name=Exchanger&amount=LOCAL%3A2&message=011Y8V8KDCPFDEKPDZTHS7KZ14GHX7BVWKRDDPZ1N75TJ90166T0 setIsRawPayto(false) }} /> <span class="flex flex-1"> @@ -298,7 +323,7 @@ export function PaytoWireTransferForm({ /> <ShowInputErrorLabel message={errorsWire?.amount} - isDirty={subject !== undefined} + isDirty={trimmedAmountStr !== undefined} /> <p class="mt-2 text-sm text-gray-500" >amount to transfer</p> </div> @@ -394,7 +419,7 @@ export function InputAmount( }, ref: Ref<HTMLInputElement>, ): VNode { - const cfg = useConfigContext() + const { config } = useBankCoreApiContext() return ( <div class="mt-2"> <div class="flex rounded-md shadow-sm border-0 ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-indigo-600"> @@ -418,8 +443,8 @@ export function InputAmount( if (!onChange) return; const l = e.currentTarget.value.length const sep_pos = e.currentTarget.value.indexOf(FRAC_SEPARATOR) - if (sep_pos !== -1 && l - sep_pos - 1 > cfg.currency_fraction_limit) { - e.currentTarget.value = e.currentTarget.value.substring(0, sep_pos + cfg.currency_fraction_limit + 1) + if (sep_pos !== -1 && l - sep_pos - 1 > config.currency.num_fractional_input_digits) { + e.currentTarget.value = e.currentTarget.value.substring(0, sep_pos + config.currency.num_fractional_input_digits + 1) } onChange(e.currentTarget.value); }} @@ -431,11 +456,11 @@ export function InputAmount( } export function RenderAmount({ value, negative }: { value: AmountJson, negative?: boolean }): VNode { - const cfg = useConfigContext() + const { config } = useBankCoreApiContext() const str = Amounts.stringifyValue(value) const sep_pos = str.indexOf(FRAC_SEPARATOR) - if (sep_pos !== -1 && str.length - sep_pos - 1 > cfg.currency_fraction_digits) { - const limit = sep_pos + cfg.currency_fraction_digits + 1 + if (sep_pos !== -1 && str.length - sep_pos - 1 > config.currency.num_fractional_normal_digits) { + const limit = sep_pos + config.currency.num_fractional_normal_digits + 1 const normal = str.substring(0, limit) const small = str.substring(limit) return <span class="whitespace-nowrap"> diff --git a/packages/demobank-ui/src/pages/PublicHistoriesPage.tsx b/packages/demobank-ui/src/pages/PublicHistoriesPage.tsx index 680368919..d33353180 100644 --- a/packages/demobank-ui/src/pages/PublicHistoriesPage.tsx +++ b/packages/demobank-ui/src/pages/PublicHistoriesPage.tsx @@ -14,35 +14,36 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ -import { Logger } from "@gnu-taler/taler-util"; +import { Logger, TalerError } from "@gnu-taler/taler-util"; import { useTranslationContext } from "@gnu-taler/web-util/browser"; import { Fragment, VNode, h } from "preact"; import { useState } from "preact/hooks"; +import { Loading } from "../components/Loading.js"; import { Transactions } from "../components/Transactions/index.js"; import { usePublicAccounts } from "../hooks/access.js"; -import { handleNotOkResult } from "./HomePage.js"; -import { Loading } from "../components/Loading.js"; const logger = new Logger("PublicHistoriesPage"); -interface Props {} +interface Props { } /** * Show histories of public accounts. */ -export function PublicHistoriesPage({}: Props): VNode { +export function PublicHistoriesPage({ }: Props): VNode { const { i18n } = useTranslationContext(); const result = usePublicAccounts(); + const firstAccount = result && !(result instanceof TalerError) && result.data.public_accounts.length > 0 + ? result.data.public_accounts[0].account_name + : undefined; - const [showAccount, setShowAccount] = useState( - result.ok && result.data.public_accounts.length > 0 - ? result.data.public_accounts[0].account_name - : undefined, - ); + const [showAccount, setShowAccount] = useState(firstAccount); - if (!result.ok) { - return handleNotOkResult(i18n)(result); + if (!result) { + return <Loading /> + } + if (result instanceof TalerError) { + return <Loading /> } const { data } = result; diff --git a/packages/demobank-ui/src/pages/QrCodeSection.tsx b/packages/demobank-ui/src/pages/QrCodeSection.tsx index e07525ab4..109993aae 100644 --- a/packages/demobank-ui/src/pages/QrCodeSection.tsx +++ b/packages/demobank-ui/src/pages/QrCodeSection.tsx @@ -17,6 +17,7 @@ import { HttpStatusCode, stringifyWithdrawUri, + TalerError, TranslatedString, WithdrawUriResult, } from "@gnu-taler/taler-util"; @@ -29,8 +30,9 @@ import { import { Fragment, h, VNode } from "preact"; import { useEffect } from "preact/hooks"; import { QR } from "../components/QR.js"; -import { useAccessAnonAPI } from "../hooks/access.js"; import { buildRequestErrorMessage } from "../utils.js"; +import { useBankCoreApiContext } from "../context/config.js"; +import { assertUnreachable } from "./HomePage.js"; export function QrCodeSection({ withdrawUri, @@ -50,22 +52,30 @@ export function QrCodeSection({ }, []); const talerWithdrawUri = stringifyWithdrawUri(withdrawUri); - const { abortWithdrawal } = useAccessAnonAPI(); + const { api } = useBankCoreApiContext() async function doAbort() { try { - await abortWithdrawal(withdrawUri.withdrawalOperationId); - onAborted(); + const result = await api.abortWithdrawalById(withdrawUri.withdrawalOperationId); + if (result.type === "ok") { + onAborted(); + } else { + switch (result.case) { + case "previously-confirmed": { + notify({ + type: "info", + title: i18n.str`The reserve operation has been confirmed previously and can't be aborted` + }) + break; + } + default: { + assertUnreachable(result.case) + } + } + } } catch (error) { - if (error instanceof RequestError) { - notify( - buildRequestErrorMessage(i18n, error.cause, { - onClientError: (status) => - status === HttpStatusCode.Conflict - ? i18n.str`The reserve operation has been confirmed previously and can't be aborted` - : undefined, - }), - ); + if (error instanceof TalerError) { + notify(buildRequestErrorMessage(i18n, error)) } else { notifyError( i18n.str`Operation failed, please report`, @@ -120,13 +130,13 @@ export function QrCodeSection({ </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"> - <button type="button" - // class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md px-3 py-2 text-sm font-semibold text-black shadow-sm hover:bg-red-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-600" - class="text-sm font-semibold leading-6 text-gray-900" - onClick={doAbort} - > - Cancel - </button> + <button type="button" + // class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md px-3 py-2 text-sm font-semibold text-black shadow-sm hover:bg-red-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-600" + class="text-sm font-semibold leading-6 text-gray-900" + onClick={doAbort} + > + Cancel + </button> </div> </div> diff --git a/packages/demobank-ui/src/pages/RegistrationPage.tsx b/packages/demobank-ui/src/pages/RegistrationPage.tsx index 9ac93bb34..fda2d904d 100644 --- a/packages/demobank-ui/src/pages/RegistrationPage.tsx +++ b/packages/demobank-ui/src/pages/RegistrationPage.tsx @@ -13,7 +13,7 @@ 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 { HttpStatusCode, Logger, TranslatedString } from "@gnu-taler/taler-util"; +import { AccessToken, HttpStatusCode, Logger, TalerError, TranslatedString } from "@gnu-taler/taler-util"; import { RequestError, notify, @@ -23,12 +23,11 @@ import { import { Fragment, VNode, h } from "preact"; import { useState } from "preact/hooks"; import { useBackendContext } from "../context/backend.js"; -import { useTestingAPI } from "../hooks/access.js"; import { bankUiSettings } from "../settings.js"; import { buildRequestErrorMessage, undefinedIfEmpty } from "../utils.js"; import { ShowInputErrorLabel } from "../components/ShowInputErrorLabel.js"; import { getRandomPassword, getRandomUsername } from "./rnd.js"; -import { useCredentialsChecker } from "../hooks/useCredentialsChecker.js"; +import { useBankCoreApiContext } from "../context/config.js"; const logger = new Logger("RegistrationPage"); @@ -63,9 +62,9 @@ function RegistrationForm({ onComplete, onCancel }: { onComplete: () => void, on const [phone, setPhone] = useState<string | undefined>(); const [email, setEmail] = useState<string | undefined>(); const [repeatPassword, setRepeatPassword] = useState<string | undefined>(); - const { requestNewLoginToken } = useCredentialsChecker() - const { register } = useTestingAPI(); + const { api } = useBankCoreApiContext() + // const { register } = useTestingAPI(); const { i18n } = useTranslationContext(); const errors = undefinedIfEmpty({ @@ -95,26 +94,77 @@ function RegistrationForm({ onComplete, onCancel }: { onComplete: () => void, on : undefined, }); + async function doRegistrationAndLogin(name: string | undefined, username: string, password: string) { + const creationResponse = await api.createAccount("" as AccessToken, { name: name ?? "", username, password }); + if (creationResponse.type === "fail") { + switch (creationResponse.case) { + case "invalid-input": return notify({ + type: "error", + title: i18n.str`Some of the input fields are invalid.`, + description: creationResponse.detail.hint as TranslatedString, + debug: creationResponse.detail, + }) + case "unable-to-create": return notify({ + type: "error", + title: i18n.str`Unable to create that account.`, + description: creationResponse.detail.hint as TranslatedString, + debug: creationResponse.detail, + }) + case "unauthorized": return notify({ + type: "error", + title: i18n.str`No enough permission to create that account.`, + description: creationResponse.detail.hint as TranslatedString, + debug: creationResponse.detail, + }) + case "already-exist": return notify({ + type: "error", + title: i18n.str`That username is already taken`, + description: creationResponse.detail.hint as TranslatedString, + debug: creationResponse.detail, + }) + default: assertUnreachable(creationResponse) + } + } + const resp = await api.getAuthenticationAPI(username).createAccessToken(password, { + // scope: "readwrite" as "write", //FIX: different than merchant + scope: "readwrite", + duration: { + d_us: "forever" //FIX: should return shortest + // d_us: 60 * 60 * 24 * 7 * 1000 * 1000 + }, + refreshable: true, + }) + + if (resp.type === "ok") { + backend.logIn({ username, token: resp.body.access_token }); + } else { + switch (resp.case) { + case "wrong-credentials": return notify({ + type: "error", + title: i18n.str`Wrong credentials for "${username}"`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + }) + case "not-found": return notify({ + type: "error", + title: i18n.str`Account not found`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + }) + default: assertUnreachable(resp) + } + } + } + async function doRegistrationStep() { if (!username || !password) return; try { - await register({ name: name ?? "", username, password }); - const resp = await requestNewLoginToken(username, password) + await doRegistrationAndLogin(name, username, password) setUsername(undefined); - if (resp.valid) { - backend.logIn({ username, token: resp.token }); - } onComplete(); } catch (error) { - if (error instanceof RequestError) { - notify( - buildRequestErrorMessage(i18n, error.cause, { - onClientError: (status) => - status === HttpStatusCode.Conflict - ? i18n.str`That username is already taken` - : undefined, - }), - ); + if (error instanceof TalerError) { + notify(buildRequestErrorMessage(i18n, error)) } else { notifyError( i18n.str`Operation failed, please report`, @@ -143,27 +193,11 @@ function RegistrationForm({ onComplete, onCancel }: { onComplete: () => void, on setPassword(undefined); setRepeatPassword(undefined); const username = `_${user.first}-${user.second}_` - await register({ username, name: `${user.first} ${user.second}`, password: pass }); - const resp = await requestNewLoginToken(username, pass) - if (resp.valid) { - backend.logIn({ username, token: resp.token }); - } + await doRegistrationAndLogin(name, username, pass) onComplete(); } catch (error) { - if (error instanceof RequestError) { - if (tries > 0) { - await delay(200) - await doRandomRegistration(tries - 1) - } else { - notify( - buildRequestErrorMessage(i18n, error.cause, { - onClientError: (status) => - status === HttpStatusCode.Conflict - ? i18n.str`Could not create a random user` - : undefined, - }), - ); - } + if (error instanceof TalerError) { + notify(buildRequestErrorMessage(i18n, error)) } else { notifyError( i18n.str`Operation failed, please report`, diff --git a/packages/demobank-ui/src/pages/ShowAccountDetails.tsx b/packages/demobank-ui/src/pages/ShowAccountDetails.tsx index 6acf0361e..3534f9733 100644 --- a/packages/demobank-ui/src/pages/ShowAccountDetails.tsx +++ b/packages/demobank-ui/src/pages/ShowAccountDetails.tsx @@ -1,67 +1,88 @@ -import { ErrorType, HttpResponsePaginated, RequestError, notify, notifyError, useTranslationContext } from "@gnu-taler/web-util/browser"; +import { HttpStatusCode, TalerCorebankApi, TalerError, TranslatedString } from "@gnu-taler/taler-util"; +import { HttpResponsePaginated, RequestError, notify, notifyError, useTranslationContext } from "@gnu-taler/web-util/browser"; import { VNode, h } from "preact"; -import { useAdminAccountAPI, useBusinessAccountDetails } from "../hooks/circuit.js"; import { useState } from "preact/hooks"; -import { HttpStatusCode, TranslatedString } from "@gnu-taler/taler-util"; -import { buildRequestErrorMessage } from "../utils.js"; +import { ErrorLoading } from "../components/ErrorLoading.js"; +import { Loading } from "../components/Loading.js"; +import { useBankCoreApiContext } from "../context/config.js"; +import { useAccountDetails } from "../hooks/access.js"; +import { useBackendState } from "../hooks/backend.js"; +import { buildRequestErrorMessage, undefinedIfEmpty } from "../utils.js"; +import { assertUnreachable } from "./HomePage.js"; +import { LoginForm } from "./LoginForm.js"; import { AccountForm } from "./admin/AccountForm.js"; export function ShowAccountDetails({ account, onClear, onUpdateSuccess, - onLoadNotOk, onChangePassword, }: { - onLoadNotOk: <T>( - error: HttpResponsePaginated<T, SandboxBackend.SandboxError>, - ) => VNode; onClear?: () => void; onChangePassword: () => void; onUpdateSuccess: () => void; account: string; }): VNode { const { i18n } = useTranslationContext(); - const result = useBusinessAccountDetails(account); - const { updateAccount } = useAdminAccountAPI(); + const { state: credentials } = useBackendState(); + const creds = credentials.status !== "loggedIn" ? undefined : credentials + const { api } = useBankCoreApiContext() + const [update, setUpdate] = useState(false); - const [submitAccount, setSubmitAccount] = useState< - SandboxBackend.Circuit.CircuitAccountData | undefined - >(); + const [submitAccount, setSubmitAccount] = useState<TalerCorebankApi.AccountData | undefined>(); - if (!result.ok) { - if (result.loading || result.type === ErrorType.TIMEOUT) { - return onLoadNotOk(result); - } - if (result.status === HttpStatusCode.NotFound) { - return <div>account not found</div>; + const result = useAccountDetails(account); + if (!result) { + return <Loading /> + } + if (result instanceof TalerError) { + return <ErrorLoading error={result} /> + } + if (result.type === "fail") { + switch (result.case) { + case "not-found": return <LoginForm reason="not-found" /> + case "unauthorized": return <LoginForm reason="forbidden" /> + default: assertUnreachable(result) } - return onLoadNotOk(result); } async function doUpdate() { if (!update) { setUpdate(true); } else { - if (!submitAccount) return; + if (!submitAccount || !creds) return; try { - await updateAccount(account, { - cashout_address: submitAccount.cashout_address, - contact_data: submitAccount.contact_data, + const resp = await api.updateAccount(creds, { + cashout_address: submitAccount.cashout_payto_uri, + challenge_contact_data: undefinedIfEmpty({ + email: submitAccount.contact_data?.email, + phone: submitAccount.contact_data?.phone, + }), + is_exchange: false, + name: submitAccount.name, }); - onUpdateSuccess(); + if (resp.type === "ok") { + onUpdateSuccess(); + } else { + switch (resp.case) { + case "unauthorized": return notify({ + type: "error", + title: i18n.str`The rights to change the account are not sufficient`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + }) + case "not-found": return notify({ + type: "error", + title: i18n.str`The username was not found`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + }) + default: assertUnreachable(resp) + } + } } catch (error) { - if (error instanceof RequestError) { - notify( - buildRequestErrorMessage(i18n, error.cause, { - onClientError: (status) => - status === HttpStatusCode.Forbidden - ? i18n.str`The rights to change the account are not sufficient` - : status === HttpStatusCode.NotFound - ? i18n.str`The username was not found` - : undefined, - }), - ); + if (error instanceof TalerError) { + notify(buildRequestErrorMessage(i18n, error)) } else { notifyError( i18n.str`Operation failed, please report`, @@ -86,24 +107,24 @@ export function ShowAccountDetails({ } </h2> <div class="mt-4"> - <div class="flex items-center justify-between"> - <span class="flex flex-grow flex-col"> - <span class="text-sm text-black font-medium leading-6 " id="availability-label"> - <i18n.Translate>change the account details</i18n.Translate> - </span> - </span> - <button type="button" data-enabled={!update} class="bg-indigo-600 data-[enabled=true]:bg-gray-200 relative inline-flex h-5 w-10 flex-shrink-0 cursor-pointer rounded-full ring-2 border-gray-600 transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-indigo-600 focus:ring-offset-2" role="switch" aria-checked="false" aria-labelledby="availability-label" aria-describedby="availability-description" - onClick={() => { - setUpdate(!update) - }}> - <span aria-hidden="true" data-enabled={!update} class="translate-x-5 data-[enabled=true]:translate-x-0 pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out"></span> - </button> - </div> - </div> + <div class="flex items-center justify-between"> + <span class="flex flex-grow flex-col"> + <span class="text-sm text-black font-medium leading-6 " id="availability-label"> + <i18n.Translate>change the account details</i18n.Translate> + </span> + </span> + <button type="button" data-enabled={!update} class="bg-indigo-600 data-[enabled=true]:bg-gray-200 relative inline-flex h-5 w-10 flex-shrink-0 cursor-pointer rounded-full ring-2 border-gray-600 transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-indigo-600 focus:ring-offset-2" role="switch" aria-checked="false" aria-labelledby="availability-label" aria-describedby="availability-description" + onClick={() => { + setUpdate(!update) + }}> + <span aria-hidden="true" data-enabled={!update} class="translate-x-5 data-[enabled=true]:translate-x-0 pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out"></span> + </button> + </div> + </div> </div> <AccountForm - template={result.data} + template={result.body} purpose={update ? "update" : "show"} onChange={(a) => setSubmitAccount(a)} > diff --git a/packages/demobank-ui/src/pages/UpdateAccountPassword.tsx b/packages/demobank-ui/src/pages/UpdateAccountPassword.tsx index 46f4fe0ef..ac6e9fa9b 100644 --- a/packages/demobank-ui/src/pages/UpdateAccountPassword.tsx +++ b/packages/demobank-ui/src/pages/UpdateAccountPassword.tsx @@ -1,43 +1,33 @@ -import { HttpStatusCode, TranslatedString } from "@gnu-taler/taler-util"; -import { ErrorType, HttpResponsePaginated, RequestError, notify, notifyError, useTranslationContext } from "@gnu-taler/web-util/browser"; +import { TalerError, TranslatedString } from "@gnu-taler/taler-util"; +import { HttpResponsePaginated, RequestError, notify, notifyError, useTranslationContext } from "@gnu-taler/web-util/browser"; import { Fragment, VNode, h } from "preact"; -import { useEffect, useRef, useState } from "preact/hooks"; +import { useState } from "preact/hooks"; import { ShowInputErrorLabel } from "../components/ShowInputErrorLabel.js"; -import { useAdminAccountAPI, useBusinessAccountDetails } from "../hooks/circuit.js"; import { buildRequestErrorMessage, undefinedIfEmpty } from "../utils.js"; import { doAutoFocus } from "./PaytoWireTransferForm.js"; +import { useBankCoreApiContext } from "../context/config.js"; +import { assertUnreachable } from "./HomePage.js"; +import { useBackendState } from "../hooks/backend.js"; export function UpdateAccountPassword({ account, onCancel, onUpdateSuccess, - onLoadNotOk, focus, }: { - onLoadNotOk: <T>( - error: HttpResponsePaginated<T, SandboxBackend.SandboxError>, - ) => VNode; onCancel: () => void; focus?: boolean, onUpdateSuccess: () => void; account: string; }): VNode { const { i18n } = useTranslationContext(); - const result = useBusinessAccountDetails(account); - const { changePassword } = useAdminAccountAPI(); + const { state: credentials } = useBackendState(); + const creds = credentials.status !== "loggedIn" ? undefined : credentials + const { api } = useBankCoreApiContext(); + const [password, setPassword] = useState<string | undefined>(); const [repeat, setRepeat] = useState<string | undefined>(); - if (!result.ok) { - if (result.loading || result.type === ErrorType.TIMEOUT) { - return onLoadNotOk(result); - } - if (result.status === HttpStatusCode.NotFound) { - return <div>account not found</div>; - } - return onLoadNotOk(result); - } - const errors = undefinedIfEmpty({ password: !password ? i18n.str`required` : undefined, repeat: !repeat @@ -48,15 +38,35 @@ export function UpdateAccountPassword({ }); async function doChangePassword() { - if (!!errors || !password) return; + if (!!errors || !password || !creds) return; try { - const r = await changePassword(account, { + const resp = await api.updatePassword(creds, { new_password: password, }); - onUpdateSuccess(); + if (resp.type === "ok") { + onUpdateSuccess(); + } else { + switch (resp.case) { + case "unauthorized": { + notify({ + type: "error", + title: i18n.str`Not authorized to change the password, maybe the session is invalid.` + }) + break; + } + case "not-found": { + notify({ + type: "error", + title: i18n.str`Account not found` + }) + break; + } + default: assertUnreachable(resp) + } + } } catch (error) { - if (error instanceof RequestError) { - notify(buildRequestErrorMessage(i18n, error.cause)); + if (error instanceof TalerError) { + notify(buildRequestErrorMessage(i18n, error)) } else { notifyError(i18n.str`Operation failed, please report`, (error instanceof Error ? error.message diff --git a/packages/demobank-ui/src/pages/WalletWithdrawForm.tsx b/packages/demobank-ui/src/pages/WalletWithdrawForm.tsx index da299b1c8..2d80bad1f 100644 --- a/packages/demobank-ui/src/pages/WalletWithdrawForm.tsx +++ b/packages/demobank-ui/src/pages/WalletWithdrawForm.tsx @@ -19,9 +19,9 @@ import { Amounts, HttpStatusCode, Logger, + TalerError, TranslatedString, - WithdrawUriResult, - parseWithdrawUri, + parseWithdrawUri } from "@gnu-taler/taler-util"; import { RequestError, @@ -31,13 +31,15 @@ import { } from "@gnu-taler/web-util/browser"; import { Fragment, VNode, h } from "preact"; import { forwardRef } from "preact/compat"; -import { useEffect, useRef, useState } from "preact/hooks"; -import { useAccessAPI } from "../hooks/access.js"; -import { buildRequestErrorMessage, undefinedIfEmpty } from "../utils.js"; -import { InputAmount, doAutoFocus } from "./PaytoWireTransferForm.js"; +import { useState } from "preact/hooks"; +import { Attention } from "../components/Attention.js"; +import { useBankCoreApiContext } from "../context/config.js"; +import { useBackendState } from "../hooks/backend.js"; import { useSettings } from "../hooks/settings.js"; +import { buildRequestErrorMessage, undefinedIfEmpty } from "../utils.js"; +import { assertUnreachable } from "./HomePage.js"; import { OperationState } from "./OperationState/index.js"; -import { Attention } from "../components/Attention.js"; +import { InputAmount, doAutoFocus } from "./PaytoWireTransferForm.js"; const logger = new Logger("WalletWithdrawForm"); const RefAmount = forwardRef(InputAmount); @@ -52,7 +54,10 @@ function OldWithdrawalForm({ goToConfirmOperation, limit, onCancel, focus }: { const { i18n } = useTranslationContext(); const [settings, updateSettings] = useSettings() - const { createWithdrawal } = useAccessAPI(); + const { state: credentials } = useBackendState(); + const creds = credentials.status !== "loggedIn" ? undefined : credentials + + const { api } = useBankCoreApiContext() const [amountStr, setAmountStr] = useState<string | undefined>(`${settings.maxWithdrawalAmount}`); if (!!settings.currentWithdrawalOperationId) { @@ -81,30 +86,33 @@ function OldWithdrawalForm({ goToConfirmOperation, limit, onCancel, focus }: { }); async function doStart() { - if (!parsedAmount) return; + if (!parsedAmount || !creds) return; try { - const result = await createWithdrawal({ + const result = await api.createWithdrawal(creds, { amount: Amounts.stringify(parsedAmount), }); - const uri = parseWithdrawUri(result.data.taler_withdraw_uri); - if (!uri) { - return notifyError( - i18n.str`Server responded with an invalid withdraw URI`, - i18n.str`Withdraw URI: ${result.data.taler_withdraw_uri}`); + if (result.type === "ok") { + const uri = parseWithdrawUri(result.body.taler_withdraw_uri); + if (!uri) { + return notifyError( + i18n.str`Server responded with an invalid withdraw URI`, + i18n.str`Withdraw URI: ${result.body.taler_withdraw_uri}`); + } else { + updateSettings("currentWithdrawalOperationId", uri.withdrawalOperationId) + goToConfirmOperation(uri.withdrawalOperationId); + } } else { - updateSettings("currentWithdrawalOperationId", uri.withdrawalOperationId) - goToConfirmOperation(uri.withdrawalOperationId); + switch (result.case) { + case "insufficient-funds": { + notify({ type: "error", title: i18n.str`The operation was rejected due to insufficient funds` }) + break; + } + default: assertUnreachable(result.case) + } } } catch (error) { - if (error instanceof RequestError) { - notify( - buildRequestErrorMessage(i18n, error.cause, { - onClientError: (status) => - status === HttpStatusCode.Forbidden - ? i18n.str`The operation was rejected due to insufficient funds` - : undefined, - }), - ); + if (error instanceof TalerError) { + notify(buildRequestErrorMessage(i18n, error)) } else { notifyError( i18n.str`Operation failed, please report`, diff --git a/packages/demobank-ui/src/pages/WithdrawalConfirmationQuestion.tsx b/packages/demobank-ui/src/pages/WithdrawalConfirmationQuestion.tsx index ddcd2492d..602ec9bd8 100644 --- a/packages/demobank-ui/src/pages/WithdrawalConfirmationQuestion.tsx +++ b/packages/demobank-ui/src/pages/WithdrawalConfirmationQuestion.tsx @@ -22,6 +22,7 @@ import { PaytoUri, PaytoUriIBAN, PaytoUriTalerBank, + TalerError, TranslatedString, WithdrawUriResult } from "@gnu-taler/taler-util"; @@ -35,10 +36,12 @@ import { import { Fragment, VNode, h } from "preact"; import { useMemo, useState } from "preact/hooks"; import { ShowInputErrorLabel } from "../components/ShowInputErrorLabel.js"; -import { useAccessAnonAPI } from "../hooks/access.js"; import { buildRequestErrorMessage, undefinedIfEmpty } from "../utils.js"; import { useSettings } from "../hooks/settings.js"; import { RenderAmount } from "./PaytoWireTransferForm.js"; +import { useBankCoreApiContext } from "../context/config.js"; +import { assertUnreachable } from "./HomePage.js"; +import { mutate } from "swr"; const logger = new Logger("WithdrawalConfirmationQuestion"); @@ -70,7 +73,7 @@ export function WithdrawalConfirmationQuestion({ }; }, []); - const { confirmWithdrawal, abortWithdrawal } = useAccessAnonAPI(); + const { api } = useBankCoreApiContext() const [captchaAnswer, setCaptchaAnswer] = useState<string | undefined>(); const answer = parseInt(captchaAnswer ?? "", 10); const [busy, setBusy] = useState<Record<string, undefined>>() @@ -87,24 +90,32 @@ export function WithdrawalConfirmationQuestion({ async function doTransfer() { try { setBusy({}) - await confirmWithdrawal( - withdrawUri.withdrawalOperationId, - ); - if (!settings.showWithdrawalSuccess) { - notifyInfo(i18n.str`Wire transfer completed!`) + const resp = await api.confirmWithdrawalById(withdrawUri.withdrawalOperationId); + if (resp.type === "ok") { + mutate(() => true)// clean any info that we have + if (!settings.showWithdrawalSuccess) { + notifyInfo(i18n.str`Wire transfer completed!`) + } + } else { + switch (resp.case) { + case "previously-aborted": return notify({ + type: "error", + title: i18n.str`The withdrawal has been aborted previously and can't be confirmed`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + }); + case "no-exchange-or-reserve-selected": return notify({ + type: "error", + title: i18n.str`The withdraw operation cannot be confirmed because no exchange and reserve public key selection happened before`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + }); + default: assertUnreachable(resp) + } } } catch (error) { - if (error instanceof RequestError) { - notify( - buildRequestErrorMessage(i18n, error.cause, { - onClientError: (status) => - status === HttpStatusCode.Conflict - ? i18n.str`The withdrawal has been aborted previously and can't be confirmed` - : status === HttpStatusCode.UnprocessableEntity - ? i18n.str`The withdraw operation cannot be confirmed because no exchange and reserve public key selection happened before` - : undefined, - }), - ); + if (error instanceof TalerError) { + notify(buildRequestErrorMessage(i18n, error)) } else { notifyError( i18n.str`Operation failed, please report`, @@ -120,18 +131,26 @@ export function WithdrawalConfirmationQuestion({ async function doCancel() { try { setBusy({}) - await abortWithdrawal(withdrawUri.withdrawalOperationId); - onAborted(); + const resp = await api.abortWithdrawalById(withdrawUri.withdrawalOperationId); + if (resp.type === "ok") { + onAborted(); + } else { + switch (resp.case) { + case "previously-confirmed": { + notify({ + type: "error", + title: i18n.str`The reserve operation has been confirmed previously and can't be aborted` + }); + break; + } + default: { + assertUnreachable(resp.case) + } + } + } } catch (error) { - if (error instanceof RequestError) { - notify( - buildRequestErrorMessage(i18n, error.cause, { - onClientError: (status) => - status === HttpStatusCode.Conflict - ? i18n.str`The reserve operation has been confirmed previously and can't be aborted` - : undefined, - }), - ); + if (error instanceof TalerError) { + notify(buildRequestErrorMessage(i18n, error)) } else { notifyError( i18n.str`Operation failed, please report`, diff --git a/packages/demobank-ui/src/pages/WithdrawalQRCode.tsx b/packages/demobank-ui/src/pages/WithdrawalQRCode.tsx index 35fb94a6c..15910201e 100644 --- a/packages/demobank-ui/src/pages/WithdrawalQRCode.tsx +++ b/packages/demobank-ui/src/pages/WithdrawalQRCode.tsx @@ -16,17 +16,17 @@ import { Amounts, - HttpStatusCode, Logger, + TalerError, WithdrawUriResult, parsePaytoUri } from "@gnu-taler/taler-util"; -import { ErrorType, notifyInfo, useTranslationContext } from "@gnu-taler/web-util/browser"; +import { notifyInfo, useTranslationContext } from "@gnu-taler/web-util/browser"; import { Fragment, VNode, h } from "preact"; +import { ErrorLoading } from "../components/ErrorLoading.js"; import { Loading } from "../components/Loading.js"; import { useWithdrawalDetails } from "../hooks/access.js"; -import { useSettings } from "../hooks/settings.js"; -import { handleNotOkResult } from "./HomePage.js"; +import { assertUnreachable } from "./HomePage.js"; import { QrCodeSection } from "./QrCodeSection.js"; import { WithdrawalConfirmationQuestion } from "./WithdrawalConfirmationQuestion.js"; @@ -48,48 +48,20 @@ export function WithdrawalQRCode({ const { i18n } = useTranslationContext(); const result = useWithdrawalDetails(withdrawUri.withdrawalOperationId); - if (!result.ok) { - if (result.loading) { - return <Loading />; - } - if (result.type === ErrorType.CLIENT && result.status === HttpStatusCode.NotFound) { - 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 operation is not known by the server. The operation id is wrong or the - server deleted the operation information before reaching here. - </i18n.Translate> - </p> - </div> - </div> - </div> - <div class="mt-5 sm:mt-6"> - <button type="button" - 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" - onClick={async (e) => { - e.preventDefault(); - onClose() - }}> - <i18n.Translate>Cotinue to dashboard</i18n.Translate> - </button> - </div> - </div> + if (!result) { + return <Loading /> + } + if (result instanceof TalerError) { + return <ErrorLoading error={result} /> + } + if (result.type === "fail") { + switch (result.case) { + case "not-found": return <OperationNotFound onClose={onClose} /> + default: assertUnreachable(result.case) } - return handleNotOkResult(i18n)(result); } - const { data } = result; + + const { body: data } = result; if (data.aborted) { return <section id="main" class="content"> @@ -194,3 +166,41 @@ export function WithdrawalQRCode({ /> ); } + + +function OperationNotFound({ onClose }: { onClose: () => void }): 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 operation is not known by the server. The operation id is wrong or the + server deleted the operation information before reaching here. + </i18n.Translate> + </p> + </div> + </div> + </div> + <div class="mt-5 sm:mt-6"> + <button type="button" + 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" + onClick={async (e) => { + e.preventDefault(); + onClose() + }}> + <i18n.Translate>Cotinue to dashboard</i18n.Translate> + </button> + </div> + </div> +}
\ No newline at end of file diff --git a/packages/demobank-ui/src/pages/admin/Account.tsx b/packages/demobank-ui/src/pages/admin/Account.tsx index 676fc43d0..bf2fa86f0 100644 --- a/packages/demobank-ui/src/pages/admin/Account.tsx +++ b/packages/demobank-ui/src/pages/admin/Account.tsx @@ -1,10 +1,13 @@ -import { Amounts } from "@gnu-taler/taler-util"; -import { PaytoWireTransferForm } from "../PaytoWireTransferForm.js"; -import { handleNotOkResult } from "../HomePage.js"; -import { useAccountDetails } from "../../hooks/access.js"; -import { useBackendContext } from "../../context/backend.js"; +import { Amounts, TalerError } from "@gnu-taler/taler-util"; import { notifyInfo, useTranslationContext } from "@gnu-taler/web-util/browser"; -import { Fragment, h, VNode } from "preact"; +import { Fragment, VNode, h } from "preact"; +import { ErrorLoading } from "../../components/ErrorLoading.js"; +import { Loading } from "../../components/Loading.js"; +import { useBackendContext } from "../../context/backend.js"; +import { useAccountDetails } from "../../hooks/access.js"; +import { assertUnreachable } from "../HomePage.js"; +import { LoginForm } from "../LoginForm.js"; +import { PaytoWireTransferForm } from "../PaytoWireTransferForm.js"; export function AdminAccount({ onRegister }: { onRegister: () => void }): VNode { const { i18n } = useTranslationContext(); @@ -12,15 +15,25 @@ export function AdminAccount({ onRegister }: { onRegister: () => void }): VNode const account = r.state.status !== "loggedOut" ? r.state.username : "admin"; const result = useAccountDetails(account); - if (!result.ok) { - return handleNotOkResult(i18n)(result); + if (!result) { + return <Loading /> + } + if (result instanceof TalerError) { + return <ErrorLoading error={result} /> } - const { data } = result; + if (result.type === "fail") { + switch (result.case) { + case "unauthorized": return <LoginForm reason="forbidden" onRegister={onRegister} /> + case "not-found": return <LoginForm reason="not-found" onRegister={onRegister} /> + default: assertUnreachable(result) + } + } + const { body: data } = result; const balance = Amounts.parseOrThrow(data.balance.amount); - const balanceIsDebit = result.data.balance.credit_debit_indicator == "debit"; - - const debitThreshold = Amounts.parseOrThrow(result.data.debit_threshold); + const balanceIsDebit = data.balance.credit_debit_indicator == "debit"; + + const debitThreshold = Amounts.parseOrThrow(data.debit_threshold); const limit = balanceIsDebit ? Amounts.sub(debitThreshold, balance).amount : Amounts.add(balance, debitThreshold).amount; diff --git a/packages/demobank-ui/src/pages/admin/AccountForm.tsx b/packages/demobank-ui/src/pages/admin/AccountForm.tsx index ed8bf610d..8470930bf 100644 --- a/packages/demobank-ui/src/pages/admin/AccountForm.tsx +++ b/packages/demobank-ui/src/pages/admin/AccountForm.tsx @@ -3,7 +3,7 @@ import { ShowInputErrorLabel } from "../../components/ShowInputErrorLabel.js"; import { PartialButDefined, RecursivePartial, WithIntermediate, undefinedIfEmpty, validateIBAN } from "../../utils.js"; import { useEffect, useRef, useState } from "preact/hooks"; import { useTranslationContext } from "@gnu-taler/web-util/browser"; -import { buildPayto, parsePaytoUri } from "@gnu-taler/taler-util"; +import { TalerCorebankApi, buildPayto, parsePaytoUri } from "@gnu-taler/taler-util"; import { doAutoFocus } from "../PaytoWireTransferForm.js"; const IBAN_REGEX = /^[A-Z][A-Z0-9]*$/; @@ -28,8 +28,8 @@ export function AccountForm({ }: { focus?: boolean, children: ComponentChildren, - template: SandboxBackend.Circuit.CircuitAccountData | undefined; - onChange: (a: SandboxBackend.Circuit.CircuitAccountData | undefined) => void; + template: TalerCorebankApi.AccountData | undefined; + onChange: (a: TalerCorebankApi.AccountData | undefined) => void; purpose: "create" | "update" | "show"; }): VNode { const initial = initializeFromTemplate(template); @@ -41,12 +41,12 @@ export function AccountForm({ function updateForm(newForm: typeof initial): void { - const parsed = !newForm.cashout_address + const parsed = !newForm.cashout_payto_uri ? undefined - : buildPayto("iban", newForm.cashout_address, undefined);; + : buildPayto("iban", newForm.cashout_payto_uri, undefined);; const errors = undefinedIfEmpty<RecursivePartial<typeof initial>>({ - cashout_address: !newForm.cashout_address + cashout_payto_uri: !newForm.cashout_payto_uri ? i18n.str`required` : !parsed ? i18n.str`does not follow the pattern` @@ -75,7 +75,8 @@ export function AccountForm({ // ? i18n.str`IBAN should have just uppercased letters and numbers` // : validateIBAN(newForm.iban, i18n), name: !newForm.name ? i18n.str`required` : undefined, - username: !newForm.username ? i18n.str`required` : undefined, + + // username: !newForm.username ? i18n.str`required` : undefined, }); setErrors(errors); setForm(newForm); @@ -94,7 +95,7 @@ export function AccountForm({ <div class="px-4 py-6 sm:p-8"> <div class="grid max-w-2xl grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6"> - <div class="sm:col-span-5"> + {/* <div class="sm:col-span-5"> <label class="block text-sm font-medium leading-6 text-gray-900" for="username" @@ -127,7 +128,7 @@ export function AccountForm({ <p class="mt-2 text-sm text-gray-500" > <i18n.Translate>account identification in the bank</i18n.Translate> </p> - </div> + </div> */} <div class="sm:col-span-5"> <label @@ -178,7 +179,7 @@ export function AccountForm({ name="internal-iban" id="internal-iban" disabled={true} - value={form.iban ?? ""} + value={form.payto_uri ?? ""} /> </div> <p class="mt-2 text-sm text-gray-500" > @@ -200,18 +201,20 @@ export function AccountForm({ class="block w-full disabled:bg-gray-100 rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 data-[error=true]:ring-red-500 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" name="email" id="email" - data-error={!!errors?.contact_data?.email && form.contact_data.email !== undefined} + data-error={!!errors?.contact_data?.email && form.contact_data?.email !== undefined} disabled={purpose !== "create"} - value={form.contact_data.email ?? ""} + value={form.contact_data?.email ?? ""} onChange={(e) => { - form.contact_data.email = e.currentTarget.value; - updateForm(structuredClone(form)); + if (form.contact_data) { + form.contact_data.email = e.currentTarget.value; + updateForm(structuredClone(form)); + } }} autocomplete="off" /> <ShowInputErrorLabel message={errors?.contact_data?.email} - isDirty={form.contact_data.email !== undefined} + isDirty={form.contact_data?.email !== undefined} /> </div> </div> @@ -231,18 +234,20 @@ export function AccountForm({ name="phone" id="phone" disabled={purpose !== "create"} - value={form.contact_data.phone ?? ""} - data-error={!!errors?.contact_data?.phone && form.contact_data.phone !== undefined} + value={form.contact_data?.phone ?? ""} + data-error={!!errors?.contact_data?.phone && form.contact_data?.phone !== undefined} onChange={(e) => { - form.contact_data.phone = e.currentTarget.value; - updateForm(structuredClone(form)); + if (form.contact_data) { + form.contact_data.phone = e.currentTarget.value; + updateForm(structuredClone(form)); + } }} // placeholder="" autocomplete="off" /> <ShowInputErrorLabel message={errors?.contact_data?.phone} - isDirty={form.contact_data.phone !== undefined} + isDirty={form.contact_data?.phone !== undefined} /> </div> </div> @@ -259,21 +264,21 @@ export function AccountForm({ <div class="mt-2"> <input type="text" - data-error={!!errors?.cashout_address && form.cashout_address !== undefined} + data-error={!!errors?.cashout_payto_uri && form.cashout_payto_uri !== undefined} class="block w-full disabled:bg-gray-100 rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 data-[error=true]:ring-red-500 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" name="cashout" id="cashout" disabled={purpose === "show"} - value={form.cashout_address ?? ""} + value={form.cashout_payto_uri ?? ""} onChange={(e) => { - form.cashout_address = e.currentTarget.value; + form.cashout_payto_uri = e.currentTarget.value; updateForm(structuredClone(form)); }} autocomplete="off" /> <ShowInputErrorLabel - message={errors?.cashout_address} - isDirty={form.cashout_address !== undefined} + message={errors?.cashout_payto_uri} + isDirty={form.cashout_payto_uri !== undefined} /> </div> <p class="mt-2 text-sm text-gray-500" > @@ -289,26 +294,27 @@ export function AccountForm({ } function initializeFromTemplate( - account: SandboxBackend.Circuit.CircuitAccountData | undefined, -): WithIntermediate<SandboxBackend.Circuit.CircuitAccountData> { + account: TalerCorebankApi.AccountData | undefined, +): WithIntermediate<TalerCorebankApi.AccountData> { const emptyAccount = { - cashout_address: undefined, - iban: undefined, - name: undefined, - username: undefined, + cashout_payto_uri: undefined, contact_data: undefined, + payto_uri: undefined, + balance: undefined, + debit_threshold: undefined, + name: undefined, }; const emptyContact = { email: undefined, phone: undefined, }; - const initial: PartialButDefined<SandboxBackend.Circuit.CircuitAccountData> = + const initial: PartialButDefined<TalerCorebankApi.AccountData> = structuredClone(account) ?? emptyAccount; if (typeof initial.contact_data === "undefined") { initial.contact_data = emptyContact; } - initial.contact_data.email; + // initial.contact_data.email; return initial as any; } diff --git a/packages/demobank-ui/src/pages/admin/AccountList.tsx b/packages/demobank-ui/src/pages/admin/AccountList.tsx index a6899e679..8a1e8294a 100644 --- a/packages/demobank-ui/src/pages/admin/AccountList.tsx +++ b/packages/demobank-ui/src/pages/admin/AccountList.tsx @@ -1,10 +1,12 @@ -import { h, VNode } from "preact"; -import { useBusinessAccounts } from "../../hooks/circuit.js"; -import { handleNotOkResult } from "../HomePage.js"; -import { AccountAction } from "./Home.js"; -import { Amounts } from "@gnu-taler/taler-util"; +import { Amounts, TalerError } from "@gnu-taler/taler-util"; import { useTranslationContext } from "@gnu-taler/web-util/browser"; +import { VNode, h } from "preact"; +import { ErrorLoading } from "../../components/ErrorLoading.js"; +import { Loading } from "../../components/Loading.js"; +import { useBusinessAccounts } from "../../hooks/circuit.js"; +import { assertUnreachable } from "../HomePage.js"; import { RenderAmount } from "../PaytoWireTransferForm.js"; +import { AccountAction } from "./Home.js"; interface Props { onAction: (type: AccountAction, account: string) => void; @@ -13,15 +15,23 @@ interface Props { } export function AccountList({ account, onAction, onCreateAccount }: Props): VNode { - const result = useBusinessAccounts({ account }); + const result = useBusinessAccounts(); const { i18n } = useTranslationContext(); - if (result.loading) return <div />; - if (!result.ok) { - return handleNotOkResult(i18n)(result); + if (!result) { + return <Loading /> + } + if (result instanceof TalerError) { + return <ErrorLoading error={result} /> + } + if (result.data.type === "fail") { + switch (result.data.case) { + case "unauthorized": return <div>un auth</div> + default: assertUnreachable(result.data.case) + } } - const { customers } = result.data; + const { accounts } = result.data.body; return <div class="px-4 sm:px-6 lg:px-8"> <div class="sm:flex sm:items-center"> <div class="sm:flex-auto"> @@ -45,7 +55,7 @@ export function AccountList({ account, onAction, onCreateAccount }: Props): VNod <div class="mt-8 flow-root"> <div class="-mx-4 -my-2 overflow-x-auto sm:-mx-6 lg:-mx-8"> <div class="inline-block min-w-full py-2 align-middle sm:px-6 lg:px-8"> - {!customers.length ? ( + {!accounts.length ? ( <div></div> ) : ( <table class="min-w-full divide-y divide-gray-300"> @@ -60,7 +70,7 @@ export function AccountList({ account, onAction, onCreateAccount }: Props): VNod </tr> </thead> <tbody class="divide-y divide-gray-200"> - {customers.map((item, idx) => { + {accounts.map((item, idx) => { const balance = !item.balance ? undefined : Amounts.parse(item.balance.amount); diff --git a/packages/demobank-ui/src/pages/admin/CreateNewAccount.tsx b/packages/demobank-ui/src/pages/admin/CreateNewAccount.tsx index 2146fc6f0..f6176e772 100644 --- a/packages/demobank-ui/src/pages/admin/CreateNewAccount.tsx +++ b/packages/demobank-ui/src/pages/admin/CreateNewAccount.tsx @@ -1,11 +1,14 @@ +import { HttpStatusCode, TalerCorebankApi, TalerError, TranslatedString } from "@gnu-taler/taler-util"; import { RequestError, notify, notifyError, useTranslationContext } from "@gnu-taler/web-util/browser"; -import { VNode, h, Fragment } from "preact"; -import { useAdminAccountAPI } from "../../hooks/circuit.js"; +import { Fragment, VNode, h } from "preact"; import { useState } from "preact/hooks"; import { buildRequestErrorMessage } from "../../utils.js"; -import { HttpStatusCode, TranslatedString } from "@gnu-taler/taler-util"; import { getRandomPassword } from "../rnd.js"; import { AccountForm } from "./AccountForm.js"; +import { useBackendState } from "../../hooks/backend.js"; +import { useBankCoreApiContext } from "../../context/config.js"; +import { assertUnreachable } from "../HomePage.js"; +import { mutate } from "swr"; export function CreateNewAccount({ onCancel, @@ -15,40 +18,63 @@ export function CreateNewAccount({ onCreateSuccess: (password: string) => void; }): VNode { const { i18n } = useTranslationContext(); - const { createAccount } = useAdminAccountAPI(); + // const { createAccount } = useAdminAccountAPI(); + const { state: credentials } = useBackendState() + const token = credentials.status !== "loggedIn" ? undefined : credentials.token + const { api } = useBankCoreApiContext(); + const [submitAccount, setSubmitAccount] = useState< - SandboxBackend.Circuit.CircuitAccountData | undefined + TalerCorebankApi.AccountData | undefined >(); async function doCreate() { - if (!submitAccount) return; + if (!submitAccount || !token) return; try { - const account: SandboxBackend.Circuit.CircuitAccountRequest = - { - cashout_address: submitAccount.cashout_address, - contact_data: submitAccount.contact_data, - internal_iban: submitAccount.iban, + const account: TalerCorebankApi.RegisterAccountRequest = { + cashout_payto_uri: submitAccount.cashout_payto_uri, + challenge_contact_data: submitAccount.contact_data, + internal_payto_uri: submitAccount.payto_uri, name: submitAccount.name, - username: submitAccount.username, + username: "",//FIXME: not in account data password: getRandomPassword(), }; - await createAccount(account); - onCreateSuccess(account.password); + const resp = await api.createAccount(token, account); + if (resp.type === "ok") { + mutate(() => true)// clean account list + onCreateSuccess(account.password); + } else { + switch (resp.case) { + case "invalid-input": return notify({ + type: "error", + title: i18n.str`Server replied that input data was invalid`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + }) + case "unable-to-create": return notify({ + type: "error", + title: i18n.str`The account name is registered.`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + }) + case "unauthorized": return notify({ + type: "error", + title: i18n.str`The rights to perform the operation are not sufficient`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + }) + case "already-exist": return notify({ + type: "error", + title: i18n.str`Account name is already taken`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + }) + default: assertUnreachable(resp) + } + } } catch (error) { - if (error instanceof RequestError) { - notify( - buildRequestErrorMessage(i18n, error.cause, { - onClientError: (status) => - status === HttpStatusCode.Forbidden - ? i18n.str`The rights to perform the operation are not sufficient` - : status === HttpStatusCode.BadRequest - ? i18n.str`Server replied that input data was invalid` - : status === HttpStatusCode.Conflict - ? i18n.str`At least one registration detail was not available` - : undefined, - }), - ); + if (error instanceof TalerError) { + notify(buildRequestErrorMessage(i18n, error)) } else { notifyError( i18n.str`Operation failed, please report`, diff --git a/packages/demobank-ui/src/pages/admin/Home.tsx b/packages/demobank-ui/src/pages/admin/Home.tsx index d50ff14b4..71ea8ce1b 100644 --- a/packages/demobank-ui/src/pages/admin/Home.tsx +++ b/packages/demobank-ui/src/pages/admin/Home.tsx @@ -2,15 +2,14 @@ import { notifyInfo, useTranslationContext } from "@gnu-taler/web-util/browser"; import { Fragment, VNode, h } from "preact"; import { useState } from "preact/hooks"; import { Cashouts } from "../../components/Cashouts/index.js"; -import { ShowCashoutDetails } from "../business/Home.js"; -import { handleNotOkResult } from "../HomePage.js"; +import { Transactions } from "../../components/Transactions/index.js"; import { ShowAccountDetails } from "../ShowAccountDetails.js"; import { UpdateAccountPassword } from "../UpdateAccountPassword.js"; +import { ShowCashoutDetails } from "../business/Home.js"; import { AdminAccount } from "./Account.js"; import { AccountList } from "./AccountList.js"; import { CreateNewAccount } from "./CreateNewAccount.js"; import { RemoveAccount } from "./RemoveAccount.js"; -import { Transactions } from "../../components/Transactions/index.js"; /** * Query account information and show QR code if there is pending withdrawal @@ -38,7 +37,6 @@ export function AdminHome({ onRegister }: Props): VNode { switch (action.type) { case "show-cashouts-details": return <ShowCashoutDetails id={action.account} - onLoadNotOk={handleNotOkResult(i18n)} onCancel={() => { setAction(undefined); }} @@ -74,7 +72,6 @@ export function AdminHome({ onRegister }: Props): VNode { ) case "update-password": return <UpdateAccountPassword account={action.account} - onLoadNotOk={handleNotOkResult(i18n)} onUpdateSuccess={() => { notifyInfo(i18n.str`Password changed`); setAction(undefined); @@ -85,7 +82,6 @@ export function AdminHome({ onRegister }: Props): VNode { /> case "remove-account": return <RemoveAccount account={action.account} - onLoadNotOk={handleNotOkResult(i18n)} onUpdateSuccess={() => { notifyInfo(i18n.str`Account removed`); setAction(undefined); @@ -96,7 +92,6 @@ export function AdminHome({ onRegister }: Props): VNode { /> case "show-details": return <ShowAccountDetails account={action.account} - onLoadNotOk={handleNotOkResult(i18n)} onChangePassword={() => { setAction({ type: "update-password", @@ -137,12 +132,12 @@ export function AdminHome({ onRegister }: Props): VNode { }} account={undefined} onAction={(type, account) => setAction({ account, type })} - + /> <AdminAccount onRegister={onRegister} /> - <Transactions account="admin"/> + <Transactions account="admin" /> </Fragment> ); }
\ No newline at end of file diff --git a/packages/demobank-ui/src/pages/admin/RemoveAccount.tsx b/packages/demobank-ui/src/pages/admin/RemoveAccount.tsx index b323b0d01..ce8a53ca1 100644 --- a/packages/demobank-ui/src/pages/admin/RemoveAccount.tsx +++ b/packages/demobank-ui/src/pages/admin/RemoveAccount.tsx @@ -1,24 +1,25 @@ -import { ErrorType, HttpResponsePaginated, RequestError, notify, notifyError, useTranslationContext } from "@gnu-taler/web-util/browser"; -import { VNode, h, Fragment } from "preact"; +import { Amounts, HttpStatusCode, TalerError, TranslatedString } from "@gnu-taler/taler-util"; +import { HttpResponsePaginated, RequestError, notify, notifyError, useTranslationContext } from "@gnu-taler/web-util/browser"; +import { Fragment, VNode, h } from "preact"; +import { useState } from "preact/hooks"; +import { Attention } from "../../components/Attention.js"; +import { ErrorLoading } from "../../components/ErrorLoading.js"; +import { Loading } from "../../components/Loading.js"; +import { ShowInputErrorLabel } from "../../components/ShowInputErrorLabel.js"; import { useAccountDetails } from "../../hooks/access.js"; -import { useAdminAccountAPI } from "../../hooks/circuit.js"; -import { Amounts, HttpStatusCode, TranslatedString } from "@gnu-taler/taler-util"; import { buildRequestErrorMessage, undefinedIfEmpty } from "../../utils.js"; -import { useEffect, useRef, useState } from "preact/hooks"; -import { ShowInputErrorLabel } from "../../components/ShowInputErrorLabel.js"; -import { Attention } from "../../components/Attention.js"; +import { assertUnreachable } from "../HomePage.js"; +import { LoginForm } from "../LoginForm.js"; import { doAutoFocus } from "../PaytoWireTransferForm.js"; +import { useBankCoreApiContext } from "../../context/config.js"; +import { useBackendState } from "../../hooks/backend.js"; export function RemoveAccount({ account, onCancel, onUpdateSuccess, - onLoadNotOk, focus, }: { - onLoadNotOk: <T>( - error: HttpResponsePaginated<T, SandboxBackend.SandboxError>, - ) => VNode; focus?: boolean; onCancel: () => void; onUpdateSuccess: () => void; @@ -27,18 +28,26 @@ export function RemoveAccount({ const { i18n } = useTranslationContext(); const result = useAccountDetails(account); const [accountName, setAccountName] = useState<string | undefined>() - const { deleteAccount } = useAdminAccountAPI(); - if (!result.ok) { - if (result.loading || result.type === ErrorType.TIMEOUT) { - return onLoadNotOk(result); - } - if (result.status === HttpStatusCode.NotFound) { - return <div>account not found</div>; + const { state } = useBackendState(); + const token = state.status !== "loggedIn" ? undefined : state.token + const { api } = useBankCoreApiContext() + + if (!result) { + return <Loading /> + } + if (result instanceof TalerError) { + return <ErrorLoading error={result} /> + } + if (result.type === "fail") { + switch (result.case) { + case "unauthorized": return <LoginForm reason="forbidden" /> + case "not-found": return <LoginForm reason="not-found" /> + default: assertUnreachable(result) } - return onLoadNotOk(result); } - const balance = Amounts.parse(result.data.balance.amount); + + const balance = Amounts.parse(result.body.balance.amount); if (!balance) { return <div>there was an error reading the balance</div>; } @@ -50,23 +59,45 @@ export function RemoveAccount({ } async function doRemove() { + if (!token) return; try { - const r = await deleteAccount(account); - onUpdateSuccess(); + const resp = await api.deleteAccount({ username: account, token }); + if (resp.type === "ok") { + onUpdateSuccess(); + } else { + switch (resp.case) { + case "unauthorized": return notify({ + type: "error", + title: i18n.str`No enough permission to delete the account.`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + }) + case "not-found": return notify({ + type: "error", + title: i18n.str`The username was not found.`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + }) + case "unable-to-delete": return notify({ + type: "error", + title: i18n.str`The administrator specified a institutional username.`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + }) + case "balance-not-zero": return notify({ + type: "error", + title: i18n.str`Can't delete an account with balance different than zero.`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + }) + default: { + assertUnreachable(resp) + } + } + } } catch (error) { - if (error instanceof RequestError) { - notify( - buildRequestErrorMessage(i18n, error.cause, { - onClientError: (status) => - status === HttpStatusCode.Forbidden - ? i18n.str`The administrator specified a institutional username` - : status === HttpStatusCode.NotFound - ? i18n.str`The username was not found` - : status === HttpStatusCode.PreconditionFailed - ? i18n.str`Balance was not zero` - : undefined, - }), - ); + if (error instanceof TalerError) { + notify(buildRequestErrorMessage(i18n, error)) } else { notifyError(i18n.str`Operation failed, please report`, (error instanceof Error diff --git a/packages/demobank-ui/src/pages/business/Home.tsx b/packages/demobank-ui/src/pages/business/Home.tsx index 1a84effcd..03d7895e3 100644 --- a/packages/demobank-ui/src/pages/business/Home.tsx +++ b/packages/demobank-ui/src/pages/business/Home.tsx @@ -16,26 +16,28 @@ import { AmountJson, Amounts, - HttpStatusCode, + TalerError, TranslatedString } from "@gnu-taler/taler-util"; import { - HttpResponse, - HttpResponsePaginated, - RequestError, notify, notifyError, notifyInfo, - useTranslationContext, + useTranslationContext } from "@gnu-taler/web-util/browser"; +import { format } from "date-fns"; import { Fragment, VNode, h } from "preact"; import { useEffect, useState } from "preact/hooks"; +import { mutate } from "swr"; import { Cashouts } from "../../components/Cashouts/index.js"; +import { ErrorLoading } from "../../components/ErrorLoading.js"; +import { Loading } from "../../components/Loading.js"; import { ShowInputErrorLabel } from "../../components/ShowInputErrorLabel.js"; +import { useBankCoreApiContext } from "../../context/config.js"; import { useAccountDetails } from "../../hooks/access.js"; +import { useBackendState } from "../../hooks/backend.js"; import { useCashoutDetails, - useCircuitAccountAPI, useEstimator, useRatiosAndFeeConfig, } from "../../hooks/circuit.js"; @@ -44,7 +46,7 @@ import { buildRequestErrorMessage, undefinedIfEmpty, } from "../../utils.js"; -import { handleNotOkResult } from "../HomePage.js"; +import { LoginForm } from "../LoginForm.js"; import { InputAmount } from "../PaytoWireTransferForm.js"; import { ShowAccountDetails } from "../ShowAccountDetails.js"; import { UpdateAccountPassword } from "../UpdateAccountPassword.js"; @@ -53,12 +55,10 @@ interface Props { account: string, onClose: () => void; onRegister: () => void; - onLoadNotOk: () => void; } export function BusinessAccount({ onClose, account, - onLoadNotOk, onRegister, }: Props): VNode { const { i18n } = useTranslationContext(); @@ -68,12 +68,10 @@ export function BusinessAccount({ string | undefined >(); - if (newCashout) { return ( <CreateCashout account={account} - onLoadNotOk={handleNotOkResult(i18n)} onCancel={() => { setNewcashout(false); }} @@ -91,7 +89,6 @@ export function BusinessAccount({ return ( <ShowCashoutDetails id={showCashoutDetails} - onLoadNotOk={handleNotOkResult(i18n)} onCancel={() => { setShowCashoutDetails(undefined); }} @@ -102,7 +99,6 @@ export function BusinessAccount({ return ( <UpdateAccountPassword account={account} - onLoadNotOk={handleNotOkResult(i18n)} onUpdateSuccess={() => { notifyInfo(i18n.str`Password changed`); setUpdatePassword(false); @@ -117,7 +113,6 @@ export function BusinessAccount({ <div> <ShowAccountDetails account={account} - onLoadNotOk={handleNotOkResult(i18n)} onUpdateSuccess={() => { notifyInfo(i18n.str`Account updated`); }} @@ -158,11 +153,6 @@ interface PropsCashout { account: string; onComplete: (id: string) => void; onCancel: () => void; - onLoadNotOk: <T>( - error: - | HttpResponsePaginated<T, SandboxBackend.SandboxError> - | HttpResponse<T, SandboxBackend.SandboxError>, - ) => VNode; } type FormType = { @@ -175,88 +165,78 @@ type ErrorFrom<T> = { [P in keyof T]+?: string; }; -// check #7719 -function useRatiosAndFeeConfigWithChangeDetection(): HttpResponse< - SandboxBackend.Circuit.Config & { hasChanged?: boolean }, - SandboxBackend.SandboxError -> { - const result = useRatiosAndFeeConfig(); - const [oldResult, setOldResult] = useState< - SandboxBackend.Circuit.Config | undefined - >(undefined); - const dataFromBackend = result.ok ? result.data : undefined; - useEffect(() => { - // save only the first result of /config to the backend - if (!dataFromBackend || oldResult !== undefined) return; - setOldResult(dataFromBackend); - }, [dataFromBackend]); - - if (!result.ok) return result; - - const data = !oldResult ? result.data : oldResult; - const hasChanged = - oldResult && - (result.data.name !== oldResult.name || - result.data.version !== oldResult.version || - result.data.ratios_and_fees.buy_at_ratio !== - oldResult.ratios_and_fees.buy_at_ratio || - result.data.ratios_and_fees.buy_in_fee !== - oldResult.ratios_and_fees.buy_in_fee || - result.data.ratios_and_fees.sell_at_ratio !== - oldResult.ratios_and_fees.sell_at_ratio || - result.data.ratios_and_fees.sell_out_fee !== - oldResult.ratios_and_fees.sell_out_fee || - result.data.fiat_currency !== oldResult.fiat_currency); - - return { - ...result, - data: { ...data, hasChanged }, - }; -} function CreateCashout({ - account, + account: accountName, onComplete, onCancel, - onLoadNotOk, }: PropsCashout): VNode { const { i18n } = useTranslationContext(); - const ratiosResult = useRatiosAndFeeConfig(); - const result = useAccountDetails(account); + const resultRatios = useRatiosAndFeeConfig(); + const resultAccount = useAccountDetails(accountName); const { estimateByCredit: calculateFromCredit, estimateByDebit: calculateFromDebit, } = useEstimator(); + const { state } = useBackendState() + const creds = state.status !== "loggedIn" ? undefined : state + const { api, config } = useBankCoreApiContext() const [form, setForm] = useState<Partial<FormType>>({ isDebit: true }); - const { createCashout } = useCircuitAccountAPI(); - if (!result.ok) return onLoadNotOk(result); - if (!ratiosResult.ok) return onLoadNotOk(ratiosResult); - const config = ratiosResult.data; + if (!resultAccount || !resultRatios) { + return <Loading /> + } + if (resultAccount instanceof TalerError) { + return <ErrorLoading error={resultAccount} /> + } + if (resultRatios instanceof TalerError) { + return <ErrorLoading error={resultRatios} /> + } + if (resultAccount.type === "fail") { + switch (resultAccount.case) { + case "unauthorized": return <LoginForm reason="forbidden" /> + case "not-found": return <LoginForm reason="not-found" /> + default: assertUnreachable(resultAccount) + } + } + + if (resultRatios.type === "fail") { + switch (resultRatios.case) { + case "not-supported": return <div>cashout operations are not supported</div> + default: assertUnreachable(resultRatios.case) + } + } + if (!config.fiat_currency) { + return <div>cashout operations are not supported</div> + } - const balance = Amounts.parseOrThrow(result.data.balance.amount); - const balanceIsDebit = result.data.balance.credit_debit_indicator == "debit"; + const ratio = resultRatios.body - const debitThreshold = Amounts.parseOrThrow(result.data.debit_threshold); - const zero = Amounts.zeroOfCurrency(balance.currency); - const limit = balanceIsDebit - ? Amounts.sub(debitThreshold, balance).amount - : Amounts.add(balance, debitThreshold).amount; + const account = { + balance: Amounts.parseOrThrow(resultAccount.body.balance.amount), + balanceIsDebit: resultAccount.body.balance.credit_debit_indicator == "debit", + debitThreshold: Amounts.parseOrThrow(resultAccount.body.debit_threshold) + } + + const zero = Amounts.zeroOfCurrency(account.balance.currency); + const limit = account.balanceIsDebit + ? Amounts.sub(account.debitThreshold, account.balance).amount + : Amounts.add(account.balance, account.debitThreshold).amount; const zeroCalc = { debit: zero, credit: zero, beforeFee: zero }; const [calc, setCalc] = useState(zeroCalc); - const sellRate = config.ratios_and_fees.sell_at_ratio; - const sellFee = !config.ratios_and_fees.sell_out_fee + + const sellRate = ratio.sell_at_ratio; + const sellFee = !ratio.sell_out_fee ? zero : Amounts.parseOrThrow( - `${balance.currency}:${config.ratios_and_fees.sell_out_fee}`, + `${account.balance.currency}:${ratio.sell_out_fee}`, ); - const fiatCurrency = config.fiat_currency; if (!sellRate || sellRate < 0) return <div>error rate</div>; const amount = Amounts.parseOrThrow( - `${!form.isDebit ? fiatCurrency : balance.currency}:${!form.amount ? "0" : form.amount + `${!form.isDebit ? config.fiat_currency.name : account.balance.currency}:${!form.amount ? "0" : form.amount }`, ); @@ -267,15 +247,16 @@ function CreateCashout({ setCalc(r); }) .catch((error) => { - notify( - error instanceof RequestError - ? buildRequestErrorMessage(i18n, error.cause) - : { - type: "error", - title: i18n.str`Could not estimate the cashout`, - description: error.message as TranslatedString - }, - ); + if (error instanceof TalerError) { + notify(buildRequestErrorMessage(i18n, error)) + } else { + notifyError( + i18n.str`Operation failed, please report`, + (error instanceof Error + ? error.message + : JSON.stringify(error)) as TranslatedString + ) + } }); } else { calculateFromCredit(amount, sellFee, sellRate) @@ -283,20 +264,21 @@ function CreateCashout({ setCalc(r); }) .catch((error) => { - notify( - error instanceof RequestError - ? buildRequestErrorMessage(i18n, error.cause) - : { - type: "error", - title: i18n.str`Could not estimate the cashout`, - description: error.message, - }, - ); + if (error instanceof TalerError) { + notify(buildRequestErrorMessage(i18n, error)) + } else { + notifyError( + i18n.str`Operation failed, please report`, + (error instanceof Error + ? error.message + : JSON.stringify(error)) as TranslatedString + ) + } }); } }, [form.amount, form.isDebit]); - const balanceAfter = Amounts.sub(balance, calc.debit).amount; + const balanceAfter = Amounts.sub(account.balance, calc.debit).amount; function updateForm(newForm: typeof form): void { setForm(newForm); @@ -374,8 +356,8 @@ function CreateCashout({ <label for="balance-now">{i18n.str`Balance now`}</label> <InputAmount name="banace-now" - currency={balance.currency} - value={Amounts.stringifyValue(balance)} + currency={account.balance.currency} + value={Amounts.stringifyValue(account.balance)} /> </fieldset> <fieldset> @@ -384,7 +366,7 @@ function CreateCashout({ >{i18n.str`Total cost`}</label> <InputAmount name="total-cost" - currency={balance.currency} + currency={account.balance.currency} value={Amounts.stringifyValue(calc.debit)} /> </fieldset> @@ -392,7 +374,7 @@ function CreateCashout({ <label for="balance-after">{i18n.str`Balance after`}</label> <InputAmount name="balance-after" - currency={balance.currency} + currency={account.balance.currency} value={balanceAfter ? Amounts.stringifyValue(balanceAfter) : ""} /> </fieldset>{" "} @@ -402,7 +384,7 @@ function CreateCashout({ <label for="amount-conversiojn">{i18n.str`Amount after conversion`}</label> <InputAmount name="amount-conversion" - currency={fiatCurrency} + currency={config.fiat_currency.name} value={Amounts.stringifyValue(calc.beforeFee)} /> </fieldset> @@ -411,7 +393,7 @@ function CreateCashout({ <label form="cashout-fee">{i18n.str`Cashout fee`}</label> <InputAmount name="cashout-fee" - currency={fiatCurrency} + currency={config.fiat_currency.name} value={Amounts.stringifyValue(sellFee)} /> </fieldset> @@ -423,7 +405,7 @@ function CreateCashout({ >{i18n.str`Total cashout transfer`}</label> <InputAmount name="total" - currency={fiatCurrency} + currency={config.fiat_currency.name} value={Amounts.stringifyValue(calc.credit)} /> </fieldset> @@ -501,35 +483,55 @@ function CreateCashout({ onClick={async (e) => { e.preventDefault(); - if (errors) return; + if (errors || !creds) return; try { - const res = await createCashout({ + const resp = await api.createCashout(creds, { amount_credit: Amounts.stringify(calc.credit), amount_debit: Amounts.stringify(calc.debit), subject: form.subject, tan_channel: form.channel, }); - onComplete(res.data.uuid); + if (resp.type === "ok") { + mutate(() => true)// clean cashout list + onComplete(resp.body.cashout_id); + } else { + switch (resp.case) { + case "incorrect-exchange-rate": return notify({ + type: "error", + title: i18n.str`The exchange rate was incorrectly applied`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + }); + case "no-allowed": return notify({ + type: "error", + title: i18n.str`This user is not allowed to make a cashout`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + }); + case "no-contact-info": return notify({ + type: "error", + title: i18n.str`Need a contact data where to send the TAN`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + }); + case "no-enough-balance": return notify({ + type: "error", + title: i18n.str`The account does not have sufficient funds`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + }); + case "tan-not-supported": return notify({ + type: "error", + title: i18n.str`The bank does not support the TAN channel for this operation`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + }); + default: assertUnreachable(resp) + } + } } catch (error) { - if (error instanceof RequestError) { - notify( - buildRequestErrorMessage(i18n, error.cause, { - onClientError: (status) => - status === HttpStatusCode.BadRequest - ? i18n.str`The exchange rate was incorrectly applied` - : status === HttpStatusCode.Forbidden - ? i18n.str`A institutional user tried the operation` - : status === HttpStatusCode.Conflict - ? i18n.str`Need a contact data where to send the TAN` - : status === HttpStatusCode.PreconditionFailed - ? i18n.str`The account does not have sufficient funds` - : undefined, - onServerError: (status) => - status === HttpStatusCode.ServiceUnavailable - ? i18n.str`The bank does not support the TAN channel for this operation` - : undefined, - }), - ); + if (error instanceof TalerError) { + notify(buildRequestErrorMessage(i18n, error)) } else { notifyError( i18n.str`Operation failed, please report`, @@ -552,24 +554,34 @@ function CreateCashout({ interface ShowCashoutProps { id: string; onCancel: () => void; - onLoadNotOk: <T>( - error: HttpResponsePaginated<T, SandboxBackend.SandboxError>, - ) => VNode; } export function ShowCashoutDetails({ id, onCancel, - onLoadNotOk, }: ShowCashoutProps): VNode { const { i18n } = useTranslationContext(); + const { state } = useBackendState(); + const creds = state.status !== "loggedIn" ? undefined : state + const { api } = useBankCoreApiContext() const result = useCashoutDetails(id); - const { abortCashout, confirmCashout } = useCircuitAccountAPI(); const [code, setCode] = useState<string | undefined>(undefined); - if (!result.ok) return onLoadNotOk(result); + + if (!result) { + return <Loading /> + } + if (result instanceof TalerError) { + return <ErrorLoading error={result} /> + } + if (result.type === "fail") { + switch (result.case) { + case "already-aborted": return <div>this cashout is already aborted</div> + default: assertUnreachable(result.case) + } + } const errors = undefinedIfEmpty({ code: !code ? i18n.str`required` : undefined, }); - const isPending = String(result.data.status).toUpperCase() === "PENDING"; + const isPending = String(result.body.status).toUpperCase() === "PENDING"; return ( <div> <h1>Cashout details {id}</h1> @@ -578,43 +590,47 @@ export function ShowCashoutDetails({ <label> <i18n.Translate>Subject</i18n.Translate> </label> - <input readOnly value={result.data.subject} /> + <input readOnly value={result.body.subject} /> </fieldset> <fieldset> <label> <i18n.Translate>Created</i18n.Translate> </label> - <input readOnly value={result.data.creation_time ?? ""} /> + <input readOnly value={result.body.creation_time.t_s === "never" ? i18n.str`never` : format(result.body.creation_time.t_s, "dd/MM/yyyy HH:mm:ss")} /> </fieldset> <fieldset> <label> <i18n.Translate>Confirmed</i18n.Translate> </label> - <input readOnly value={result.data.confirmation_time ?? ""} /> + <input readOnly value={result.body.confirmation_time === undefined ? "-" : + (result.body.confirmation_time.t_s === "never" ? + i18n.str`never` : + format(result.body.confirmation_time.t_s, "dd/MM/yyyy HH:mm:ss")) + } /> </fieldset> <fieldset> <label> <i18n.Translate>Debited</i18n.Translate> </label> - <input readOnly value={result.data.amount_debit} /> + <input readOnly value={result.body.amount_debit} /> </fieldset> <fieldset> <label> <i18n.Translate>Credit</i18n.Translate> </label> - <input readOnly value={result.data.amount_credit} /> + <input readOnly value={result.body.amount_credit} /> </fieldset> <fieldset> <label> <i18n.Translate>Status</i18n.Translate> </label> - <input readOnly value={result.data.status} /> + <input readOnly value={result.body.status} /> </fieldset> <fieldset> <label> <i18n.Translate>Destination</i18n.Translate> </label> - <input readOnly value={result.data.cashout_address} /> + <input readOnly value={result.body.credit_payto_uri} /> </fieldset> {isPending ? ( <fieldset> @@ -652,21 +668,33 @@ export function ShowCashoutDetails({ class="pure-button pure-button-primary button-error" onClick={async (e) => { e.preventDefault(); + if (!creds) return; try { - await abortCashout(id); - onCancel(); + const resp = await api.abortCashoutById(creds, id); + if (resp.type === "ok") { + onCancel(); + } else { + switch (resp.case) { + case "not-found": return notify({ + type: "error", + title: i18n.str`Cashout not found. It may be also mean that it was already aborted.`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + }) + case "already-confirmed": return notify({ + type: "error", + title: i18n.str`Cashout was already confimed.`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + }) + default: { + assertUnreachable(resp) + } + } + } } catch (error) { - if (error instanceof RequestError) { - notify( - buildRequestErrorMessage(i18n, error.cause, { - onClientError: (status) => - status === HttpStatusCode.NotFound - ? i18n.str`Cashout not found. It may be also mean that it was already aborted.` - : status === HttpStatusCode.PreconditionFailed - ? i18n.str`Cashout was already confimed` - : undefined, - }), - ); + if (error instanceof TalerError) { + notify(buildRequestErrorMessage(i18n, error)) } else { notifyError( i18n.str`Operation failed, please report`, @@ -687,27 +715,40 @@ export function ShowCashoutDetails({ class="pure-button pure-button-primary " onClick={async (e) => { e.preventDefault(); + if (!creds) return; try { if (!code) return; - const rest = await confirmCashout(id, { + const resp = await api.confirmCashoutById(creds, id, { tan: code, }); + if (resp.type === "ok") { + mutate(() => true)//clean cashout state + } else { + switch (resp.case) { + case "not-found": return notify({ + type: "error", + title: i18n.str`Cashout not found. It may be also mean that it was already aborted.`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + }) + case "wrong-tan-or-credential": return notify({ + type: "error", + title: i18n.str`Invalid code or credentials.`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + }) + case "cashout-address-changed": return notify({ + type: "error", + title: i18n.str`The cash-out address between the creation and the confirmation changed.`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + }) + default: assertUnreachable(resp) + } + } } catch (error) { - if (error instanceof RequestError) { - notify( - buildRequestErrorMessage(i18n, error.cause, { - onClientError: (status) => - status === HttpStatusCode.NotFound - ? i18n.str`Cashout not found. It may be also mean that it was already aborted.` - : status === HttpStatusCode.PreconditionFailed - ? i18n.str`Cashout was already confimed` - : status === HttpStatusCode.Conflict - ? i18n.str`Confirmation failed. Maybe the user changed their cash-out address between the creation and the confirmation` - : status === HttpStatusCode.Forbidden - ? i18n.str`Invalid code` - : undefined, - }), - ); + if (error instanceof TalerError) { + notify(buildRequestErrorMessage(i18n, error)) } else { notifyError( i18n.str`Operation failed, please report`, diff --git a/packages/demobank-ui/src/stories.test.ts b/packages/demobank-ui/src/stories.test.ts index 07db7d8cf..265304b25 100644 --- a/packages/demobank-ui/src/stories.test.ts +++ b/packages/demobank-ui/src/stories.test.ts @@ -18,7 +18,7 @@ * * @author Sebastian Javier Marchano (sebasjm) */ -import { setupI18n } from "@gnu-taler/taler-util"; +import { AccessToken, setupI18n } from "@gnu-taler/taler-util"; import { parseGroupImport } from "@gnu-taler/web-util/browser"; import * as tests from "@gnu-taler/web-util/testing"; import * as components from "./components/index.examples.js"; @@ -26,7 +26,6 @@ import * as pages from "./pages/index.stories.js"; import { ComponentChildren, VNode, h as create } from "preact"; import { BackendStateProviderTesting } from "./context/backend.js"; -import { AccessToken } from "./hooks/useCredentialsChecker.js"; setupI18n("en", { en: {} }); diff --git a/packages/demobank-ui/src/utils.ts b/packages/demobank-ui/src/utils.ts index e7673f078..310e80cd6 100644 --- a/packages/demobank-ui/src/utils.ts +++ b/packages/demobank-ui/src/utils.ts @@ -14,7 +14,7 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ -import { HttpStatusCode, TranslatedString } from "@gnu-taler/taler-util"; +import { HttpStatusCode, TalerError, TalerErrorCode, TranslatedString } from "@gnu-taler/taler-util"; import { ErrorNotification, ErrorType, @@ -62,15 +62,15 @@ export type PartialButDefined<T> = { export type WithIntermediate<Type extends object> = { [prop in keyof Type]: Type[prop] extends object - ? WithIntermediate<Type[prop]> - : Type[prop] | undefined; + ? WithIntermediate<Type[prop]> + : Type[prop] | undefined; }; export type RecursivePartial<T> = { [P in keyof T]?: T[P] extends (infer U)[] - ? RecursivePartial<U>[] - : T[P] extends object - ? RecursivePartial<T[P]> - : T[P]; + ? RecursivePartial<U>[] + : T[P] extends object + ? RecursivePartial<T[P]> + : T[P]; }; export enum TanChannel { @@ -94,59 +94,61 @@ export const MAX_RESULT_SIZE = PAGE_SIZE * 2 - 1; export function buildRequestErrorMessage( i18n: ReturnType<typeof useTranslationContext>["i18n"], - cause: HttpError<SandboxBackend.SandboxError>, - specialCases: { - onClientError?: (status: HttpStatusCode) => TranslatedString | undefined; - onServerError?: (status: HttpStatusCode) => TranslatedString | undefined; - } = {}, + cause: TalerError<{}>, ): ErrorNotification { let result: ErrorNotification; - switch (cause.type) { - case ErrorType.TIMEOUT: { + switch (cause.errorDetail.code) { + case TalerErrorCode.WALLET_HTTP_REQUEST_GENERIC_TIMEOUT: { result = { type: "error", title: i18n.str`Request timeout`, + description: cause.message as TranslatedString, + debug: JSON.stringify(cause.errorDetail, undefined, 2), }; break; } - case ErrorType.CLIENT: { - const title = - specialCases.onClientError && specialCases.onClientError(cause.status); + case TalerErrorCode.WALLET_HTTP_REQUEST_THROTTLED: { result = { type: "error", - title: title ? title : i18n.str`The server didn't accept the request`, - description: cause?.payload?.error?.description as TranslatedString, - debug: JSON.stringify(cause), + title: i18n.str`Request throttled`, + description: cause.message as TranslatedString, + debug: JSON.stringify(cause.errorDetail, undefined, 2), }; break; } - case ErrorType.SERVER: { - const title = - specialCases.onServerError && specialCases.onServerError(cause.status); + case TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE: { result = { type: "error", - title: title - ? title - : i18n.str`The server had problems processing the request`, - description: cause?.payload?.error?.description as TranslatedString, - debug: JSON.stringify(cause), + title: i18n.str`Malformed response`, + description: cause.message as TranslatedString, + debug: JSON.stringify(cause.errorDetail, undefined, 2), }; break; } - case ErrorType.UNREADABLE: { + case TalerErrorCode.WALLET_NETWORK_ERROR: { result = { type: "error", - title: i18n.str`Unexpected error`, - description: `Response from ${cause?.info?.url} is unreadable, status: ${cause?.status}` as TranslatedString, - debug: JSON.stringify(cause), + title: i18n.str`Network error`, + description: cause.message as TranslatedString, + debug: JSON.stringify(cause.errorDetail, undefined, 2), + }; + break; + } + case TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR: { + result = { + type: "error", + title: i18n.str`Unexpected request error`, + description: cause.message as TranslatedString, + debug: JSON.stringify(cause.errorDetail, undefined, 2), }; break; } - case ErrorType.UNEXPECTED: { + default: { result = { type: "error", title: i18n.str`Unexpected error`, - debug: JSON.stringify(cause), + description: cause.message as TranslatedString, + debug: JSON.stringify(cause.errorDetail, undefined, 2), }; break; } |