diff options
Diffstat (limited to 'packages/bank-ui/src/pages')
43 files changed, 15050 insertions, 0 deletions
diff --git a/packages/bank-ui/src/pages/AccountPage/index.ts b/packages/bank-ui/src/pages/AccountPage/index.ts new file mode 100644 index 000000000..8a9471ef4 --- /dev/null +++ b/packages/bank-ui/src/pages/AccountPage/index.ts @@ -0,0 +1,135 @@ +/* + This file is part of GNU Taler + (C) 2022-2024 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +import { + AbsoluteTime, + AmountJson, + TalerCorebankApi, + TalerError, +} from "@gnu-taler/taler-util"; +import { Loading, utils } from "@gnu-taler/web-util/browser"; +import { ErrorLoadingWithDebug } from "../../components/ErrorLoadingWithDebug.js"; +import { LoginForm } from "../LoginForm.js"; +import { useComponentState } from "./state.js"; +import { InvalidIbanView, ReadyView } from "./views.js"; +import { RouteDefinition } from "@gnu-taler/web-util/browser"; + +export interface Props { + account: string; + onAuthorizationRequired: () => void; + onOperationCreated: (wopid: string) => void; + onClose: () => void; + tab: "charge-wallet" | "wire-transfer" | undefined; + routeClose: RouteDefinition; + routeCashout: RouteDefinition; + routeChargeWallet: RouteDefinition; + routeWireTransfer: RouteDefinition<{ + account?: string; + subject?: string; + amount?: string; + }>; + routePublicAccounts: RouteDefinition; + routeCreateWireTransfer: RouteDefinition<{ + account?: string; + subject?: string; + amount?: string; + }>; + routeOperationDetails: RouteDefinition<{ wopid: string }>; + routeSolveSecondFactor: RouteDefinition; +} + +export type State = + | State.Loading + | State.LoadingError + | State.Ready + | State.InvalidIban + | State.UserNotFound; + +export namespace State { + export interface Loading { + status: "loading"; + error: undefined; + } + + export interface LoadingError { + status: "loading-error"; + error: TalerError; + } + + export interface BaseInfo { + error: undefined; + } + + export interface Ready extends BaseInfo { + status: "ready"; + error: undefined; + account: string; + tab: "charge-wallet" | "wire-transfer" | undefined; + limit: AmountJson; + balance: AmountJson; + onAuthorizationRequired: () => void; + onOperationCreated: (wopid: string) => void; + onClose: () => void; + routeClose: RouteDefinition; + routeCashout: RouteDefinition; + routeChargeWallet: RouteDefinition; + routePublicAccounts: RouteDefinition; + routeWireTransfer: RouteDefinition<{ + account?: string; + subject?: string; + amount?: string; + }>; + routeCreateWireTransfer: RouteDefinition<{ + account?: string; + subject?: string; + amount?: string; + }>; + routeOperationDetails: RouteDefinition<{ wopid: string }>; + routeSolveSecondFactor: RouteDefinition; + } + + export interface InvalidIban { + status: "invalid-iban"; + error: TalerCorebankApi.AccountData; + } + + export interface UserNotFound { + status: "login"; + reason: "not-found" | "forbidden"; + routeRegister?: RouteDefinition; + } +} + +export interface Transaction { + negative: boolean; + counterpart: string; + when: AbsoluteTime; + amount: AmountJson | undefined; + subject: string; +} + +const viewMapping: utils.StateViewMap<State> = { + loading: Loading, + login: LoginForm, + "invalid-iban": InvalidIbanView, + "loading-error": ErrorLoadingWithDebug, + ready: ReadyView, +}; + +export const AccountPage = utils.compose( + (p: Props) => useComponentState(p), + viewMapping, +); diff --git a/packages/bank-ui/src/pages/AccountPage/state.ts b/packages/bank-ui/src/pages/AccountPage/state.ts new file mode 100644 index 000000000..f8b91a2ce --- /dev/null +++ b/packages/bank-ui/src/pages/AccountPage/state.ts @@ -0,0 +1,122 @@ +/* + This file is part of GNU Taler + (C) 2022-2024 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +import { + Amounts, + HttpStatusCode, + TalerError, + assertUnreachable, + parsePaytoUri, +} from "@gnu-taler/taler-util"; +import { useAccountDetails } from "../../hooks/account.js"; +import { Props, State } from "./index.js"; + +export function useComponentState({ + account, + tab, + routeChargeWallet, + routeCreateWireTransfer, + routePublicAccounts, + routeSolveSecondFactor, + routeOperationDetails, + routeWireTransfer, + routeCashout, + onOperationCreated, + onClose, + routeClose, + onAuthorizationRequired, +}: Props): State { + const result = useAccountDetails(account); + + if (!result) { + return { + status: "loading", + error: undefined, + }; + } + + if (result instanceof TalerError) { + return { + status: "loading-error", + error: result, + }; + } + + if (result.type === "fail") { + switch (result.case) { + case HttpStatusCode.Unauthorized: + return { + status: "login", + reason: "forbidden", + }; + case HttpStatusCode.NotFound: + return { + status: "login", + reason: "not-found", + }; + default: { + assertUnreachable(result); + } + } + } + + const { body: data } = result; + + const balance = Amounts.parseOrThrow(data.balance.amount); + + const debitThreshold = Amounts.parseOrThrow(data.debit_threshold); + const payto = parsePaytoUri(data.payto_uri); + + if ( + !payto || + !payto.isKnown || + (payto.targetType !== "iban" && payto.targetType !== "x-taler-bank") + ) { + return { + status: "invalid-iban", + error: data, + }; + } + + const balanceIsDebit = data.balance.credit_debit_indicator == "debit"; + const limit = balanceIsDebit + ? Amounts.sub(debitThreshold, balance).amount + : Amounts.add(balance, debitThreshold).amount; + + const positiveBalance = balanceIsDebit + ? Amounts.zeroOfAmount(balance) + : balance; + + return { + status: "ready", + onOperationCreated, + error: undefined, + tab, + routeCashout, + routeOperationDetails, + routeCreateWireTransfer, + routePublicAccounts, + routeSolveSecondFactor, + onAuthorizationRequired, + onClose, + routeClose, + routeChargeWallet, + routeWireTransfer, + account, + limit, + balance: positiveBalance, + }; +} diff --git a/packages/bank-ui/src/pages/AccountPage/stories.tsx b/packages/bank-ui/src/pages/AccountPage/stories.tsx new file mode 100644 index 000000000..fe09a4f89 --- /dev/null +++ b/packages/bank-ui/src/pages/AccountPage/stories.tsx @@ -0,0 +1,29 @@ +/* + This file is part of GNU Taler + (C) 2022-2024 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ + +import * as tests from "@gnu-taler/web-util/testing"; +import { ReadyView } from "./views.js"; + +export default { + title: "account page", +}; + +export const Ready = tests.createExample(ReadyView, {}); diff --git a/packages/bank-ui/src/pages/AccountPage/test.ts b/packages/bank-ui/src/pages/AccountPage/test.ts new file mode 100644 index 000000000..14c8be948 --- /dev/null +++ b/packages/bank-ui/src/pages/AccountPage/test.ts @@ -0,0 +1,31 @@ +/* + This file is part of GNU Taler + (C) 2022-2024 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ + +// import * as tests from "@gnu-taler/web-util/testing"; +// import { SwrMockEnvironment } from "@gnu-taler/web-util/testing"; +// import { expect } from "chai"; +// import { CASHOUT_API_EXAMPLE } from "../../endpoints.js"; +// import { Props } from "./index.js"; +// import { useComponentState } from "./state.js"; + +describe("Account states", () => { + it("should do some tests", async () => {}); +}); diff --git a/packages/bank-ui/src/pages/AccountPage/views.tsx b/packages/bank-ui/src/pages/AccountPage/views.tsx new file mode 100644 index 000000000..42892f536 --- /dev/null +++ b/packages/bank-ui/src/pages/AccountPage/views.tsx @@ -0,0 +1,156 @@ +/* + This file is part of GNU Taler + (C) 2022-2024 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +import { TranslatedString } from "@gnu-taler/taler-util"; +import { Attention, useTranslationContext } from "@gnu-taler/web-util/browser"; +import { Fragment, VNode, h } from "preact"; +import { Transactions } from "../../components/Transactions/index.js"; +import { useBankState } from "../../hooks/bank-state.js"; +import { usePreferences } from "../../hooks/preferences.js"; +import { PaymentOptions } from "../PaymentOptions.js"; +import { State } from "./index.js"; +import { RouteDefinition } from "@gnu-taler/web-util/browser"; + +export function InvalidIbanView({ error }: State.InvalidIban) { + return ( + <div>Payto from server is not valid "{error.payto_uri}"</div> + ); +} + +const IS_PUBLIC_ACCOUNT_ENABLED = false; + +function ShowDemoInfo({ + routePublicAccounts, +}: { + routePublicAccounts: RouteDefinition; +}): VNode { + const { i18n } = useTranslationContext(); + const [settings, updateSettings] = usePreferences(); + if (!settings.showDemoDescription) return <Fragment />; + return ( + <Attention + title={i18n.str`This is a demo bank`} + onClose={() => { + updateSettings("showDemoDescription", false); + }} + > + {IS_PUBLIC_ACCOUNT_ENABLED ? ( + <i18n.Translate> + This part of the demo shows how a bank that supports Taler directly + would work. In addition to using your own bank account, you can also + see the transaction history of some{" "} + <a name="public account" href={routePublicAccounts.url({})}> + Public Accounts + </a> + . + </i18n.Translate> + ) : ( + <i18n.Translate> + This part of the demo shows how a bank that supports Taler directly + would work. + </i18n.Translate> + )} + </Attention> + ); +} + +function ShowPedingOperation({ + routeSolveSecondFactor, +}: { + routeSolveSecondFactor: RouteDefinition; +}): VNode { + const { i18n } = useTranslationContext(); + const [bankState, updateBankState] = useBankState(); + if (!bankState.currentChallenge) return <Fragment />; + const title = ((op): TranslatedString => { + switch (op) { + case "delete-account": + return i18n.str`Pending account delete operation`; + case "update-account": + return i18n.str`Pending account update operation`; + case "update-password": + return i18n.str`Pending password update operation`; + case "create-transaction": + return i18n.str`Pending transaction operation`; + case "confirm-withdrawal": + return i18n.str`Pending withdrawal operation`; + case "create-cashout": + return i18n.str`Pending cashout operation`; + } + })(bankState.currentChallenge.operation); + return ( + <Attention + title={title} + type="warning" + onClose={() => { + updateBankState("currentChallenge", undefined); + }} + > + <i18n.Translate> + You can complete or cancel the operation in + </i18n.Translate>{" "} + <a + class="font-semibold text-yellow-700 hover:text-yellow-600" + name="complete operation" + href={routeSolveSecondFactor.url({})} + > + <i18n.Translate>this page</i18n.Translate> + </a> + </Attention> + ); +} + +export function ReadyView({ + tab, + account, + routeChargeWallet, + routeWireTransfer, + limit, + balance, + routeCashout, + routeCreateWireTransfer, + routePublicAccounts, + routeOperationDetails, + routeSolveSecondFactor, + onClose, + routeClose, + onOperationCreated, + onAuthorizationRequired, +}: State.Ready): VNode { + return ( + <Fragment> + <ShowPedingOperation routeSolveSecondFactor={routeSolveSecondFactor} /> + <ShowDemoInfo routePublicAccounts={routePublicAccounts} /> + <PaymentOptions + tab={tab} + routeOperationDetails={routeOperationDetails} + routeCashout={routeCashout} + routeChargeWallet={routeChargeWallet} + routeWireTransfer={routeWireTransfer} + limit={limit} + balance={balance} + routeClose={routeClose} + onClose={onClose} + onOperationCreated={onOperationCreated} + onAuthorizationRequired={onAuthorizationRequired} + /> + <Transactions + account={account} + routeCreateWireTransfer={routeCreateWireTransfer} + /> + </Fragment> + ); +} diff --git a/packages/bank-ui/src/pages/BankFrame.stories.tsx b/packages/bank-ui/src/pages/BankFrame.stories.tsx new file mode 100644 index 000000000..c874ac4ca --- /dev/null +++ b/packages/bank-ui/src/pages/BankFrame.stories.tsx @@ -0,0 +1,29 @@ +/* + This file is part of GNU Taler + (C) 2022-2024 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ + +import * as tests from "@gnu-taler/web-util/testing"; +import { BankFrame } from "./BankFrame.js"; + +export default { + title: "bank frame", +}; + +export const Ready = tests.createExample(BankFrame, {}); diff --git a/packages/bank-ui/src/pages/BankFrame.tsx b/packages/bank-ui/src/pages/BankFrame.tsx new file mode 100644 index 000000000..db757ee07 --- /dev/null +++ b/packages/bank-ui/src/pages/BankFrame.tsx @@ -0,0 +1,368 @@ +/* + This file is part of GNU Taler + (C) 2022-2024 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +import { + AbsoluteTime, + Amounts, + ObservabilityEventType, + TalerError, + TranslatedString, + assertUnreachable, +} from "@gnu-taler/taler-util"; +import { + Footer, + Header, + Loading, + RouteDefinition, + ToastBanner, + notifyError, + notifyException, + useBankCoreApiContext, + useTranslationContext, +} from "@gnu-taler/web-util/browser"; +import { ComponentChildren, Fragment, VNode, h } from "preact"; +import { useEffect, useErrorBoundary, useState } from "preact/hooks"; +import { privatePages } from "../Routing.js"; +import { useSettingsContext } from "../context/settings.js"; +import { useAccountDetails } from "../hooks/account.js"; +import { useBankState } from "../hooks/bank-state.js"; +import { + getAllBooleanPreferences, + getLabelForPreferences, + usePreferences, +} from "../hooks/preferences.js"; +import { useSessionState } from "../hooks/session.js"; +import { RenderAmount } from "./PaytoWireTransferForm.js"; + +const GIT_HASH = typeof __GIT_HASH__ !== "undefined" ? __GIT_HASH__ : undefined; +const VERSION = typeof __VERSION__ !== "undefined" ? __VERSION__ : undefined; + +export function BankFrame({ + children, + account, + routeAccountDetails, +}: { + account?: string; + routeAccountDetails?: RouteDefinition; + children: ComponentChildren; +}): VNode { + const { i18n } = useTranslationContext(); + const session = useSessionState(); + const settings = useSettingsContext(); + const [preferences, updatePreferences] = usePreferences(); + const [, , resetBankState] = useBankState(); + + const [error, resetError] = useErrorBoundary(); + + useEffect(() => { + if (error) { + if (error instanceof Error) { + console.log("Internal error, please report", error); + notifyException(i18n.str`Internal error, please report.`, error); + } else { + console.log("Internal error, please report", error); + notifyError( + i18n.str`Internal error, please report.`, + String(error) as TranslatedString, + ); + } + resetError(); + } + }, [error]); + + return ( + <div + class="min-h-full flex flex-col m-0 bg-slate-200" + style="min-height: 100vh;" + > + <div class="bg-indigo-600 pb-32"> + <Header + title="Bank" + iconLinkURL={settings.iconLinkURL ?? "#"} + profileURL={routeAccountDetails?.url({})} + notificationURL={ + preferences.showDebugInfo + ? privatePages.notifications.url({}) + : undefined + } + onLogout={ + session.state.status !== "loggedIn" + ? undefined + : () => { + session.logOut(); + resetBankState(); + } + } + sites={ + !settings.topNavSites ? [] : Object.entries(settings.topNavSites) + } + supportedLangs={["en", "es", "de"]} + > + <li> + <div class="text-xs font-semibold leading-6 text-gray-400"> + <i18n.Translate>Preferences</i18n.Translate> + </div> + <ul role="list" class="space-y-4"> + {getAllBooleanPreferences().map((set) => { + const isOn: boolean = !!preferences[set]; + return ( + <li key={set} class="pl-2"> + <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" + > + {getLabelForPreferences(set, i18n)} + </span> + </span> + <button + type="button" + name={`${set} switch`} + data-enabled={isOn} + 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={() => { + updatePreferences(set, !isOn); + }} + > + <span + aria-hidden="true" + data-enabled={isOn} + class="translate-x-5 data-[enabled=false]: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> + </li> + ); + })} + </ul> + </li> + </Header> + </div> + + <div class="fixed z-20 top-14 w-full"> + <div class="mx-auto w-4/5"> + <ToastBanner /> + {/* <Attention type="success" title={"hola" as TranslatedString} onClose={() => { }} /> */} + </div> + </div> + + <main class="-mt-32 flex-1"> + {account && routeAccountDetails && ( + <header class="py-6 bg-indigo-600"> + <div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8"> + <h1 class=" flex flex-wrap items-center justify-between sm:flex-nowrap"> + <span class="text-2xl font-bold tracking-tight text-white"> + <WelcomeAccount + account={account} + routeAccountDetails={routeAccountDetails} + /> + </span> + <span class="text-2xl font-bold tracking-tight text-white"> + <AccountBalance account={account} /> + </span> + </h1> + </div> + </header> + )} + + <div class="mx-auto max-w-7xl px-4 pb-4 sm:px-6 lg:px-8"> + <div class="rounded-lg bg-white px-5 py-6 shadow sm:px-6"> + {children} + </div> + </div> + </main> + + <AppActivity /> + + <Footer + testingUrlKey="corebank-api-base-url" + GIT_HASH={GIT_HASH} + VERSION={VERSION} + /> + </div> + ); +} + +function Wait({ class: clazz }: { class?: string }): VNode { + return ( + <Fragment> + <style>{` + .animated-loader { + display: inline-block; + --b: 5px; + border-radius: 50%; + aspect-ratio: 1; + padding: 1px; + background: conic-gradient(#0000 10%,#4f46e5) content-box; + -webkit-mask: + repeating-conic-gradient(#0000 0deg,#000 1deg 20deg,#0000 21deg 36deg), + radial-gradient(farthest-side,#0000 calc(100% - var(--b) - 1px),#000 calc(100% - var(--b))); + -webkit-mask-composite: destination-in; + mask-composite: intersect; + animation:spinning-loader 1s infinite steps(10); + } + @keyframes spinning-loader {to{transform: rotate(1turn)}} + `}</style> + <div class={`animated-loader ${clazz}`} /> + </Fragment> + ); +} + +function AppActivity(): VNode { + const [lastEvent, setLastEvent] = useState<{ + url: string; + id: string; + when: AbsoluteTime; + }>(); + const [status, setStatus] = useState<"ok" | "fail">(); + const d = useBankCoreApiContext(); + const onBackendActivity = !d ? undefined : d.onActivity; + const cancelRequest = !d ? undefined : d.cancelRequest; + const [pref] = usePreferences(); + useEffect(() => { + // console.log("ASDASDS", onBackendActivity) + if (!pref.showDebugInfo) return; + if (!onBackendActivity) return; + return onBackendActivity((ev) => { + switch (ev.type) { + case ObservabilityEventType.HttpFetchStart: { + setLastEvent(ev); + setStatus(undefined); + return; + } + case ObservabilityEventType.HttpFetchFinishError: { + setStatus("fail"); + return; + } + case ObservabilityEventType.HttpFetchFinishSuccess: { + setStatus("ok"); + return; + } + /** + * all of this are ignored + */ + case ObservabilityEventType.DbQueryStart: + case ObservabilityEventType.DbQueryFinishSuccess: + case ObservabilityEventType.DbQueryFinishError: + case ObservabilityEventType.RequestStart: + case ObservabilityEventType.RequestFinishSuccess: + case ObservabilityEventType.RequestFinishError: + case ObservabilityEventType.TaskStart: + case ObservabilityEventType.TaskStop: + case ObservabilityEventType.TaskReset: + case ObservabilityEventType.ShepherdTaskResult: + case ObservabilityEventType.DeclareTaskDependency: + case ObservabilityEventType.CryptoStart: + case ObservabilityEventType.CryptoFinishSuccess: + case ObservabilityEventType.CryptoFinishError: + case ObservabilityEventType.Message: + return; + default: { + assertUnreachable(ev); + } + } + }); + }); + if (!pref.showDebugInfo || !lastEvent) return <Fragment />; + return ( + <div + data-status={status} + class="fixed z-20 bottom-0 w-full ease-in-out delay-1000 transition-transform data-[status=ok]:scale-y-0" + > + <div + data-status={status} + class="mx-auto w-4/5 center flex p-1 bg-gray-300 m-1 data-[status=fail]:bg-red-200 data-[status=ok]:bg-green-200 " + > + {!status ? <Wait class="w-6 h-6" /> : <div class="w-6 h-6" />} + + <p class="ml-2 my-auto text-sm text-gray-500">{lastEvent.url}</p> + {!status ? ( + <button + onClick={() => { + if (cancelRequest) cancelRequest(lastEvent.id); + }} + > + cancel + </button> + ) : undefined} + </div> + </div> + ); +} + +function WelcomeAccount({ + account, + routeAccountDetails, +}: { + account: string; + routeAccountDetails: RouteDefinition; +}): VNode { + const { i18n } = useTranslationContext(); + const result = useAccountDetails(account); + if (!result) { + return <Loading />; + } + if (result instanceof TalerError) { + return <div />; + } + if (result.type === "fail") { + return ( + <a + name="account details" + href={routeAccountDetails.url({})} + class="underline underline-offset-2" + > + <i18n.Translate>Welcome</i18n.Translate> + </a> + ); + } + return ( + <a + name="account details" + href={routeAccountDetails.url({})} + class="underline underline-offset-2" + > + <i18n.Translate> + Welcome, <span class="whitespace-nowrap">{result.body.name}</span> + </i18n.Translate> + </a> + ); +} + +function AccountBalance({ account }: { account: string }): VNode { + const result = useAccountDetails(account); + const { config } = useBankCoreApiContext(); + if (!result) { + return <Loading />; + } + if (result instanceof TalerError) { + return <div />; + } + if (result.type === "fail") return <div />; + + return ( + <RenderAmount + value={Amounts.parseOrThrow(result.body.balance.amount)} + negative={result.body.balance.credit_debit_indicator === "debit"} + spec={config.currency_specification} + /> + ); +} diff --git a/packages/bank-ui/src/pages/LoginForm.tsx b/packages/bank-ui/src/pages/LoginForm.tsx new file mode 100644 index 000000000..600025400 --- /dev/null +++ b/packages/bank-ui/src/pages/LoginForm.tsx @@ -0,0 +1,230 @@ +/* + This file is part of GNU Taler + (C) 2022-2024 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +import { HttpStatusCode, createRFC8959AccessTokenEncoded, createRFC8959AccessTokenPlain } from "@gnu-taler/taler-util"; +import { + Button, + LocalNotificationBanner, + ShowInputErrorLabel, + useLocalNotificationHandler, + useTranslationContext, +} from "@gnu-taler/web-util/browser"; +import { VNode, h } from "preact"; +import { useEffect, useRef, useState } from "preact/hooks"; +import { useBankCoreApiContext } from "@gnu-taler/web-util/browser"; +import { useSessionState } from "../hooks/session.js"; +import { RouteDefinition } from "@gnu-taler/web-util/browser"; +import { undefinedIfEmpty } from "../utils.js"; +import { doAutoFocus } from "./PaytoWireTransferForm.js"; +import { USERNAME_REGEX } from "./RegistrationPage.js"; + +/** + * Collect and submit login data. + */ +export function LoginForm({ + currentUser, + fixedUser, + routeRegister, +}: { + fixedUser?: boolean; + currentUser?: string; + routeRegister?: RouteDefinition; +}): VNode { + const session = useSessionState(); + + const sessionUser = + session.state.status !== "loggedOut" ? session.state.username : undefined; + const [username, setUsername] = useState<string | undefined>( + currentUser ?? sessionUser, + ); + const [password, setPassword] = useState<string | undefined>(); + const { i18n } = useTranslationContext(); + const { + lib: { auth: authenticator }, + } = useBankCoreApiContext(); + const [notification, withErrorHandler] = useLocalNotificationHandler(); + const { config } = useBankCoreApiContext(); + + const ref = useRef<HTMLInputElement>(null); + useEffect(function focusInput() { + ref.current?.focus(); + }, []); + + const errors = undefinedIfEmpty({ + username: !username + ? i18n.str`Missing username` + : !USERNAME_REGEX.test(username) + ? i18n.str`Use letters, numbers or any of these characters: - . _ ~` + : undefined, + password: !password ? i18n.str`Missing password` : undefined, + }); + + async function doLogout() { + session.logOut(); + } + + const loginHandler = + !username || !password + ? undefined + : withErrorHandler( + async () => + authenticator(username).createAccessTokenBasic(username, password, { + scope: "readwrite", + duration: { d_us: "forever" }, + refreshable: true, + }), + (result) => { + session.logIn({ username, token: createRFC8959AccessTokenEncoded(result.body.access_token) }); + }, + (fail) => { + switch (fail.case) { + case HttpStatusCode.Unauthorized: + return i18n.str`Wrong credentials for "${username}"`; + case HttpStatusCode.NotFound: + return i18n.str`Account not found`; + } + }, + ); + + return ( + <div class="flex min-h-full flex-col justify-center "> + <LocalNotificationBanner notification={notification} /> + <div class="mt-10 sm:mx-auto sm:w-full sm:max-w-sm"> + <form + class="space-y-6" + noValidate + onSubmit={(e) => { + e.preventDefault(); + }} + autoCapitalize="none" + autoCorrect="off" + > + <div> + <label + for="username" + class="block text-sm font-medium leading-6 text-gray-900" + > + <i18n.Translate>Username</i18n.Translate> + </label> + <div class="mt-2"> + <input + ref={doAutoFocus} + type="text" + name="username" + id="username" + class="block w-full disabled:bg-gray-200 rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" + value={username ?? ""} + disabled={fixedUser} + enterkeyhint="next" + placeholder="identification" + autocomplete="username" + title={i18n.str`Username of the account`} + required + onInput={(e): void => { + setUsername(e.currentTarget.value); + }} + /> + <ShowInputErrorLabel + message={errors?.username} + isDirty={username !== undefined} + /> + </div> + </div> + + <div> + <div class="flex items-center justify-between"> + <label + for="password" + class="block text-sm font-medium leading-6 text-gray-900" + > + <i18n.Translate>Password</i18n.Translate> + </label> + </div> + <div class="mt-2"> + <input + type="password" + name="password" + id="password" + autocomplete="current-password" + class="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" + enterkeyhint="send" + value={password ?? ""} + placeholder="Password" + title={i18n.str`Password of the account`} + required + onInput={(e): void => { + setPassword(e.currentTarget.value); + }} + /> + <ShowInputErrorLabel + message={errors?.password} + isDirty={password !== undefined} + /> + </div> + </div> + + {session.state.status !== "loggedOut" ? ( + <div class="flex justify-between"> + <button + type="submit" + name="cancel" + class="rounded-md bg-white-600 px-3 py-1.5 text-sm font-semibold leading-6 text-black shadow-sm hover:bg-gray-100 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-600" + onClick={(e) => { + e.preventDefault(); + doLogout(); + }} + > + <i18n.Translate>Cancel</i18n.Translate> + </button> + + <Button + type="submit" + name="check" + class="rounded-md bg-indigo-600 disabled:bg-gray-300 px-3 py-1.5 text-sm font-semibold leading-6 text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" + disabled={!!errors} + handler={loginHandler} + > + <i18n.Translate>Check</i18n.Translate> + </Button> + </div> + ) : ( + <div> + <Button + type="submit" + name="login" + class="flex w-full justify-center rounded-md bg-indigo-600 disabled:bg-gray-300 px-3 py-1.5 text-sm font-semibold leading-6 text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" + disabled={!!errors} + handler={loginHandler} + > + <i18n.Translate>Log in</i18n.Translate> + </Button> + </div> + )} + </form> + + {config.allow_registrations && routeRegister && ( + <a + name="register" + href={routeRegister.url({})} + class="flex justify-center border-t mt-4 rounded-md bg-blue-600 px-3 py-1.5 text-sm font-semibold leading-6 text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600" + > + <i18n.Translate>Register</i18n.Translate> + </a> + )} + </div> + </div> + ); +} diff --git a/packages/bank-ui/src/pages/OperationState/index.ts b/packages/bank-ui/src/pages/OperationState/index.ts new file mode 100644 index 000000000..38f698a04 --- /dev/null +++ b/packages/bank-ui/src/pages/OperationState/index.ts @@ -0,0 +1,157 @@ +/* + This file is part of GNU Taler + (C) 2022-2024 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +import { + AbsoluteTime, + AmountJson, + TalerCoreBankErrorsByMethod, + TalerError, + WithdrawUriResult, +} from "@gnu-taler/taler-util"; +import { Loading, utils } from "@gnu-taler/web-util/browser"; +import { ErrorLoadingWithDebug } from "../../components/ErrorLoadingWithDebug.js"; +import { useComponentState } from "./state.js"; +import { + AbortedView, + ConfirmedView, + FailedView, + InvalidPaytoView, + InvalidReserveView, + InvalidWithdrawalView, + NeedConfirmationView, + ReadyView, +} from "./views.js"; +import { RouteDefinition } from "@gnu-taler/web-util/browser"; + +export interface Props { + currency: string; + onAuthorizationRequired: () => void; + routeClose: RouteDefinition; + onAbort: () => void; + routeHere: RouteDefinition<{ wopid: string }>; +} + +export type State = + | State.Loading + | State.LoadingError + | State.Ready + | State.Failed + | State.Aborted + | State.Confirmed + | State.InvalidPayto + | State.InvalidWithdrawal + | State.InvalidReserve + | State.NeedConfirmation; + +export namespace State { + export interface Loading { + status: "loading"; + error: undefined; + } + + export interface Failed { + status: "failed"; + error: TalerCoreBankErrorsByMethod<"createWithdrawal">; + } + + export interface LoadingError { + status: "loading-error"; + error: TalerError; + } + + /** + * Need to open the wallet + */ + export interface Ready { + status: "ready"; + error: undefined; + uri: WithdrawUriResult; + onAbort: () => Promise< + TalerCoreBankErrorsByMethod<"abortWithdrawalById"> | undefined + >; + routeClose: RouteDefinition; + } + + export interface InvalidPayto { + status: "invalid-payto"; + error: undefined; + payto: string | undefined; + } + export interface InvalidWithdrawal { + status: "invalid-withdrawal"; + error: undefined; + uri: string; + } + export interface InvalidReserve { + status: "invalid-reserve"; + error: undefined; + reserve: string | undefined; + } + export interface NeedConfirmation { + status: "need-confirmation"; + onAuthorizationRequired: () => void; + account: string; + routeHere: RouteDefinition<{ wopid: string }>; + onAbort: + | undefined + | (() => Promise< + TalerCoreBankErrorsByMethod<"abortWithdrawalById"> | undefined + >); + onConfirm: + | undefined + | (() => Promise< + TalerCoreBankErrorsByMethod<"confirmWithdrawalById"> | undefined + >); + error: undefined; + id: string; + } + export interface Aborted { + status: "aborted"; + error: undefined; + routeClose: RouteDefinition; + } + export interface Confirmed { + status: "confirmed"; + error: undefined; + routeClose: RouteDefinition; + } +} + +export interface Transaction { + negative: boolean; + counterpart: string; + when: AbsoluteTime; + amount: AmountJson | undefined; + subject: string; +} + +const viewMapping: utils.StateViewMap<State> = { + loading: Loading, + failed: FailedView, + "invalid-payto": InvalidPaytoView, + "invalid-withdrawal": InvalidWithdrawalView, + "invalid-reserve": InvalidReserveView, + "need-confirmation": NeedConfirmationView, + aborted: AbortedView, + confirmed: ConfirmedView, + "loading-error": ErrorLoadingWithDebug, + ready: ReadyView, +}; + +export const OperationState = utils.compose( + (p: Props) => useComponentState(p), + viewMapping, +); diff --git a/packages/bank-ui/src/pages/OperationState/state.ts b/packages/bank-ui/src/pages/OperationState/state.ts new file mode 100644 index 000000000..19c097d18 --- /dev/null +++ b/packages/bank-ui/src/pages/OperationState/state.ts @@ -0,0 +1,234 @@ +/* + This file is part of GNU Taler + (C) 2022-2024 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +import { + Amounts, + HttpStatusCode, + TalerCoreBankErrorsByMethod, + TalerError, + assertUnreachable, + parsePaytoUri, + parseWithdrawUri, + stringifyWithdrawUri, +} from "@gnu-taler/taler-util"; +import { utils } from "@gnu-taler/web-util/browser"; +import { useEffect, useState } from "preact/hooks"; +import { mutate } from "swr"; +import { useBankCoreApiContext } from "@gnu-taler/web-util/browser"; +import { useWithdrawalDetails } from "../../hooks/account.js"; +import { useSessionState } from "../../hooks/session.js"; +import { useBankState } from "../../hooks/bank-state.js"; +import { usePreferences } from "../../hooks/preferences.js"; +import { Props, State } from "./index.js"; + +export function useComponentState({ + currency, + routeClose, + onAbort, + routeHere, + onAuthorizationRequired, +}: Props): utils.RecursiveState<State> { + const [settings] = usePreferences(); + const [bankState, updateBankState] = useBankState(); + const { state: credentials } = useSessionState(); + const creds = credentials.status !== "loggedIn" ? undefined : credentials; + const { + lib: { bank }, + } = useBankCoreApiContext(); + + const [failure, setFailure] = useState< + TalerCoreBankErrorsByMethod<"createWithdrawal"> | undefined + >(); + const amount = settings.maxWithdrawalAmount; + + async function doSilentStart() { + // FIXME: if amount is not enough use balance + const parsedAmount = Amounts.parseOrThrow(`${currency}:${amount}`); + if (!creds) return; + const resp = await bank.createWithdrawal(creds, { + amount: Amounts.stringify(parsedAmount), + }); + if (resp.type === "fail") { + setFailure(resp); + return; + } + updateBankState("currentWithdrawalOperationId", resp.body.withdrawal_id); + } + + const withdrawalOperationId = bankState.currentWithdrawalOperationId; + useEffect(() => { + if (withdrawalOperationId === undefined) { + doSilentStart(); + } + }, [settings.fastWithdrawal, amount]); + + if (failure) { + return { + status: "failed", + error: failure, + }; + } + + if (!withdrawalOperationId) { + return { + status: "loading", + error: undefined, + }; + } + + const wid = withdrawalOperationId; + + async function doAbort() { + if (!creds) return; + const resp = await bank.abortWithdrawalById(creds, wid); + if (resp.type === "ok") { + // updateBankState("currentWithdrawalOperationId", undefined) + onAbort(); + } else { + return resp; + } + } + + async function doConfirm(): Promise< + TalerCoreBankErrorsByMethod<"confirmWithdrawalById"> | undefined + > { + if (!creds) return; + const resp = await bank.confirmWithdrawalById(creds, wid); + if (resp.type === "ok") { + mutate(() => true); //clean withdrawal state + } else { + return resp; + } + } + + const uri = stringifyWithdrawUri({ + bankIntegrationApiBaseUrl: bank.getIntegrationAPI().href, + withdrawalOperationId, + }); + const parsedUri = parseWithdrawUri(uri); + if (!parsedUri) { + return { + status: "invalid-withdrawal", + error: undefined, + uri, + }; + } + + return (): utils.RecursiveState<State> => { + const result = useWithdrawalDetails(withdrawalOperationId); + const shouldCreateNewOperation = result && !(result instanceof TalerError); + + useEffect(() => { + if (shouldCreateNewOperation) { + doSilentStart(); + } + }, []); + if (!result) { + return { + status: "loading", + error: undefined, + }; + } + if (result instanceof TalerError) { + return { + status: "loading-error", + error: result, + }; + } + + if (result.type === "fail") { + switch (result.case) { + case HttpStatusCode.BadRequest: + case HttpStatusCode.NotFound: { + return { + status: "aborted", + error: undefined, + routeClose, + }; + } + default: + assertUnreachable(result); + } + } + + const { body: data } = result; + if (data.status === "aborted") { + return { + status: "aborted", + error: undefined, + routeClose, + }; + } + + if (data.status === "confirmed") { + if (!settings.showWithdrawalSuccess) { + updateBankState("currentWithdrawalOperationId", undefined); + // onClose() + } + return { + status: "confirmed", + error: undefined, + routeClose, + }; + } + + if (data.status === "pending") { + return { + status: "ready", + error: undefined, + uri: parsedUri, + routeClose, + onAbort: !creds + ? async () => { + onAbort(); + return undefined; + } + : doAbort, + }; + } + + if (!data.selected_reserve_pub) { + return { + status: "invalid-reserve", + error: undefined, + reserve: data.selected_reserve_pub, + }; + } + + const account = !data.selected_exchange_account + ? undefined + : parsePaytoUri(data.selected_exchange_account); + + if (!account) { + return { + status: "invalid-payto", + error: undefined, + payto: data.selected_exchange_account, + }; + } + + return { + status: "need-confirmation", + error: undefined, + routeHere, + onAuthorizationRequired, + account: data.username, + id: withdrawalOperationId, + onAbort: !creds ? undefined : doAbort, + onConfirm: !creds ? undefined : doConfirm, + }; + }; +} diff --git a/packages/bank-ui/src/pages/OperationState/stories.tsx b/packages/bank-ui/src/pages/OperationState/stories.tsx new file mode 100644 index 000000000..82253b82c --- /dev/null +++ b/packages/bank-ui/src/pages/OperationState/stories.tsx @@ -0,0 +1,29 @@ +/* + This file is part of GNU Taler + (C) 2022-2024 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ + +import * as tests from "@gnu-taler/web-util/testing"; +import { ReadyView } from "./views.js"; + +export default { + title: "operation status page", +}; + +export const Ready = tests.createExample(ReadyView, {}); diff --git a/packages/bank-ui/src/pages/OperationState/test.ts b/packages/bank-ui/src/pages/OperationState/test.ts new file mode 100644 index 000000000..d47cb64a2 --- /dev/null +++ b/packages/bank-ui/src/pages/OperationState/test.ts @@ -0,0 +1,31 @@ +/* + This file is part of GNU Taler + (C) 2022-2024 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ + +// import * as tests from "@gnu-taler/web-util/testing"; +// import { SwrMockEnvironment } from "@gnu-taler/web-util/testing"; +// import { expect } from "chai"; +// import { CASHOUT_API_EXAMPLE } from "../../endpoints.js"; +// import { Props } from "./index.js"; +// import { useComponentState } from "./state.js"; + +describe("Withdrawal operation states", () => { + it("should do some tests", async () => {}); +}); diff --git a/packages/bank-ui/src/pages/OperationState/views.tsx b/packages/bank-ui/src/pages/OperationState/views.tsx new file mode 100644 index 000000000..62308eca6 --- /dev/null +++ b/packages/bank-ui/src/pages/OperationState/views.tsx @@ -0,0 +1,447 @@ +/* + This file is part of GNU Taler + (C) 2022-2024 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +import { + AbsoluteTime, + HttpStatusCode, + TalerErrorCode, + TranslatedString, + assertUnreachable, + stringifyWithdrawUri, +} from "@gnu-taler/taler-util"; +import { + Attention, + LocalNotificationBanner, + notifyInfo, + useLocalNotification, + useTalerWalletIntegrationAPI, + useTranslationContext, +} from "@gnu-taler/web-util/browser"; +import { Fragment, VNode, h } from "preact"; +import { useEffect } from "preact/hooks"; +import { QR } from "../../components/QR.js"; +import { useBankState } from "../../hooks/bank-state.js"; +import { usePreferences } from "../../hooks/preferences.js"; +import { ShouldBeSameUser } from "../WithdrawalConfirmationQuestion.js"; +import { State } from "./index.js"; + +export function InvalidPaytoView({ payto }: State.InvalidPayto) { + return <div>Payto from server is not valid "{payto}"</div>; +} +export function InvalidWithdrawalView({ uri }: State.InvalidWithdrawal) { + return <div>Withdrawal uri from server is not valid "{uri}"</div>; +} +export function InvalidReserveView({ reserve }: State.InvalidReserve) { + return <div>Reserve from server is not valid "{reserve}"</div>; +} + +export function NeedConfirmationView({ + onAbort: doAbort, + onConfirm: doConfirm, + routeHere, + account, + id, + onAuthorizationRequired, +}: State.NeedConfirmation) { + const { i18n } = useTranslationContext(); + const [settings] = usePreferences(); + const [notification, notify, errorHandler] = useLocalNotification(); + const [, updateBankState] = useBankState(); + + async function onCancel() { + errorHandler(async () => { + if (!doAbort) return; + const resp = await doAbort(); + if (!resp) return; + switch (resp.case) { + case HttpStatusCode.Conflict: + 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, + when: AbsoluteTime.now(), + }); + case HttpStatusCode.BadRequest: + return notify({ + type: "error", + title: i18n.str`The operation id is invalid.`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + when: AbsoluteTime.now(), + }); + case HttpStatusCode.NotFound: + return notify({ + type: "error", + title: i18n.str`The operation was not found.`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + when: AbsoluteTime.now(), + }); + default: + assertUnreachable(resp); + } + }); + } + + async function onConfirm() { + errorHandler(async () => { + if (!doConfirm) return; + const resp = await doConfirm(); + if (!resp) { + if (!settings.showWithdrawalSuccess) { + notifyInfo(i18n.str`Wire transfer completed!`); + } + return; + } + switch (resp.case) { + case TalerErrorCode.BANK_CONFIRM_ABORT_CONFLICT: + 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, + when: AbsoluteTime.now(), + }); + case TalerErrorCode.BANK_CONFIRM_INCOMPLETE: + return notify({ + type: "error", + title: i18n.str`The withdrawal operation can't be confirmed before a wallet accepted the transaction.`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + when: AbsoluteTime.now(), + }); + case HttpStatusCode.BadRequest: + return notify({ + type: "error", + title: i18n.str`The operation id is invalid.`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + when: AbsoluteTime.now(), + }); + case HttpStatusCode.NotFound: + return notify({ + type: "error", + title: i18n.str`The operation was not found.`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + when: AbsoluteTime.now(), + }); + case TalerErrorCode.BANK_UNALLOWED_DEBIT: + return notify({ + type: "error", + title: i18n.str`Your balance is not enough.`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + when: AbsoluteTime.now(), + }); + case HttpStatusCode.Accepted: { + updateBankState("currentChallenge", { + operation: "confirm-withdrawal", + id: String(resp.body.challenge_id), + sent: AbsoluteTime.never(), + location: routeHere.url({ wopid: id }), + request: id, + }); + return onAuthorizationRequired(); + } + default: + assertUnreachable(resp); + } + }); + } + + return ( + <div class="bg-white shadow sm:rounded-lg"> + <LocalNotificationBanner notification={notification} /> + <div class="px-4 py-5 sm:p-6"> + <h3 class="text-base font-semibold text-gray-900"> + <i18n.Translate>Confirm the withdrawal operation</i18n.Translate> + </h3> + <div class="mt-3 text-sm leading-6"> + <ShouldBeSameUser username={account}> + <form + class="bg-white shadow-sm ring-1 ring-gray-900/5 sm:rounded-xl md:col-span-2" + autoCapitalize="none" + autoCorrect="off" + onSubmit={(e) => { + e.preventDefault(); + }} + > + <div class="flex items-center justify-between gap-x-6 border-t border-gray-900/10 px-4 py-4 sm:px-8"> + <button + type="button" + name="cancel" + class="text-sm font-semibold leading-6 text-gray-900" + onClick={(e) => { + e.preventDefault(); + onCancel(); + }} + > + <i18n.Translate>Cancel</i18n.Translate> + </button> + <button + type="submit" + name="transfer" + class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" + onClick={(e) => { + e.preventDefault(); + onConfirm(); + }} + > + <i18n.Translate>Transfer</i18n.Translate> + </button> + </div> + </form> + </ShouldBeSameUser> + </div> + </div> + </div> + ); +} +export function FailedView({ error }: State.Failed) { + const { i18n } = useTranslationContext(); + switch (error.case) { + case HttpStatusCode.Unauthorized: + return ( + <Attention + type="danger" + title={i18n.str`Unauthorized to make the operation, maybe the session has expired or the password changed.`} + > + <div class="mt-2 text-sm text-red-700">{error.detail.hint}</div> + </Attention> + ); + case HttpStatusCode.Conflict: + return ( + <Attention + type="danger" + title={i18n.str`The operation was rejected due to insufficient funds.`} + > + <div class="mt-2 text-sm text-red-700">{error.detail.hint}</div> + </Attention> + ); + case HttpStatusCode.NotFound: + return ( + <Attention + type="danger" + title={i18n.str`The operation was rejected due to insufficient funds.`} + > + <div class="mt-2 text-sm text-red-700">{error.detail.hint}</div> + </Attention> + ); + default: + assertUnreachable(error); + } +} + +export function AbortedView() { + return <div>aborted</div>; +} + +export function ConfirmedView({ routeClose }: State.Confirmed) { + const { i18n } = useTranslationContext(); + const [settings, updateSettings] = usePreferences(); + return ( + <Fragment> + <div class="relative ml-auto mr-auto transform overflow-hidden rounded-lg bg-white p-4 text-left shadow-xl transition-all "> + <div class="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-green-100"> + <svg + class="h-6 w-6 text-green-600" + fill="none" + viewBox="0 0 24 24" + stroke-width="1.5" + stroke="currentColor" + aria-hidden="true" + > + <path + stroke-linecap="round" + stroke-linejoin="round" + d="M4.5 12.75l6 6 9-13.5" + /> + </svg> + </div> + <div class="mt-3 text-center sm:mt-5"> + <h3 + class="text-base font-semibold leading-6 text-gray-900" + id="modal-title" + > + <i18n.Translate>Withdrawal confirmed</i18n.Translate> + </h3> + <div class="mt-2"> + <p class="text-sm text-gray-500"> + <i18n.Translate> + The wire transfer to the Taler operator has been initiated. You + will soon receive the requested amount in your Taler wallet. + </i18n.Translate> + </p> + </div> + </div> + </div> + <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>Do not show this again</i18n.Translate> + </span> + </span> + <button + type="button" + name="toggle withdrawal" + data-enabled={!settings.showWithdrawalSuccess} + class="bg-indigo-600 data-[enabled=false]:bg-gray-200 relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-indigo-600 focus:ring-offset-2" + role="switch" + aria-checked="false" + aria-labelledby="availability-label" + aria-describedby="availability-description" + onClick={() => { + updateSettings( + "showWithdrawalSuccess", + !settings.showWithdrawalSuccess, + ); + }} + > + <span + aria-hidden="true" + data-enabled={!settings.showWithdrawalSuccess} + class="translate-x-5 data-[enabled=false]: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="mt-5 sm:mt-6"> + <a + href={routeClose.url({})} + type="button" + name="close" + class="inline-flex w-full justify-center rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" + > + <i18n.Translate>Close</i18n.Translate> + </a> + </div> + </Fragment> + ); +} + +export function ReadyView({ uri, onAbort: doAbort }: State.Ready): VNode { + const { i18n } = useTranslationContext(); + const walletInegrationApi = useTalerWalletIntegrationAPI(); + const [notification, notify, errorHandler] = useLocalNotification(); + + const talerWithdrawUri = stringifyWithdrawUri(uri); + useEffect(() => { + walletInegrationApi.publishTalerAction(uri); + }, []); + + async function onAbort() { + errorHandler(async () => { + const hasError = await doAbort(); + if (!hasError) return; + switch (hasError.case) { + case HttpStatusCode.Conflict: + return notify({ + type: "error", + title: i18n.str`The reserve operation has been confirmed previously and can't be aborted`, + description: hasError.detail.hint as TranslatedString, + debug: hasError.detail, + when: AbsoluteTime.now(), + }); + case HttpStatusCode.BadRequest: + return notify({ + type: "error", + title: i18n.str`The operation id is invalid.`, + description: hasError.detail.hint as TranslatedString, + debug: hasError.detail, + when: AbsoluteTime.now(), + }); + case HttpStatusCode.NotFound: + return notify({ + type: "error", + title: i18n.str`The operation was not found.`, + description: hasError.detail.hint as TranslatedString, + debug: hasError.detail, + when: AbsoluteTime.now(), + }); + default: + assertUnreachable(hasError); + } + }); + } + + return ( + <Fragment> + <LocalNotificationBanner notification={notification} /> + + <div class="flex justify-end mt-4"> + <button + type="button" + name="cancel" + class="inline-flex items-center rounded-md bg-red-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-500" + onClick={onAbort} + > + <i18n.Translate>Cancel</i18n.Translate> + </button> + </div> + + <div class="bg-white shadow sm:rounded-lg mt-4"> + <div class="p-4"> + <h3 class="text-base font-semibold leading-6 text-gray-900"> + <i18n.Translate>On this device</i18n.Translate> + </h3> + <div class="mt-2 sm:flex sm:items-start sm:justify-between"> + <div class="max-w-xl text-sm text-gray-500"> + <p> + <i18n.Translate> + If you are using a web browser on desktop you can also + </i18n.Translate> + </p> + </div> + <div class="mt-5 sm:ml-6 sm:mt-0 sm:flex sm:flex-shrink-0 sm:items-center"> + <a + href={talerWithdrawUri} + name="start" + class="inline-flex items-center disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" + > + <i18n.Translate>Start</i18n.Translate> + </a> + </div> + </div> + </div> + </div> + <div class="bg-white shadow sm:rounded-lg mt-2"> + <div class="p-4"> + <h3 class="text-base font-semibold leading-6 text-gray-900"> + <i18n.Translate>On a mobile phone</i18n.Translate> + </h3> + <div class="mt-2 sm:flex sm:items-start sm:justify-between"> + <div class="max-w-xl text-sm text-gray-500"> + <p> + <i18n.Translate> + Scan the QR code with your mobile device. + </i18n.Translate> + </p> + </div> + </div> + <div class="mt-2 max-w-md ml-auto mr-auto"> + <QR text={talerWithdrawUri} /> + </div> + </div> + </div> + </Fragment> + ); +} diff --git a/packages/bank-ui/src/pages/PaymentOptions.stories.tsx b/packages/bank-ui/src/pages/PaymentOptions.stories.tsx new file mode 100644 index 000000000..78af886a8 --- /dev/null +++ b/packages/bank-ui/src/pages/PaymentOptions.stories.tsx @@ -0,0 +1,35 @@ +/* + This file is part of GNU Taler + (C) 2022-2024 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ + +import * as tests from "@gnu-taler/web-util/testing"; +import { PaymentOptions } from "./PaymentOptions.js"; + +export default { + title: "PaymentOptions", +}; + +export const USD = tests.createExample(PaymentOptions, { + limit: { + currency: "USD", + fraction: 0, + value: 1, + }, +}); diff --git a/packages/bank-ui/src/pages/PaymentOptions.tsx b/packages/bank-ui/src/pages/PaymentOptions.tsx new file mode 100644 index 000000000..386fe31bc --- /dev/null +++ b/packages/bank-ui/src/pages/PaymentOptions.tsx @@ -0,0 +1,239 @@ +/* + This file is part of GNU Taler + (C) 2022-2024 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +import { AmountJson, TalerError } from "@gnu-taler/taler-util"; +import { useTranslationContext } from "@gnu-taler/web-util/browser"; +import { Fragment, VNode, h } from "preact"; +import { useEffect } from "preact/hooks"; +import { useWithdrawalDetails } from "../hooks/account.js"; +import { useBankState } from "../hooks/bank-state.js"; +import { useSessionState } from "../hooks/session.js"; +import { RouteDefinition } from "@gnu-taler/web-util/browser"; +import { PaytoWireTransferForm } from "./PaytoWireTransferForm.js"; +import { WalletWithdrawForm } from "./WalletWithdrawForm.js"; + +function ShowOperationPendingTag({ + woid, + onOperationAlreadyCompleted, +}: { + woid: string; + onOperationAlreadyCompleted?: () => void; +}): VNode { + const { i18n } = useTranslationContext(); + const { state: credentials } = useSessionState(); + const result = useWithdrawalDetails(woid); + const loading = !result; + const error = + !loading && (result instanceof TalerError || result.type === "fail"); + const pending = + !loading && + !error && + (result.body.status === "pending" || result.body.status === "selected") && + credentials.status === "loggedIn" && + credentials.username === result.body.username; + useEffect(() => { + if (!loading && !pending && onOperationAlreadyCompleted) { + onOperationAlreadyCompleted(); + } + }, [pending]); + + if (error || !pending) { + return <Fragment />; + } + + return ( + <span class="flex items-center gap-x-1.5 w-fit rounded-md bg-green-100 px-2 py-1 text-xs font-medium text-green-700 whitespace-pre"> + <svg + class="h-1.5 w-1.5 fill-green-500" + viewBox="0 0 6 6" + aria-hidden="true" + > + <circle cx="3" cy="3" r="3" /> + </svg> + <i18n.Translate>Operation ready</i18n.Translate> + </span> + ); +} + +/** + * Let the user choose a payment option, + * then specify the details trigger the action. + */ +export function PaymentOptions({ + routeClose, + routeCashout, + routeChargeWallet, + routeWireTransfer, + tab, + limit, + balance, + onOperationCreated, + onClose, + routeOperationDetails, + onAuthorizationRequired, +}: { + limit: AmountJson; + balance: AmountJson; + tab: "charge-wallet" | "wire-transfer" | undefined; + onAuthorizationRequired: () => void; + onOperationCreated: (wopid: string) => void; + onClose: () => void; + + routeOperationDetails: RouteDefinition<{ wopid: string }>; + routeClose: RouteDefinition; + routeCashout: RouteDefinition; + routeChargeWallet: RouteDefinition; + routeWireTransfer: RouteDefinition<{ + account?: string; + subject?: string; + amount?: string; + }>; +}): VNode { + const { i18n } = useTranslationContext(); + const [bankState, updateBankState] = useBankState(); + + return ( + <div class="mt-4"> + <fieldset> + <legend class="px-4 text-base font-semibold leading-6 text-gray-900"> + <i18n.Translate>Send money</i18n.Translate> + </legend> + + <div class="px-4 mt-4 grid grid-cols-1 gap-y-6 sm:grid-cols-2 sm:gap-x-4"> + {/* <!-- Active: "border-indigo-600 ring-2 ring-indigo-600", Not Active: "border-gray-300" --> */} + <a name="charge wallet" href={routeChargeWallet.url({})}> + <label + class={ + "relative flex cursor-pointer rounded-lg border bg-white p-4 shadow-sm focus:outline-none" + + (tab === "charge-wallet" + ? "border-indigo-600 ring-2 ring-indigo-600" + : "border-gray-300") + } + > + <div class="flex flex-col"> + <span class="flex"> + <div class="text-4xl mr-4 my-auto">💵</div> + <span class="grow self-center text-lg text-gray-900 align-middle text-center"> + <i18n.Translate>to a Taler wallet</i18n.Translate> + </span> + <svg + class="self-center flex-none h-5 w-5 text-indigo-600" + style={{ + visibility: + tab === "charge-wallet" ? "visible" : "hidden", + }} + viewBox="0 0 20 20" + fill="currentColor" + aria-hidden="true" + > + <path + fill-rule="evenodd" + d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z" + clip-rule="evenodd" + /> + </svg> + </span> + <div class="mt-1 flex items-center text-sm text-gray-500"> + <i18n.Translate> + Withdraw digital money into your mobile wallet or browser + extension + </i18n.Translate> + </div> + {!!bankState.currentWithdrawalOperationId && ( + <ShowOperationPendingTag + woid={bankState.currentWithdrawalOperationId} + onOperationAlreadyCompleted={() => { + updateBankState( + "currentWithdrawalOperationId", + undefined, + ); + }} + /> + )} + </div> + </label> + </a> + + <a name="wire transfer" href={routeWireTransfer.url({})}> + <label + class={ + "relative flex cursor-pointer rounded-lg border bg-white p-4 shadow-sm focus:outline-none" + + (tab === "wire-transfer" + ? "border-indigo-600 ring-2 ring-indigo-600" + : "border-gray-300") + } + > + <div class="flex flex-col"> + <span class="flex"> + <div class="text-4xl mr-4 my-auto">↔</div> + <span class="grow self-center text-lg font-medium text-gray-900 align-middle text-center"> + <i18n.Translate>to another bank account</i18n.Translate> + </span> + <svg + class="self-center flex-none h-5 w-5 text-indigo-600" + style={{ + visibility: + tab === "wire-transfer" ? "visible" : "hidden", + }} + viewBox="0 0 20 20" + fill="currentColor" + aria-hidden="true" + > + <path + fill-rule="evenodd" + d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z" + clip-rule="evenodd" + /> + </svg> + </span> + <div class="mt-1 flex items-center text-sm text-gray-500"> + <i18n.Translate> + Make a wire transfer to an account with known bank account + number. + </i18n.Translate> + </div> + </div> + </label> + </a> + </div> + {tab === "charge-wallet" && ( + <WalletWithdrawForm + routeOperationDetails={routeOperationDetails} + focus + limit={limit} + balance={balance} + onAuthorizationRequired={onAuthorizationRequired} + onOperationCreated={onOperationCreated} + onOperationAborted={onClose} + routeCancel={routeClose} + /> + )} + {tab === "wire-transfer" && ( + <PaytoWireTransferForm + focus + routeHere={routeWireTransfer} + limit={limit} + balance={balance} + onAuthorizationRequired={onAuthorizationRequired} + onSuccess={onClose} + routeCashout={routeCashout} + routeCancel={routeClose} + /> + )} + </fieldset> + </div> + ); +} diff --git a/packages/bank-ui/src/pages/PaytoWireTransferForm.stories.tsx b/packages/bank-ui/src/pages/PaytoWireTransferForm.stories.tsx new file mode 100644 index 000000000..61cfb5629 --- /dev/null +++ b/packages/bank-ui/src/pages/PaytoWireTransferForm.stories.tsx @@ -0,0 +1,35 @@ +/* + This file is part of GNU Taler + (C) 2022-2024 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ + +import * as tests from "@gnu-taler/web-util/testing"; +import { PaytoWireTransferForm } from "./PaytoWireTransferForm.js"; + +export default { + title: "PaytoWireTransferForm", +}; + +export const USD = tests.createExample(PaytoWireTransferForm, { + limit: { + currency: "USD", + fraction: 0, + value: 1, + }, +}); diff --git a/packages/bank-ui/src/pages/PaytoWireTransferForm.tsx b/packages/bank-ui/src/pages/PaytoWireTransferForm.tsx new file mode 100644 index 000000000..3bf891504 --- /dev/null +++ b/packages/bank-ui/src/pages/PaytoWireTransferForm.tsx @@ -0,0 +1,924 @@ +/* + This file is part of GNU Taler + (C) 2022-2024 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +import { + AbsoluteTime, + AmountJson, + AmountString, + Amounts, + CurrencySpecification, + FRAC_SEPARATOR, + HttpStatusCode, + PaytoString, + PaytoUri, + TalerCorebankApi, + TalerErrorCode, + TranslatedString, + assertUnreachable, + buildPayto, + parsePaytoUri, + stringifyPaytoUri, +} from "@gnu-taler/taler-util"; +import { + InternationalizationAPI, + LocalNotificationBanner, + RouteDefinition, + ShowInputErrorLabel, + notifyInfo, + useBankCoreApiContext, + useLocalNotification, + useTranslationContext, +} from "@gnu-taler/web-util/browser"; +import { ComponentChildren, Fragment, Ref, VNode, h } from "preact"; +import { useState } from "preact/hooks"; +import { mutate } from "swr"; +import { IdempotencyRetry } from "../../../taler-util/lib/http-client/utils.js"; +import { useBankState } from "../hooks/bank-state.js"; +import { useSessionState } from "../hooks/session.js"; +import { undefinedIfEmpty, validateIBAN, validateTalerBank } from "../utils.js"; + +interface Props { + focus?: boolean; + withAccount?: string; + withSubject?: string; + withAmount?: string; + onSuccess: () => void; + onAuthorizationRequired: () => void; + routeCancel?: RouteDefinition; + routeCashout?: RouteDefinition; + routeHere: RouteDefinition<{ + account?: string; + subject?: string; + amount?: string; + }>; + limit: AmountJson; + balance: AmountJson; +} + +export function PaytoWireTransferForm({ + focus, + withAccount, + withSubject, + withAmount, + onSuccess, + routeCancel, + routeCashout, + routeHere, + onAuthorizationRequired, + limit, +}: Props): VNode { + const [inputType, setInputType] = useState<"form" | "payto" | "qr">("form"); + const isRawPayto = inputType !== "form"; + + const { state: credentials } = useSessionState(); + const { + lib: { bank: api }, + config, + url, + } = useBankCoreApiContext(); + + const sendingToFixedAccount = withAccount !== undefined; + + const [account, setAccount] = useState<string | undefined>(withAccount); + const [subject, setSubject] = useState<string | undefined>(withSubject); + const [amount, setAmount] = useState<string | undefined>(withAmount); + const [, updateBankState] = useBankState(); + + const [rawPaytoInput, rawPaytoInputSetter] = useState<string | undefined>( + undefined, + ); + const { i18n } = useTranslationContext(); + + const trimmedAmountStr = amount?.trim(); + const parsedAmount = Amounts.parse(`${limit.currency}:${trimmedAmountStr}`); + const [notification, notify, handleError] = useLocalNotification(); + + const paytoType = + config.wire_type === "X_TALER_BANK" + ? ("x-taler-bank" as const) + : ("iban" as const); + + const errorsWire = undefinedIfEmpty({ + account: !account + ? i18n.str`Required` + : paytoType === "iban" + ? validateIBAN(account, i18n) + : paytoType === "x-taler-bank" + ? validateTalerBank(account, i18n) + : undefined, + subject: !subject ? i18n.str`Required` : validateSubject(subject, i18n), + amount: !trimmedAmountStr + ? i18n.str`Required` + : !parsedAmount + ? i18n.str`Not valid` + : validateAmount(parsedAmount, limit, i18n), + }); + + const parsed = !rawPaytoInput ? undefined : parsePaytoUri(rawPaytoInput); + + const errorsPayto = undefinedIfEmpty({ + rawPaytoInput: !rawPaytoInput + ? i18n.str`Required` + : !parsed + ? i18n.str`Does not follow the pattern` + : validateRawPayto(parsed, limit, url.host, i18n, paytoType), + }); + + async function doSend() { + let payto_uri: PaytoString | undefined; + let sendingAmount: AmountString | undefined; + + if (credentials.status !== "loggedIn") return; + let acName: string | undefined; + if (isRawPayto) { + const p = parsePaytoUri(rawPaytoInput!); + if (!p) return; + sendingAmount = p.params.amount as AmountString; + delete p.params.amount; + // if this payto is valid then it already have message + payto_uri = stringifyPaytoUri(p); + acName = !p.isKnown + ? undefined + : p.targetType === "iban" + ? p.iban + : p.targetType === "bitcoin" + ? p.address + : p.targetType === "x-taler-bank" + ? p.account + : assertUnreachable(p); + } else { + if (!account || !subject) return; + let payto; + acName = account; + switch (paytoType) { + case "x-taler-bank": { + payto = buildPayto("x-taler-bank", url.host, account); + + break; + } + case "iban": { + payto = buildPayto("iban", account, undefined); + break; + } + default: + assertUnreachable(paytoType); + } + + payto.params.message = encodeURIComponent(subject); + payto_uri = stringifyPaytoUri(payto); + sendingAmount = `${limit.currency}:${trimmedAmountStr}` as AmountString; + } + const puri = payto_uri; + const sAmount = sendingAmount; + + await handleError(async function createTransactionHandleError() { + const request: TalerCorebankApi.CreateTransactionRequest = { + payto_uri: puri, + amount: sAmount, + }; + const check = IdempotencyRetry.tryFiveTimes(); + const resp = await api.createTransaction(credentials, request, check); + mutate(() => true); + if (resp.type === "fail") { + switch (resp.case) { + case HttpStatusCode.BadRequest: + return notify({ + type: "error", + title: i18n.str`The request was invalid or the payto://-URI used unacceptable features.`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + when: AbsoluteTime.now(), + }); + case HttpStatusCode.Unauthorized: + return notify({ + type: "error", + title: i18n.str`Not enough permission to complete the operation.`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + when: AbsoluteTime.now(), + }); + case TalerErrorCode.BANK_ADMIN_CREDITOR: + return notify({ + type: "error", + title: i18n.str`Bank administrator can't be the transfer creditor.`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + when: AbsoluteTime.now(), + }); + case TalerErrorCode.BANK_UNKNOWN_CREDITOR: + return notify({ + type: "error", + title: i18n.str`The destination account "${ + acName ?? puri + }" was not found.`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + when: AbsoluteTime.now(), + }); + case TalerErrorCode.BANK_SAME_ACCOUNT: + return notify({ + type: "error", + title: i18n.str`The origin and the destination of the transfer can't be the same.`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + when: AbsoluteTime.now(), + }); + case TalerErrorCode.BANK_UNALLOWED_DEBIT: + return notify({ + type: "error", + title: i18n.str`Your balance is not enough.`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + when: AbsoluteTime.now(), + }); + case HttpStatusCode.NotFound: + return notify({ + type: "error", + title: i18n.str`The origin account "${puri}" was not found.`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + when: AbsoluteTime.now(), + }); + case TalerErrorCode.BANK_TRANSFER_REQUEST_UID_REUSED: { + return notify({ + type: "error", + title: i18n.str`Tried to create the transaction ${check.maxTries} times with different UID but failed.`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + when: AbsoluteTime.now(), + }); + } + case HttpStatusCode.Accepted: { + updateBankState("currentChallenge", { + operation: "create-transaction", + id: String(resp.body.challenge_id), + location: routeHere.url({ + account: account ?? "", + amount, + subject, + }), + sent: AbsoluteTime.never(), + request, + }); + return onAuthorizationRequired(); + } + default: + assertUnreachable(resp); + } + } + notifyInfo(i18n.str`Wire transfer created!`); + onSuccess(); + setAmount(undefined); + setAccount(undefined); + setSubject(undefined); + rawPaytoInputSetter(undefined); + }); + } + + return ( + <div class="grid grid-cols-1 gap-x-8 gap-y-8 pt-6 my-4 md:grid-cols-3 bg-gray-100 px-4 pb-4 rounded-lg"> + <div> + <fieldset class="px-2 grid grid-cols-1 gap-y-4 sm:gap-x-4"> + <legend class="sr-only"> + <i18n.Translate>Input wire transfer detail</i18n.Translate> + </legend> + <div class="-space-y-px rounded-md "> + <label + data-checked={inputType === "form"} + class="group rounded-tl-md rounded-tr-md relative flex cursor-pointer border p-4 focus:outline-none bg-white data-[checked=true]:z-10 data-[checked=true]:border-indigo-200 data-[checked=true]:bg-indigo-50" + > + <input + type="radio" + name="input-type" + onChange={() => { + if (parsed && parsed.isKnown) { + switch (parsed.targetType) { + case "iban": { + setAccount(parsed.iban); + break; + } + case "x-taler-bank": { + setAccount(parsed.account); + break; + } + case "bitcoin": { + break; + } + default: { + assertUnreachable(parsed); + } + } + const amountStr = !parsed.params + ? undefined + : parsed.params["amount"]; + if (amountStr) { + const amount = Amounts.parse(amountStr); + if (amount) { + setAmount(Amounts.stringifyValue(amount)); + } + } + const subject = parsed.params["message"]; + if (subject) { + setSubject(subject); + } + } + setInputType("form"); + }} + checked={inputType === "form"} + value="form" + class="mt-0.5 h-4 w-4 shrink-0 cursor-pointer text-indigo-600 border-gray-300 focus:ring-indigo-600 active:ring-2 active:ring-offset-2 active:ring-indigo-600" + /> + <span class="ml-3 flex flex-col"> + {/* <!-- Checked: "text-indigo-900", Not Checked: "text-gray-900" --> */} + <span + data-checked={inputType === "form"} + class="block text-sm font-medium data-[checked=true]:text-indigo-900" + > + <i18n.Translate>Using a form</i18n.Translate> + </span> + </span> + </label> + {sendingToFixedAccount ? undefined : ( + <Fragment> + <label + data-checked={inputType === "payto"} + class="relative flex cursor-pointer border p-4 focus:outline-none bg-white data-[checked=true]:z-10 data-[checked=true]:border-indigo-200 data-[checked=true]:bg-indigo-50" + > + <input + type="radio" + name="input-type" + onChange={() => { + if (account) { + let payto; + switch (paytoType) { + case "x-taler-bank": { + payto = buildPayto( + "x-taler-bank", + url.host, + account, + ); + if (parsedAmount) { + payto.params["amount"] = + Amounts.stringify(parsedAmount); + } + if (subject) { + payto.params["message"] = subject; + } + break; + } + case "iban": { + payto = buildPayto("iban", account, undefined); + if (parsedAmount) { + payto.params["amount"] = + Amounts.stringify(parsedAmount); + } + if (subject) { + payto.params["message"] = subject; + } + break; + } + default: + assertUnreachable(paytoType); + } + rawPaytoInputSetter(stringifyPaytoUri(payto)); + } + setInputType("payto"); + }} + checked={inputType === "payto"} + value="payto" + class="mt-0.5 h-4 w-4 shrink-0 cursor-pointer text-indigo-600 border-gray-300 focus:ring-indigo-600 active:ring-2 active:ring-offset-2 active:ring-indigo-600" + /> + <span class="ml-3 flex flex-col"> + <span + data-checked={inputType === "payto"} + class="block font-medium data-[checked=true]:text-indigo-900" + > + payto:// URI + </span> + <span + data-checked={inputType === "payto"} + class="block text-sm text-gray-500 data-[checked=true]:text-indigo-600" + > + <i18n.Translate> + A special URI that indicate the transfer amount and + account target. + </i18n.Translate> + </span> + </span> + </label> + { + //FIXME: add QR support + false && ( + <label + data-checked={inputType === "qr"} + class="rounded-bl-md rounded-br-md relative flex cursor-pointer border p-4 focus:outline-none bg-white data-[checked=true]:z-10 data-[checked=true]:border-indigo-200 data-[checked=true]:bg-indigo-50" + > + <input + type="radio" + name="input-type" + onChange={() => { + setInputType("qr"); + }} + checked={inputType === "qr"} + value="qr" + class="mt-0.5 h-4 w-4 shrink-0 cursor-pointer text-indigo-600 border-gray-300 focus:ring-indigo-600 active:ring-2 active:ring-offset-2 active:ring-indigo-600" + /> + <span class="ml-3 flex flex-col"> + <span + data-checked={inputType === "qr"} + class="block font-medium data-[checked=true]:text-indigo-900" + > + <i18n.Translate>QR code</i18n.Translate> + </span> + <span + data-checked={inputType === "qr"} + class="block text-sm text-gray-500 data-[checked=true]:text-indigo-600" + > + <i18n.Translate> + If you have a camera in this device you can import a + payto:// URI from a QR code. + </i18n.Translate> + </span> + </span> + </label> + ) + } + </Fragment> + )} + </div> + {routeCashout ? ( + <a + name="do cashout" + href={routeCashout.url({})} + class="bg-white p-4 rounded-lg text-sm font-semibold leading-6 text-gray-900" + > + <i18n.Translate>Cashout</i18n.Translate> + </a> + ) : undefined} + </fieldset> + </div> + + <form + class="bg-white shadow-sm ring-1 ring-gray-900/5 rounded-md sm:rounded-xl md:col-span-2 w-fit mx-auto" + autoCapitalize="none" + autoCorrect="off" + onSubmit={(e) => { + e.preventDefault(); + }} + > + <div class="p-4 sm:p-8"> + {!isRawPayto ? ( + <div class="grid max-w-xs grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6"> + {(() => { + switch (paytoType) { + case "x-taler-bank": { + return ( + <TextField + id="x-taler-bank" + required + label={i18n.str`Recipient`} + help={i18n.str`Id of the recipient's account`} + error={errorsWire?.account} + onChange={setAccount} + value={account} + placeholder={i18n.str`username`} + focus={focus} + disabled={sendingToFixedAccount} + /> + ); + } + case "iban": { + return ( + <TextField + id="iban" + required + label={i18n.str`Recipient`} + help={i18n.str`IBAN of the recipient's account`} + placeholder={"CC0123456789" as TranslatedString} + error={errorsWire?.account} + onChange={(v) => setAccount(v.toUpperCase())} + value={account} + focus={focus} + disabled={sendingToFixedAccount} + /> + ); + } + default: + assertUnreachable(paytoType); + } + })()} + + <div class="sm:col-span-5"> + <label + for="subject" + class="block text-sm font-medium leading-6 text-gray-900" + > + {i18n.str`Transfer subject`} + <b style={{ color: "red" }}> *</b> + </label> + <div class="mt-2"> + <input + type="text" + class="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" + name="subject" + id="subject" + autocomplete="off" + placeholder={i18n.str`Subject`} + value={subject ?? ""} + required + onInput={(e): void => { + setSubject(e.currentTarget.value); + }} + /> + <ShowInputErrorLabel + message={errorsWire?.subject} + isDirty={subject !== undefined} + /> + </div> + <p class="mt-2 text-sm text-gray-500"> + <i18n.Translate> + Some text to identify the transfer + </i18n.Translate> + </p> + </div> + + <div class="sm:col-span-5"> + <label + for="amount" + class="block text-sm font-medium leading-6 text-gray-900" + > + {i18n.str`Amount`} + <b style={{ color: "red" }}> *</b> + </label> + <InputAmount + name="amount" + left + currency={limit.currency} + value={trimmedAmountStr} + onChange={(d) => { + setAmount(d); + }} + /> + <ShowInputErrorLabel + message={errorsWire?.amount} + isDirty={trimmedAmountStr !== undefined} + /> + <p class="mt-2 text-sm text-gray-500"> + <i18n.Translate>Amount to transfer</i18n.Translate> + </p> + </div> + </div> + ) : ( + <div class="grid max-w-2xl grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6 w-full"> + <div class="sm:col-span-6"> + <label + for="address" + class="block text-sm font-medium leading-6 text-gray-900" + > + {i18n.str`Payto URI:`} + <b style={{ color: "red" }}> *</b> + </label> + <div class="mt-2"> + <textarea + ref={focus ? doAutoFocus : undefined} + name="address" + id="address" + type="textarea" + rows={5} + class="block overflow-hidden w-44 sm:w-96 rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" + value={rawPaytoInput ?? ""} + required + title={i18n.str`Uniform resource identifier of the target account`} + placeholder={((): TranslatedString => { + switch (paytoType) { + case "x-taler-bank": + return i18n.str`payto://x-taler-bank/[bank-host]/[receiver-account]?message=[subject]&amount=[${limit.currency}:X.Y]`; + case "iban": + return i18n.str`payto://iban/[receiver-iban]?message=[subject]&amount=[${limit.currency}:X.Y]`; + } + })()} + onInput={(e): void => { + rawPaytoInputSetter(e.currentTarget.value); + }} + /> + <ShowInputErrorLabel + message={errorsPayto?.rawPaytoInput} + isDirty={rawPaytoInput !== undefined} + /> + </div> + </div> + </div> + )} + </div> + <div class="flex items-center justify-between gap-x-6 border-t border-gray-900/10 px-4 py-4 sm:px-8"> + {routeCancel ? ( + <a + name="cancel" + href={routeCancel.url({})} + class="text-sm font-semibold leading-6 text-gray-900" + > + <i18n.Translate>Cancel</i18n.Translate> + </a> + ) : ( + <div /> + )} + <button + type="submit" + name="send" + class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" + disabled={isRawPayto ? !!errorsPayto : !!errorsWire} + onClick={(e) => { + e.preventDefault(); + doSend(); + }} + > + <i18n.Translate>Send</i18n.Translate> + </button> + </div> + <LocalNotificationBanner notification={notification} /> + </form> + </div> + ); +} + +/** + * Show the element when the load ended + * @param element + */ +export function doAutoFocus(element: HTMLElement | null) { + if (element) { + setTimeout(() => { + element.focus({ preventScroll: true }); + element.scrollIntoView({ + behavior: "smooth", + block: "center", + inline: "center", + }); + }, 100); + } +} + +export function InputAmount( + { + currency, + name, + value, + error, + left, + onChange, + }: { + error?: string; + currency: string; + name: string; + left?: boolean | undefined; + value: string | undefined; + onChange?: (s: string) => void; + }, + ref: Ref<HTMLInputElement>, +): VNode { + 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"> + <div class="pointer-events-none inset-y-0 flex items-center px-3"> + <span class="text-gray-500 sm:text-sm">{currency}</span> + </div> + <input + type="number" + data-left={left} + class="disabled:bg-gray-200 text-right rounded-md rounded-l-none data-[left=true]:text-left w-full py-1.5 pl-3 text-gray-900 placeholder:text-gray-400 sm:text-sm sm:leading-6" + placeholder="0.00" + aria-describedby="price-currency" + ref={ref} + name={name} + id={name} + autocomplete="off" + value={value ?? ""} + disabled={!onChange} + onInput={(e) => { + 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 > + config.currency_specification.num_fractional_input_digits + ) { + e.currentTarget.value = e.currentTarget.value.substring( + 0, + sep_pos + + config.currency_specification.num_fractional_input_digits + + 1, + ); + } + onChange(e.currentTarget.value); + }} + /> + </div> + <ShowInputErrorLabel message={error} isDirty={value !== undefined} /> + </div> + ); +} + +export function RenderAmount({ + value, + spec, + negative, + withColor, + hideSmall, +}: { + spec: CurrencySpecification; + value: AmountJson; + hideSmall?: boolean; + negative?: boolean; + withColor?: boolean; +}): VNode { + const neg = !!negative; // convert to true or false + + const { currency, normal, small } = Amounts.stringifyValueWithSpec( + value, + spec, + ); + + return ( + <span + data-negative={withColor ? neg : undefined} + class="whitespace-nowrap data-[negative=false]:text-green-600 data-[negative=true]:text-red-600" + > + {negative ? "- " : undefined} + {currency} {normal}{" "} + {!hideSmall && small && <sup class="-ml-1">{small}</sup>} + </span> + ); +} + +function validateRawPayto( + parsed: PaytoUri, + limit: AmountJson, + host: string, + i18n: InternationalizationAPI, + type: "iban" | "x-taler-bank", +): TranslatedString | undefined { + if (!parsed.isKnown) { + return i18n.str`The target type is unknown, use "${type}"`; + } + let result: TranslatedString | undefined; + switch (type) { + case "x-taler-bank": { + if (parsed.targetType !== "x-taler-bank") { + return i18n.str`Only "x-taler-bank" target are supported`; + } + + if (parsed.host !== host) { + return i18n.str`Only this host is allowed. Use "${host}"`; + } + + if (!parsed.account) { + return i18n.str`Missing account name`; + } + const result = validateTalerBank(parsed.account, i18n); + if (result) return result; + break; + } + case "iban": { + if (parsed.targetType !== "iban") { + return i18n.str`Only "IBAN" target are supported`; + } + const result = validateIBAN(parsed.iban, i18n); + if (result) return result; + break; + } + default: + assertUnreachable(type); + } + if (!parsed.params.amount) { + return i18n.str`Missing "amount" parameter to specify the amount to be transferred`; + } + const amount = Amounts.parse(parsed.params.amount); + if (!amount) { + return i18n.str`The "amount" parameter is not valid`; + } + result = validateAmount(amount, limit, i18n); + if (result) return result; + + if (!parsed.params.message) { + return i18n.str`Missing the "message" parameter to specify a reference text for the transfer`; + } + const subject = parsed.params.message; + result = validateSubject(subject, i18n); + if (result) return result; + + return undefined; +} + +function validateAmount( + amount: AmountJson, + limit: AmountJson, + i18n: InternationalizationAPI, +): TranslatedString | undefined { + if (amount.currency !== limit.currency) { + return i18n.str`The only currency allowed is "${limit.currency}"`; + } + if (Amounts.isZero(amount)) { + return i18n.str`Can't transfer zero amount`; + } + if (Amounts.cmp(limit, amount) === -1) { + return i18n.str`Balance is not enough`; + } + return undefined; +} + +function validateSubject( + text: string, + i18n: InternationalizationAPI, +): TranslatedString | undefined { + if (text.length < 2) { + return i18n.str`Use a longer subject`; + } + return undefined; +} + +interface PaytoFieldProps { + id: string; + label: TranslatedString; + required?: boolean; + help?: TranslatedString; + placeholder?: TranslatedString; + error: string | undefined; + value: string | undefined; + rightIcons?: VNode; + onChange: (p: string) => void; + focus?: boolean; + disabled?: boolean; +} + +function Wrapper({ + withIcon, + children, +}: { + withIcon: boolean; + children: ComponentChildren; +}): VNode { + if (withIcon) { + return <div class="flex justify-between">{children}</div>; + } + return <Fragment>{children}</Fragment>; +} + +export function TextField({ + id, + label, + help, + focus, + disabled, + onChange, + placeholder, + rightIcons, + required, + value, + error, +}: PaytoFieldProps): VNode { + return ( + <div class="sm:col-span-5"> + <label for={id} class="block text-sm font-medium leading-6 text-gray-900"> + {label} + {required && <b style={{ color: "red" }}> *</b>} + </label> + <div class="mt-2"> + <Wrapper withIcon={rightIcons !== undefined}> + <input + ref={focus ? doAutoFocus : undefined} + type="text" + class="block w-full disabled:bg-gray-200 rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" + name={id} + id={id} + disabled={disabled} + value={value ?? ""} + placeholder={placeholder} + autocomplete="off" + required + onInput={(e): void => { + onChange(e.currentTarget.value); + }} + /> + {rightIcons} + </Wrapper> + <ShowInputErrorLabel message={error} isDirty={value !== undefined} /> + </div> + {help && <p class="mt-2 text-sm text-gray-500">{help}</p>} + </div> + ); +} diff --git a/packages/bank-ui/src/pages/ProfileNavigation.tsx b/packages/bank-ui/src/pages/ProfileNavigation.tsx new file mode 100644 index 000000000..3e81e307c --- /dev/null +++ b/packages/bank-ui/src/pages/ProfileNavigation.tsx @@ -0,0 +1,202 @@ +/* + This file is part of GNU Taler + (C) 2022-2024 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ +import { assertUnreachable } from "@gnu-taler/taler-util"; +import { + useNavigationContext, + useTranslationContext, +} from "@gnu-taler/web-util/browser"; +import { Fragment, VNode, h } from "preact"; +import { useBankCoreApiContext } from "@gnu-taler/web-util/browser"; +import { useSessionState } from "../hooks/session.js"; +import { RouteDefinition } from "@gnu-taler/web-util/browser"; + +export function ProfileNavigation({ + current, + routeMyAccountCashout, + routeMyAccountDelete, + routeMyAccountDetails, + routeMyAccountPassword, + routeConversionConfig, +}: { + current: "details" | "delete" | "credentials" | "cashouts" | "conversion"; + routeMyAccountDetails: RouteDefinition; + routeMyAccountDelete: RouteDefinition; + routeMyAccountPassword: RouteDefinition; + routeMyAccountCashout: RouteDefinition; + routeConversionConfig: RouteDefinition; +}): VNode { + const { i18n } = useTranslationContext(); + const { config } = useBankCoreApiContext(); + const { state: credentials } = useSessionState(); + const isAdminUser = + credentials.status !== "loggedIn" ? false : credentials.isUserAdministrator; + const nonAdminUser = !isAdminUser; + + const { navigateTo } = useNavigationContext(); + return ( + <div> + <div class="sm:hidden"> + <label for="tabs" class="sr-only"> + <i18n.Translate>Select a section</i18n.Translate> + </label> + <select + id="tabs" + name="tabs" + class="block w-full rounded-md border-gray-300 focus:border-indigo-500 focus:ring-indigo-500" + onChange={(e) => { + const op = e.currentTarget.value as typeof current; + switch (op) { + case "details": { + navigateTo(routeMyAccountDetails.url({})); + return; + } + case "delete": { + navigateTo(routeMyAccountDelete.url({})); + return; + } + case "credentials": { + navigateTo(routeMyAccountPassword.url({})); + return; + } + case "cashouts": { + navigateTo(routeMyAccountCashout.url({})); + return; + } + case "conversion": { + navigateTo(routeConversionConfig.url({})); + return; + } + default: + assertUnreachable(op); + } + }} + > + <option value="details" selected={current == "details"}> + <i18n.Translate>Details</i18n.Translate> + </option> + {!config.allow_deletions ? undefined : ( + <option value="delete" selected={current == "delete"}> + <i18n.Translate>Delete</i18n.Translate> + </option> + )} + <option value="credentials" selected={current == "credentials"}> + <i18n.Translate>Credentials</i18n.Translate> + </option> + {config.allow_conversion ? ( + <Fragment> + <option value="cashouts" selected={current == "cashouts"}> + <i18n.Translate>Cashouts</i18n.Translate> + </option> + <option value="conversion" selected={current == "cashouts"}> + <i18n.Translate>Conversion</i18n.Translate> + </option> + </Fragment> + ) : undefined} + </select> + </div> + <div class="hidden sm:block"> + <nav + class="isolate flex divide-x divide-gray-200 rounded-lg shadow" + aria-label="Tabs" + > + <a + name="my account details" + href={routeMyAccountDetails.url({})} + data-selected={current == "details"} + class="rounded-l-lg text-gray-500 hover:text-gray-700 data-[selected=true]:text-gray-900 group relative min-w-0 flex-1 overflow-hidden bg-white py-4 px-4 text-center text-sm font-medium hover:bg-gray-50 focus:z-10" + > + <span> + <i18n.Translate>Details</i18n.Translate> + </span> + <span + aria-hidden="true" + data-selected={current == "details"} + class="bg-transparent data-[selected=true]:bg-indigo-500 absolute inset-x-0 bottom-0 h-0.5" + ></span> + </a> + {!config.allow_deletions ? undefined : ( + <a + name="my account delete" + href={routeMyAccountDelete.url({})} + data-selected={current == "delete"} + aria-current="page" + class=" text-gray-500 hover:text-gray-700 data-[selected=true]:text-gray-900 group relative min-w-0 flex-1 overflow-hidden bg-white py-4 px-4 text-center text-sm font-medium hover:bg-gray-50 focus:z-10" + > + <span> + <i18n.Translate>Delete</i18n.Translate> + </span> + <span + aria-hidden="true" + data-selected={current == "delete"} + class="bg-transparent data-[selected=true]:bg-indigo-500 absolute inset-x-0 bottom-0 h-0.5" + ></span> + </a> + )} + <a + name="my account password" + href={routeMyAccountPassword.url({})} + data-selected={current == "credentials"} + aria-current="page" + class=" text-gray-500 hover:text-gray-700 data-[selected=true]:text-gray-900 group relative min-w-0 flex-1 overflow-hidden bg-white py-4 px-4 text-center text-sm font-medium hover:bg-gray-50 focus:z-10" + > + <span> + <i18n.Translate>Credentials</i18n.Translate> + </span> + <span + aria-hidden="true" + data-selected={current == "credentials"} + class="bg-transparent data-[selected=true]:bg-indigo-500 absolute inset-x-0 bottom-0 h-0.5" + ></span> + </a> + {config.allow_conversion && nonAdminUser ? ( + <a + name="my account cashout" + href={routeMyAccountCashout.url({})} + data-selected={current == "cashouts"} + class="rounded-r-lg text-gray-500 hover:text-gray-700 data-[selected=true]:text-gray-900 group relative min-w-0 flex-1 overflow-hidden bg-white py-4 px-4 text-center text-sm font-medium hover:bg-gray-50 focus:z-10" + > + <span> + <i18n.Translate>Cashouts</i18n.Translate> + </span> + <span + aria-hidden="true" + data-selected={current == "cashouts"} + class="bg-transparent data-[selected=true]:bg-indigo-500 absolute inset-x-0 bottom-0 h-0.5" + ></span> + </a> + ) : undefined} + {config.allow_conversion && isAdminUser ? ( + <a + name="conversion config" + href={routeConversionConfig.url({})} + data-selected={current == "conversion"} + class="rounded-r-lg text-gray-500 hover:text-gray-700 data-[selected=true]:text-gray-900 group relative min-w-0 flex-1 overflow-hidden bg-white py-4 px-4 text-center text-sm font-medium hover:bg-gray-50 focus:z-10" + > + <span> + <i18n.Translate>Conversion</i18n.Translate> + </span> + <span + aria-hidden="true" + data-selected={current == "conversion"} + class="bg-transparent data-[selected=true]:bg-indigo-500 absolute inset-x-0 bottom-0 h-0.5" + ></span> + </a> + ) : undefined} + </nav> + </div> + </div> + ); +} diff --git a/packages/bank-ui/src/pages/PublicHistoriesPage.tsx b/packages/bank-ui/src/pages/PublicHistoriesPage.tsx new file mode 100644 index 000000000..80ae28dde --- /dev/null +++ b/packages/bank-ui/src/pages/PublicHistoriesPage.tsx @@ -0,0 +1,98 @@ +/* + This file is part of GNU Taler + (C) 2022-2024 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +import { TalerError } from "@gnu-taler/taler-util"; +import { Loading, useTranslationContext } from "@gnu-taler/web-util/browser"; +import { Fragment, VNode, h } from "preact"; +import { useState } from "preact/hooks"; +import { Transactions } from "../components/Transactions/index.js"; +import { usePublicAccounts } from "../hooks/account.js"; + +/** + * Show histories of public accounts. + */ +export function PublicHistoriesPage(): VNode { + const { i18n } = useTranslationContext(); + + // TODO: implemented filter by account name + const result = usePublicAccounts(undefined); + const firstAccount = + result && !(result instanceof TalerError) && result.body.length > 0 + ? result.body[0].username + : undefined; + + const [showAccount, setShowAccount] = useState(firstAccount); + + if (!result) { + return <Loading />; + } + if (result instanceof TalerError) { + return <Loading />; + } + + const { body: accountList } = result; + + const txs: Record<string, h.JSX.Element> = {}; + const accountsBar = []; + + // Ask story of all the public accounts. + for (const account of accountList) { + const isSelected = account.username == showAccount; + accountsBar.push( + <li + class={ + isSelected + ? "pure-menu-selected pure-menu-item" + : "pure-menu-item pure-menu" + } + > + <a + href="#" + name={`show account ${account.username}`} + class="pure-menu-link" + onClick={() => setShowAccount(account.username)} + > + {account.username} + </a> + </li>, + ); + txs[account.username] = ( + <Transactions + account={account.username} + routeCreateWireTransfer={undefined} + /> + ); + } + + return ( + <Fragment> + <h1 class="nav">{i18n.str`History of public accounts`}</h1> + <section id="main"> + <article> + <div class="pure-menu pure-menu-horizontal" name="accountMenu"> + <ul class="pure-menu-list">{accountsBar}</ul> + {typeof showAccount !== "undefined" ? ( + txs[showAccount] + ) : ( + <p>No public transactions found.</p> + )} + <br /> + </div> + </article> + </section> + </Fragment> + ); +} diff --git a/packages/bank-ui/src/pages/QrCodeSection.stories.tsx b/packages/bank-ui/src/pages/QrCodeSection.stories.tsx new file mode 100644 index 000000000..d53d2e7b4 --- /dev/null +++ b/packages/bank-ui/src/pages/QrCodeSection.stories.tsx @@ -0,0 +1,32 @@ +/* + This file is part of GNU Taler + (C) 2022-2024 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ + +import * as tests from "@gnu-taler/web-util/testing"; +import { QrCodeSection } from "./QrCodeSection.js"; +import { parseWithdrawUri } from "@gnu-taler/taler-util"; + +export default { + title: "Qr Code Selection", +}; + +export const SimpleExample = tests.createExample(QrCodeSection, { + withdrawUri: parseWithdrawUri("taler://withdraw/bank.com/operationId"), +}); diff --git a/packages/bank-ui/src/pages/QrCodeSection.tsx b/packages/bank-ui/src/pages/QrCodeSection.tsx new file mode 100644 index 000000000..359d4c18f --- /dev/null +++ b/packages/bank-ui/src/pages/QrCodeSection.tsx @@ -0,0 +1,152 @@ +/* + This file is part of GNU Taler + (C) 2022-2024 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +import { + HttpStatusCode, + stringifyWithdrawUri, + WithdrawUriResult, +} from "@gnu-taler/taler-util"; +import { + Button, + LocalNotificationBanner, + useLocalNotificationHandler, + useTalerWalletIntegrationAPI, + useTranslationContext, +} from "@gnu-taler/web-util/browser"; +import { Fragment, h, VNode } from "preact"; +import { useEffect } from "preact/hooks"; +import { QR } from "../components/QR.js"; +import { useBankCoreApiContext } from "@gnu-taler/web-util/browser"; +import { useSessionState } from "../hooks/session.js"; + +export function QrCodeSection({ + withdrawUri, + onAborted, +}: { + withdrawUri: WithdrawUriResult; + onAborted: () => void; +}): VNode { + const { i18n } = useTranslationContext(); + const walletInegrationApi = useTalerWalletIntegrationAPI(); + const talerWithdrawUri = stringifyWithdrawUri(withdrawUri); + const { state: credentials } = useSessionState(); + const creds = credentials.status !== "loggedIn" ? undefined : credentials; + + useEffect(() => { + walletInegrationApi.publishTalerAction(withdrawUri); + }, []); + + const [notification, handleError] = useLocalNotificationHandler(); + + const { + lib: { bank: api }, + } = useBankCoreApiContext(); + + const onAbortHandler = handleError( + async () => { + if (!creds) return undefined; + return api.abortWithdrawalById(creds, withdrawUri.withdrawalOperationId); + }, + onAborted, + (fail) => { + switch (fail.case) { + case HttpStatusCode.BadRequest: + return i18n.str`The operation id is invalid.`; + case HttpStatusCode.NotFound: + return i18n.str`The operation was not found.`; + case HttpStatusCode.Conflict: + return i18n.str`The reserve operation has been confirmed previously and can't be aborted`; + } + }, + ); + + return ( + <Fragment> + <LocalNotificationBanner notification={notification} /> + <div class="bg-white shadow-xl sm:rounded-lg"> + <div class="px-4 py-5 sm:p-6"> + <h3 class="text-base font-semibold leading-6 text-gray-900"> + <i18n.Translate> + If you have a Taler wallet installed in this device + </i18n.Translate> + </h3> + <div class="mt-4 mb-4 text-sm text-gray-500"> + <p> + <i18n.Translate> + You will see the details of the operation in your wallet + including the fees (if applies). If you still don't have one you + can install it following instructions in + </i18n.Translate>{" "} + <a + class="font-semibold text-gray-500 hover:text-gray-400" + name="wallet page" + href="https://taler.net/en/wallet.html" + > + <i18n.Translate>this page</i18n.Translate> + </a> + . + </p> + </div> + <div class="flex items-center justify-between gap-x-6 border-t border-gray-900/10 pt-2 mt-2 "> + <Button + type="button" + name="cancel" + class="text-sm font-semibold leading-6 text-gray-900" + handler={onAbortHandler} + > + <i18n.Translate>Cancel</i18n.Translate> + </Button> + <a + href={talerWithdrawUri} + name="withdraw" + class="inline-flex items-center disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" + > + <i18n.Translate>Withdraw</i18n.Translate> + </a> + </div> + </div> + </div> + + <div class="bg-white shadow-xl sm:rounded-lg mt-8"> + <div class="px-4 py-5 sm:p-6"> + <h3 class="text-base font-semibold leading-6 text-gray-900"> + <i18n.Translate> + Or if you have the Taler wallet in another device + </i18n.Translate> + </h3> + <div class="mt-4 max-w-xl text-sm text-gray-500"> + <i18n.Translate> + Scan the QR below to start the withdrawal. + </i18n.Translate> + </div> + <div class="mt-2 max-w-md ml-auto mr-auto"> + <QR text={talerWithdrawUri} /> + </div> + </div> + <div class="flex items-center justify-center gap-x-6 border-t border-gray-900/10 px-4 py-4 sm:px-8"> + <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" + handler={onAbortHandler} + > + <i18n.Translate>Cancel</i18n.Translate> + </Button> + </div> + </div> + </Fragment> + ); +} diff --git a/packages/bank-ui/src/pages/RegistrationPage.tsx b/packages/bank-ui/src/pages/RegistrationPage.tsx new file mode 100644 index 000000000..61939c3d6 --- /dev/null +++ b/packages/bank-ui/src/pages/RegistrationPage.tsx @@ -0,0 +1,423 @@ +/* + This file is part of GNU Taler + (C) 2022-2024 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ +import { HttpStatusCode, TalerErrorCode } from "@gnu-taler/taler-util"; +import { + LocalNotificationBanner, + RouteDefinition, + ShowInputErrorLabel, + useBankCoreApiContext, + useLocalNotification, + useTranslationContext, +} from "@gnu-taler/web-util/browser"; +import { Fragment, VNode, h } from "preact"; +import { useState } from "preact/hooks"; +import { useSettingsContext } from "../context/settings.js"; +import { undefinedIfEmpty } from "../utils.js"; +import { getRandomPassword, getRandomUsername } from "./rnd.js"; + +export function RegistrationPage({ + onRegistrationSuccesful, + routeCancel, +}: { + onRegistrationSuccesful: (user: string, password: string) => void; + routeCancel: RouteDefinition; +}): VNode { + const { i18n } = useTranslationContext(); + const { config } = useBankCoreApiContext(); + if (!config.allow_registrations) { + return ( + <p>{i18n.str`Currently, the bank is not accepting new registrations!`}</p> + ); + } + return ( + <RegistrationForm + onRegistrationSuccesful={onRegistrationSuccesful} + routeCancel={routeCancel} + /> + ); +} + +// eslint-disable-next-line no-useless-escape +export const USERNAME_REGEX = /^[a-zA-Z0-9\-\.\_\~]*$/; +export const PHONE_REGEX = /^(\+\d{1,2}\s)?\(?\d{3}\)?[\s.-]\d{3}[\s.-]\d{4}$/; +export const EMAIL_REGEX = /^[\w-.]+@([\w-]+\.)+[\w-]{2,4}$/; + +/** + * Collect and submit registration data. + */ +function RegistrationForm({ + onRegistrationSuccesful, + routeCancel, +}: { + onRegistrationSuccesful: (user: string, password: string) => void; + routeCancel: RouteDefinition; +}): VNode { + const [username, setUsername] = useState<string | undefined>(); + const [name, setName] = useState<string | undefined>(); + const [password, setPassword] = useState<string | undefined>(); + // const [phone, setPhone] = useState<string | undefined>(); + // const [email, setEmail] = useState<string | undefined>(); + const [repeatPassword, setRepeatPassword] = useState<string | undefined>(); + const [notification, , handleError] = useLocalNotification(); + const settings = useSettingsContext(); + + const { + lib: { bank: api }, + } = useBankCoreApiContext(); + // const { register } = useTestingAPI(); + const { i18n } = useTranslationContext(); + + const errors = undefinedIfEmpty({ + name: !name ? i18n.str`Missing name` : undefined, + username: !username + ? i18n.str`Missing username` + : !USERNAME_REGEX.test(username) + ? i18n.str`Use letters, numbers or any of these characters: - . _ ~` + : undefined, + // phone: !phone + // ? undefined + // : !PHONE_REGEX.test(phone) + // ? i18n.str`Use letters and numbers only, and start with a lowercase letter` + // : undefined, + // email: !email + // ? undefined + // : !EMAIL_REGEX.test(email) + // ? i18n.str`Use letters and numbers only, and start with a lowercase letter` + // : undefined, + password: !password ? i18n.str`Missing password` : undefined, + repeatPassword: !repeatPassword + ? i18n.str`Missing password` + : repeatPassword !== password + ? i18n.str`Passwords don't match` + : undefined, + }); + + async function doRegistrationAndLogin( + name: string, + username: string, + password: string, + onComplete: () => void, + ) { + await handleError(async (onError) => { + const resp = await api.createAccount(undefined, { + name, + username, + password, + }); + if (resp.type === "ok") { + onComplete(); + } else { + onError(resp, (_case) => { + switch (_case) { + case HttpStatusCode.BadRequest: + return i18n.str`Server replied with invalid phone or email.`; + case HttpStatusCode.Unauthorized: + return i18n.str`No enough permission to create that account.`; + case TalerErrorCode.BANK_UNALLOWED_DEBIT: + return i18n.str`Registration is disabled because the bank ran out of bonus credit.`; + case TalerErrorCode.BANK_RESERVED_USERNAME_CONFLICT: + return i18n.str`That username can't be used because is reserved.`; + case TalerErrorCode.BANK_REGISTER_USERNAME_REUSE: + return i18n.str`That username is already taken.`; + case TalerErrorCode.BANK_REGISTER_PAYTO_URI_REUSE: + return i18n.str`That account id is already taken.`; + case TalerErrorCode.BANK_MISSING_TAN_INFO: + return i18n.str`No information for the selected authentication channel.`; + case TalerErrorCode.BANK_TAN_CHANNEL_NOT_SUPPORTED: + return i18n.str`Authentication channel is not supported.`; + case TalerErrorCode.BANK_NON_ADMIN_PATCH_DEBT_LIMIT: + return i18n.str`Only admin is allow to set debt limit.`; + case TalerErrorCode.BANK_NON_ADMIN_SET_MIN_CASHOUT: + return i18n.str`Only the administrator can change the minimum cashout limit.`; + case TalerErrorCode.BANK_NON_ADMIN_SET_TAN_CHANNEL: + return i18n.str`Only admin can create accounts with second factor authentication.`; + } + }); + } + }); + } + + async function doRegistrationStep() { + if (!username || !password || !name) return; + await doRegistrationAndLogin(name, username, password, () => { + setUsername(undefined); + setPassword(undefined); + setRepeatPassword(undefined); + onRegistrationSuccesful(username, password); + }); + } + + async function doRandomRegistration() { + const user = getRandomUsername(); + + const password = settings.simplePasswordForRandomAccounts + ? "123" + : getRandomPassword(); + const username = `_${user.first}-${user.second}_`; + const name = `${capitalizeFirstLetter(user.first)} ${capitalizeFirstLetter( + user.second, + )}`; + await doRegistrationAndLogin(name, username, password, () => { + onRegistrationSuccesful(username, password); + }); + } + + return ( + <Fragment> + <LocalNotificationBanner notification={notification} /> + + <div class="flex min-h-full flex-col justify-center"> + <div class="sm:mx-auto sm:w-full sm:max-w-sm"> + <h2 class="text-center text-2xl font-bold leading-9 tracking-tight text-gray-900">{i18n.str`Account registration`}</h2> + </div> + + <div class="mt-10 sm:mx-auto sm:w-full sm:max-w-sm"> + <form + class="space-y-6" + noValidate + onSubmit={(e) => { + e.preventDefault(); + }} + autoCapitalize="none" + autoCorrect="off" + > + <div> + <label + for="username" + class="block text-sm font-medium leading-6 text-gray-900" + > + <i18n.Translate>Login username</i18n.Translate> + <b style={{ color: "red" }}> *</b> + </label> + <div class="mt-2"> + <input + autoFocus + type="text" + name="username" + id="username" + class="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" + value={username ?? ""} + enterkeyhint="next" + placeholder="account identification to login" + autocomplete="username" + required + onInput={(e): void => { + setUsername(e.currentTarget.value); + }} + /> + <ShowInputErrorLabel + message={errors?.username} + isDirty={username !== undefined} + /> + </div> + </div> + + <div> + <div class="flex items-center justify-between"> + <label + for="password" + class="block text-sm font-medium leading-6 text-gray-900" + > + <i18n.Translate>Password</i18n.Translate> + <b style={{ color: "red" }}> *</b> + </label> + </div> + <div class="mt-2"> + <input + type="password" + name="password" + id="password" + autocomplete="current-password" + class="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" + enterkeyhint="send" + value={password ?? ""} + placeholder="Password" + required + onInput={(e): void => { + setPassword(e.currentTarget.value); + }} + /> + <ShowInputErrorLabel + message={errors?.password} + isDirty={password !== undefined} + /> + </div> + </div> + + <div> + <div class="flex items-center justify-between"> + <label + for="register-repeat" + class="block text-sm font-medium leading-6 text-gray-900" + > + <i18n.Translate>Repeat password</i18n.Translate> + <b style={{ color: "red" }}> *</b> + </label> + </div> + <div class="mt-2"> + <input + type="password" + name="register-repeat" + id="register-repeat" + autocomplete="current-password" + class="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" + enterkeyhint="send" + value={repeatPassword ?? ""} + placeholder="Same password" + required + onInput={(e): void => { + setRepeatPassword(e.currentTarget.value); + }} + /> + <ShowInputErrorLabel + message={errors?.repeatPassword} + isDirty={repeatPassword !== undefined} + /> + </div> + </div> + + <div> + <div class="flex items-center justify-between"> + <label + for="name" + class="block text-sm font-medium leading-6 text-gray-900" + > + <i18n.Translate>Full name</i18n.Translate> + <b style={{ color: "red" }}> *</b> + </label> + </div> + <div class="mt-2"> + <input + autoFocus + type="text" + name="name" + id="name" + class="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" + value={name ?? ""} + enterkeyhint="next" + placeholder="John Doe" + autocomplete="name" + required + onInput={(e): void => { + setName(e.currentTarget.value); + }} + /> + {/* <ShowInputErrorLabel + message={errors?.name} + isDirty={name !== undefined} + /> */} + </div> + </div> + + {/* <div> + <label for="phone" class="block text-sm font-medium leading-6 text-gray-900"> + <i18n.Translate>Phone</i18n.Translate> + </label> + <div class="mt-2"> + <input + autoFocus + type="text" + name="phone" + id="phone" + class="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" + value={phone ?? ""} + enterkeyhint="next" + placeholder="your phone" + autocomplete="none" + onInput={(e): void => { + setPhone(e.currentTarget.value); + }} + /> + <ShowInputErrorLabel + message={errors?.phone} + isDirty={phone !== undefined} + /> + </div> + </div> + <div> + <label for="email" class="block text-sm font-medium leading-6 text-gray-900"> + <i18n.Translate>Email</i18n.Translate> + </label> + <div class="mt-2"> + <input + autoFocus + type="text" + name="email" + id="email" + class="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" + value={email ?? ""} + enterkeyhint="next" + placeholder="your email" + autocomplete="email" + onInput={(e): void => { + setEmail(e.currentTarget.value); + }} + /> + <ShowInputErrorLabel + message={errors?.email} + isDirty={email !== undefined} + /> + </div> + </div> */} + + <div class="flex w-full justify-between"> + <a + name="cancel" + href={routeCancel.url({})} + class="ring-1 ring-gray-600 rounded-md bg-white disabled:bg-gray-300 px-3 py-1.5 text-sm font-semibold leading-6 text-black shadow-sm hover:bg-white-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2" + > + <i18n.Translate>Cancel</i18n.Translate> + </a> + <button + type="submit" + name="register" + class=" rounded-md bg-indigo-600 disabled:bg-gray-300 px-3 py-1.5 text-sm font-semibold leading-6 text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" + disabled={!!errors} + onClick={async (e) => { + e.preventDefault(); + + doRegistrationStep(); + }} + > + <i18n.Translate>Register</i18n.Translate> + </button> + </div> + </form> + + {settings.allowRandomAccountCreation && ( + <p class="mt-10 text-center text-sm text-gray-500 border-t"> + <button + type="submit" + name="create random" + class="flex mt-4 w-full justify-center rounded-md bg-green-600 px-3 py-1.5 text-sm font-semibold leading-6 text-white shadow-sm hover:bg-green-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-green-600" + onClick={(e) => { + e.preventDefault(); + doRandomRegistration(); + }} + > + <i18n.Translate>Create a random temporary user</i18n.Translate> + </button> + </p> + )} + </div> + </div> + </Fragment> + ); +} + +function capitalizeFirstLetter(str: string) { + return str.charAt(0).toUpperCase() + str.slice(1); +} diff --git a/packages/bank-ui/src/pages/ShowNotifications.tsx b/packages/bank-ui/src/pages/ShowNotifications.tsx new file mode 100644 index 000000000..fe041fb19 --- /dev/null +++ b/packages/bank-ui/src/pages/ShowNotifications.tsx @@ -0,0 +1,55 @@ +/* + This file is part of GNU Taler + (C) 2022-2024 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +import { useNotifications } from "@gnu-taler/web-util/browser"; +import { VNode, h } from "preact"; +import { Time } from "../components/Time.js"; + +export function ShowNotifications(): VNode { + const ns = useNotifications(); + if (!ns.length) { + return <div>no notifications</div>; + } + return ( + <div> + <p>Notifications</p> + <table> + <thead></thead> + <tbody> + {ns.map((n, idx) => { + return ( + <tr key={idx}> + <td> + <Time + timestamp={n.message.when} + format="dd/MM/yyyy HH:mm:ss" + /> + </td> + <td>{n.message.title}</td> + <td> + {n.message.type === "error" + ? n.message.description + : undefined} + </td> + </tr> + ); + })} + </tbody> + </table> + {/* <ToastBanner all /> */} + </div> + ); +} diff --git a/packages/bank-ui/src/pages/SolveChallengePage.tsx b/packages/bank-ui/src/pages/SolveChallengePage.tsx new file mode 100644 index 000000000..624890468 --- /dev/null +++ b/packages/bank-ui/src/pages/SolveChallengePage.tsx @@ -0,0 +1,793 @@ +/* + This file is part of GNU Taler + (C) 2022-2024 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +import { + AbsoluteTime, + Amounts, + Duration, + HttpStatusCode, + TalerCorebankApi, + TalerError, + TalerErrorCode, + TranslatedString, + assertUnreachable, + parsePaytoUri, +} from "@gnu-taler/taler-util"; +import { + Attention, + Loading, + LocalNotificationBanner, + ShowInputErrorLabel, + useLocalNotification, + useNavigationContext, + useTranslationContext, +} from "@gnu-taler/web-util/browser"; +import { Fragment, VNode, h } from "preact"; +import { useEffect, useState } from "preact/hooks"; +import { ErrorLoadingWithDebug } from "../components/ErrorLoadingWithDebug.js"; +import { Time } from "../components/Time.js"; +import { useBankCoreApiContext } from "@gnu-taler/web-util/browser"; +import { useWithdrawalDetails } from "../hooks/account.js"; +import { ChallengeInProgess, useBankState } from "../hooks/bank-state.js"; +import { useConversionInfo } from "../hooks/regional.js"; +import { useSessionState } from "../hooks/session.js"; +import { RouteDefinition } from "@gnu-taler/web-util/browser"; +import { undefinedIfEmpty } from "../utils.js"; +import { RenderAmount } from "./PaytoWireTransferForm.js"; +import { OperationNotFound } from "./WithdrawalQRCode.js"; +import { IdempotencyRetry } from "../../../taler-util/lib/http-client/utils.js"; + +const TAN_PREFIX = "T-"; +const TAN_REGEX = /^([Tt](-)?)?[0-9]*$/; +export function SolveChallengePage({ + onChallengeCompleted, + routeClose, +}: { + onChallengeCompleted: () => void; + routeClose: RouteDefinition; +}): VNode { + const { + lib: { bank: api }, + } = useBankCoreApiContext(); + const { i18n } = useTranslationContext(); + const [bankState, updateBankState] = useBankState(); + const [code, setCode] = useState<string | undefined>(undefined); + const [notification, notify, handleError] = useLocalNotification(); + const { state } = useSessionState(); + const creds = state.status !== "loggedIn" ? undefined : state; + const { navigateTo } = useNavigationContext(); + + if (!bankState.currentChallenge) { + return ( + <div> + <span>no challenge to solve </span> + <a + href={routeClose.url({})} + name="close" + class="inline-flex items-center rounded-md bg-red-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-500" + > + <i18n.Translate>Continue</i18n.Translate> + </a> + </div> + ); + } + + const ch = bankState.currentChallenge; + const errors = undefinedIfEmpty({ + code: !code + ? i18n.str`Required` + : !TAN_REGEX.test(code) + ? i18n.str`Confirmation codes are numerical, possibly beginning with 'T-.'` + : undefined, + }); + + async function startChallenge() { + if (!creds) return; + await handleError(async () => { + const resp = await api.sendChallenge(creds, ch.id); + if (resp.type === "ok") { + const newCh = structuredClone(ch); + newCh.sent = AbsoluteTime.now(); + newCh.info = resp.body; + updateBankState("currentChallenge", newCh); + } else { + const newCh = structuredClone(ch); + newCh.sent = AbsoluteTime.now(); + newCh.info = undefined; + updateBankState("currentChallenge", newCh); + switch (resp.case) { + case HttpStatusCode.NotFound: + 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, + when: AbsoluteTime.now(), + }); + case HttpStatusCode.Unauthorized: + 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, + when: AbsoluteTime.now(), + }); + case TalerErrorCode.BANK_TAN_CHANNEL_SCRIPT_FAILED: + 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, + when: AbsoluteTime.now(), + }); + default: + assertUnreachable(resp); + } + } + }); + } + + async function completeChallenge() { + if (!creds || !code) return; + const tan = code.toUpperCase().startsWith(TAN_PREFIX) + ? code.substring(TAN_PREFIX.length) + : code; + await handleError(async () => { + { + const resp = await api.confirmChallenge(creds, ch.id, { tan }); + if (resp.type === "fail") { + setCode(""); + switch (resp.case) { + case HttpStatusCode.NotFound: + return notify({ + type: "error", + title: i18n.str`Challenge not found.`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + when: AbsoluteTime.now(), + }); + case HttpStatusCode.Unauthorized: + return notify({ + type: "error", + title: i18n.str`This user is not authorized to complete this challenge.`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + when: AbsoluteTime.now(), + }); + case HttpStatusCode.TooManyRequests: + return notify({ + type: "error", + title: i18n.str`Too many attempts, try another code.`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + when: AbsoluteTime.now(), + }); + case TalerErrorCode.BANK_TAN_CHALLENGE_FAILED: + return notify({ + type: "error", + title: i18n.str`The confirmation code is wrong, try again.`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + when: AbsoluteTime.now(), + }); + case TalerErrorCode.BANK_TAN_CHALLENGE_EXPIRED: + return notify({ + type: "error", + title: i18n.str`The operation expired.`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + when: AbsoluteTime.now(), + }); + default: + assertUnreachable(resp); + } + } + } + { + const resp = await (async (ch: ChallengeInProgess) => { + switch (ch.operation) { + case "delete-account": + return await api.deleteAccount(creds, ch.id); + case "update-account": + return await api.updateAccount(creds, ch.request, ch.id); + case "update-password": + return await api.updatePassword(creds, ch.request, ch.id); + case "create-transaction": + return await api.createTransaction(creds, ch.request, undefined, ch.id); + case "confirm-withdrawal": + return await api.confirmWithdrawalById(creds, ch.request, ch.id); + case "create-cashout": + return await api.createCashout(creds, ch.request, ch.id); + default: + assertUnreachable(ch); + } + })(ch); + + if (resp.type === "fail") { + if (resp.case !== HttpStatusCode.Accepted) { + return notify({ + type: "error", + title: i18n.str`The operation failed.`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + when: AbsoluteTime.now(), + }); + } + // another challenge required, save the request and the ID + // @ts-expect-error no need to check the type of request, since it will be the same as the previous request + updateBankState("currentChallenge", { + operation: ch.operation, + id: String(resp.body.challenge_id), + location: ch.location, + sent: AbsoluteTime.never(), + request: ch.request, + }); + return notify({ + type: "info", + title: i18n.str`The operation needs another confirmation to complete.`, + when: AbsoluteTime.now(), + }); + } + updateBankState("currentChallenge", undefined); + return onChallengeCompleted(); + } + }); + } + + return ( + <Fragment> + <LocalNotificationBanner notification={notification} /> + <div class="grid grid-cols-1 gap-x-8 gap-y-8 pt-6 md:grid-cols-3 bg-gray-100 my-4 px-4 pb-4 rounded-lg"> + <div class="px-4 sm:px-0"> + <h2 class="text-base font-semibold leading-7 text-gray-900"> + <span + class="text-sm text-black font-semibold leading-6 " + id="availability-label" + > + <i18n.Translate>Confirm the operation</i18n.Translate> + </span> + </h2> + <p class="mt-2 text-sm text-gray-500"> + <i18n.Translate> + This operation is protected with second factor authentication. In + order to complete it we need to verify your identity using the + authentication channel you provided. + </i18n.Translate> + </p> + </div> + + <div class="bg-white shadow-sm ring-1 ring-gray-900/5 sm:rounded-xl md:col-span-2"> + <ChallengeDetails + challenge={bankState.currentChallenge} + onStart={startChallenge} + onCancel={() => { + updateBankState("currentChallenge", undefined); + navigateTo(ch.location); + }} + /> + {ch.info && ( + <div class="mt-2"> + <form + class="bg-white shadow-sm ring-1 ring-gray-900/5" + autoCapitalize="none" + autoCorrect="off" + onSubmit={(e) => { + e.preventDefault(); + }} + > + <div class="px-4 py-4"> + <label for="withdraw-amount"> + <i18n.Translate>Enter the confirmation code</i18n.Translate> + </label> + <div class="mt-2"> + <div class="relative rounded-md shadow-sm"> + <input + type="text" + // class="block w-full rounded-md border-0 py-1.5 pl-16 text-gray-900 ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" + aria-describedby="answer" + autoFocus + class="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" + value={code ?? ""} + required + onPaste={(e) => { + e.preventDefault(); + const pasted = e.clipboardData?.getData("text/plain"); + if (!pasted) return; + if (pasted.toUpperCase().startsWith(TAN_PREFIX)) { + const sub = pasted.substring(TAN_PREFIX.length); + setCode(sub); + return; + } + setCode(pasted); + }} + name="answer" + id="answer" + autocomplete="off" + onChange={(e): void => { + setCode(e.currentTarget.value); + }} + /> + </div> + <ShowInputErrorLabel + message={errors?.code} + isDirty={code !== undefined} + /> + </div> + <p class="mt-2 text-sm text-gray-500"> + {((ch: TalerCorebankApi.TanChannel): VNode => { + switch (ch) { + case TalerCorebankApi.TanChannel.SMS: + return ( + <i18n.Translate> + You should have received a code in your phone. + </i18n.Translate> + ); + case TalerCorebankApi.TanChannel.EMAIL: + return ( + <i18n.Translate> + You should have received a code in your email. + </i18n.Translate> + ); + default: + assertUnreachable(ch); + } + })(ch.info.tan_channel)} + </p> + <p class="mt-2 text-sm text-gray-500"> + <i18n.Translate> + The confirmation code starts with "{TAN_PREFIX}" followed + by numbers. + </i18n.Translate> + </p> + </div> + <div class="flex items-center justify-between border-gray-900/10 px-4 py-4 "> + <div /> + <button + type="submit" + name="confirm" + class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" + disabled={!!errors} + onClick={(e) => { + completeChallenge(); + e.preventDefault(); + }} + > + <i18n.Translate>Confirm</i18n.Translate> + </button> + </div> + </form> + </div> + )} + </div> + </div> + </Fragment> + ); +} + +function ChallengeDetails({ + challenge, + onStart, + onCancel, +}: { + challenge: ChallengeInProgess; + onStart: () => void; + onCancel: () => void; +}): VNode { + const { i18n } = useTranslationContext(); + const { config } = useBankCoreApiContext(); + + const firstTime = AbsoluteTime.isNever(challenge.sent); + useEffect(() => { + if (firstTime) { + onStart(); + } + }, []); + + const subtitle = ((op): TranslatedString => { + switch (op) { + case "delete-account": + return i18n.str`Removing account`; + case "update-account": + return i18n.str`Updating account values`; + case "update-password": + return i18n.str`Updating password`; + case "create-transaction": + return i18n.str`Making a wire transfer`; + case "confirm-withdrawal": + return i18n.str`Confirming withdrawal`; + case "create-cashout": + return i18n.str`Making a cashout`; + } + })(challenge.operation); + + return ( + <div class="px-4 mt-4 "> + <div class="w-full"> + <div class="border-gray-100"> + <h2 class="text-base font-semibold leading-10 text-gray-900"> + <span class=" text-black font-semibold leading-6 "> + <i18n.Translate>Operation:</i18n.Translate> + </span>{" "} + + <span class=" text-black font-normal leading-6 ">{subtitle}</span> + </h2> + <dl class="divide-y divide-gray-100"> + {((): VNode => { + switch (challenge.operation) { + case "delete-account": + return ( + <Fragment> + <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0"> + <dt class="text-sm font-medium leading-6 text-gray-900"> + <i18n.Translate>Type</i18n.Translate> + </dt> + <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0"> + <i18n.Translate> + Updating account settings + </i18n.Translate> + </dd> + </div> + <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0"> + <dt class="text-sm font-medium leading-6 text-gray-900"> + <i18n.Translate>Account</i18n.Translate> + </dt> + <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0"> + {challenge.request} + </dd> + </div> + </Fragment> + ); + case "create-transaction": { + const payto = parsePaytoUri(challenge.request.payto_uri)!; + return ( + <Fragment> + {challenge.request.amount && ( + <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0"> + <dt class="text-sm font-medium leading-6 text-gray-900"> + <i18n.Translate>Amount</i18n.Translate> + </dt> + <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0"> + <RenderAmount + value={Amounts.parseOrThrow( + challenge.request.amount, + )} + spec={config.currency_specification} + /> + </dd> + </div> + )} + {payto.isKnown && payto.targetType === "iban" && ( + <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0"> + <dt class="text-sm font-medium leading-6 text-gray-900"> + <i18n.Translate>To account</i18n.Translate> + </dt> + <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0"> + {payto.iban} + </dd> + </div> + )} + </Fragment> + ); + } + case "confirm-withdrawal": + return <ShowWithdrawalDetails id={challenge.request} />; + case "create-cashout": { + return <ShowCashoutDetails request={challenge.request} />; + } + case "update-account": { + return ( + <Fragment> + {challenge.request.cashout_payto_uri !== undefined && ( + <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0"> + <dt class="text-sm font-medium leading-6 text-gray-900"> + <i18n.Translate>Cashout account</i18n.Translate> + </dt> + <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0"> + {challenge.request.cashout_payto_uri} + </dd> + </div> + )} + {challenge.request.contact_data?.email !== undefined && ( + <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0"> + <dt class="text-sm font-medium leading-6 text-gray-900"> + <i18n.Translate>Email</i18n.Translate> + </dt> + <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0"> + {challenge.request.contact_data?.email} + </dd> + </div> + )} + {challenge.request.contact_data?.phone !== undefined && ( + <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0"> + <dt class="text-sm font-medium leading-6 text-gray-900"> + <i18n.Translate>Phone</i18n.Translate> + </dt> + <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0"> + {challenge.request.contact_data?.phone} + </dd> + </div> + )} + {challenge.request.debit_threshold !== undefined && ( + <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0"> + <dt class="text-sm font-medium leading-6 text-gray-900"> + <i18n.Translate>Debit threshold</i18n.Translate> + </dt> + <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0"> + <RenderAmount + value={Amounts.parseOrThrow( + challenge.request.debit_threshold, + )} + spec={config.currency_specification} + /> + </dd> + </div> + )} + {challenge.request.is_public !== undefined && ( + <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0"> + <dt class="text-sm font-medium leading-6 text-gray-900"> + <i18n.Translate> + Is this account public? + </i18n.Translate> + </dt> + <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0"> + {challenge.request.is_public + ? i18n.str`Enable` + : i18n.str`Disable`} + </dd> + </div> + )} + {challenge.request.name !== undefined && ( + <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0"> + <dt class="text-sm font-medium leading-6 text-gray-900"> + <i18n.Translate>Name</i18n.Translate> + </dt> + <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0"> + {challenge.request.name} + </dd> + </div> + )} + {challenge.request.tan_channel !== undefined && ( + <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0"> + <dt class="text-sm font-medium leading-6 text-gray-900"> + <i18n.Translate> + Authentication channel + </i18n.Translate> + </dt> + <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0"> + {challenge.request.tan_channel ?? i18n.str`Remove`} + </dd> + </div> + )} + </Fragment> + ); + } + case "update-password": { + return ( + <Fragment> + <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0"> + <dt class="text-sm font-medium leading-6 text-gray-900"> + <i18n.Translate>New password</i18n.Translate> + </dt> + <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0"> + {challenge.request.new_password} + </dd> + </div> + </Fragment> + ); + } + default: + assertUnreachable(challenge); + } + })()} + </dl> + {challenge.info && ( + <h2 class="text-base font-semibold leading-7 text-gray-900 mt-4"> + <span + class="text-sm text-black font-semibold leading-6 " + id="availability-label" + > + <i18n.Translate>Challenge details</i18n.Translate> + </span> + </h2> + )} + <dl class="divide-y divide-gray-100"> + {challenge.sent.t_ms !== "never" && ( + <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0"> + <dt class="text-sm font-medium leading-6 text-gray-900"> + <i18n.Translate>Sent at</i18n.Translate> + </dt> + <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0"> + <Time + format="dd/MM/yyyy HH:mm:ss" + timestamp={challenge.sent} + relative={Duration.fromSpec({ days: 1 })} + /> + </dd> + </div> + )} + {challenge.info && ( + <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0"> + <dt class="text-sm font-medium leading-6 text-gray-900"> + {((ch: TalerCorebankApi.TanChannel): VNode => { + switch (ch) { + case TalerCorebankApi.TanChannel.SMS: + return <i18n.Translate>To phone</i18n.Translate>; + case TalerCorebankApi.TanChannel.EMAIL: + return <i18n.Translate>To email</i18n.Translate>; + default: + assertUnreachable(ch); + } + })(challenge.info.tan_channel)} + </dt> + <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0"> + {challenge.info.tan_info} + </dd> + </div> + )} + </dl> + </div> + <div class="mt-6 mb-4 flex justify-between"> + <button + type="button" + name="cancel" + class="text-sm font-semibold leading-6 text-gray-900" + onClick={onCancel} + > + <i18n.Translate>Cancel</i18n.Translate> + </button> + {challenge.info ? ( + <button + type="submit" + name="send again" + 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) => { + onStart(); + e.preventDefault(); + }} + > + <i18n.Translate>Send again</i18n.Translate> + </button> + ) : ( + <div> sending code ...</div> + )} + </div> + </div> + </div> + ); +} + +function ShowWithdrawalDetails({ id }: { id: string }): VNode { + const details = useWithdrawalDetails(id); + const { i18n } = useTranslationContext(); + const { config } = useBankCoreApiContext(); + if (!details) { + return <Loading />; + } + if (details instanceof TalerError) { + return <ErrorLoadingWithDebug error={details} />; + } + if (details.type === "fail") { + switch (details.case) { + case HttpStatusCode.BadRequest: + case HttpStatusCode.NotFound: + return <OperationNotFound routeClose={undefined} />; + default: + assertUnreachable(details); + } + } + + return ( + <Fragment> + <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0"> + <dt class="text-sm font-medium leading-6 text-gray-900">Amount</dt> + <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0"> + <RenderAmount + value={Amounts.parseOrThrow(details.body.amount)} + spec={config.currency_specification} + /> + </dd> + </div> + {details.body.selected_reserve_pub !== undefined && ( + <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0"> + <dt class="text-sm font-medium leading-6 text-gray-900"> + <i18n.Translate>Withdraw id</i18n.Translate> + </dt> + <dd + class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0" + title={details.body.selected_reserve_pub} + > + {details.body.selected_reserve_pub.substring(0, 16)}... + </dd> + </div> + )} + {details.body.selected_exchange_account !== undefined && ( + <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0"> + <dt class="text-sm font-medium leading-6 text-gray-900"> + <i18n.Translate>To account</i18n.Translate> + </dt> + <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0"> + {details.body.selected_exchange_account} + </dd> + </div> + )} + </Fragment> + ); +} + +function ShowCashoutDetails({ + request, +}: { + request: TalerCorebankApi.CashoutRequest; +}): VNode { + const { i18n } = useTranslationContext(); + const info = useConversionInfo(); + if (!info) { + return <Loading />; + } + + if (info instanceof TalerError) { + return <ErrorLoadingWithDebug error={info} />; + } + if (info.type === "fail") { + switch (info.case) { + case HttpStatusCode.NotImplemented: { + return ( + <Attention type="danger" title={i18n.str`Cashout are disabled`}> + <i18n.Translate> + Cashout should be enable by configuration and the conversion rate + should be initialized with fee, ratio and rounding mode. + </i18n.Translate> + </Attention> + ); + } + default: + assertUnreachable(info.case); + } + } + + return ( + <Fragment> + {request.subject !== undefined && ( + <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0"> + <dt class="text-sm font-medium leading-6 text-gray-900"> + <i18n.Translate>Subject</i18n.Translate> + </dt> + <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0"> + {request.subject} + </dd> + </div> + )} + <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0"> + <dt class="text-sm font-medium leading-6 text-gray-900">Debit</dt> + <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0"> + <RenderAmount + value={Amounts.parseOrThrow(request.amount_credit)} + spec={info.body.regional_currency_specification} + /> + </dd> + </div> + <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0"> + <dt class="text-sm font-medium leading-6 text-gray-900">Credit</dt> + <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0"> + <RenderAmount + value={Amounts.parseOrThrow(request.amount_credit)} + spec={info.body.fiat_currency_specification} + /> + </dd> + </div> + </Fragment> + ); +} diff --git a/packages/bank-ui/src/pages/WalletWithdrawForm.tsx b/packages/bank-ui/src/pages/WalletWithdrawForm.tsx new file mode 100644 index 000000000..a9c652643 --- /dev/null +++ b/packages/bank-ui/src/pages/WalletWithdrawForm.tsx @@ -0,0 +1,404 @@ +/* + This file is part of GNU Taler + (C) 2022-2024 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +import { + AbsoluteTime, + AmountJson, + Amounts, + HttpStatusCode, + TranslatedString, + assertUnreachable, + parseWithdrawUri, +} from "@gnu-taler/taler-util"; +import { + Attention, + LocalNotificationBanner, + notifyError, + useLocalNotification, + useTranslationContext, +} from "@gnu-taler/web-util/browser"; +import { VNode, h } from "preact"; +import { forwardRef } from "preact/compat"; +import { useState } from "preact/hooks"; +import { useBankCoreApiContext } from "@gnu-taler/web-util/browser"; +import { useSessionState } from "../hooks/session.js"; +import { useBankState } from "../hooks/bank-state.js"; +import { usePreferences } from "../hooks/preferences.js"; +import { RouteDefinition } from "@gnu-taler/web-util/browser"; +import { undefinedIfEmpty } from "../utils.js"; +import { OperationState } from "./OperationState/index.js"; +import { + InputAmount, + RenderAmount, + doAutoFocus, +} from "./PaytoWireTransferForm.js"; + +const RefAmount = forwardRef(InputAmount); + +function OldWithdrawalForm({ + onOperationCreated, + limit, + balance, + routeCancel, + focus, + routeOperationDetails, +}: { + limit: AmountJson; + balance: AmountJson; + focus?: boolean; + routeOperationDetails: RouteDefinition<{ wopid: string }>; + onOperationCreated: (wopid: string) => void; + routeCancel: RouteDefinition; +}): VNode { + const { i18n } = useTranslationContext(); + const [settings] = usePreferences(); + + // const walletInegrationApi = useTalerWalletIntegrationAPI() + // const { navigateTo } = useNavigationContext(); + + const [bankState, updateBankState] = useBankState(); + const { + lib: { bank: api }, + config, + } = useBankCoreApiContext(); + + const { state: credentials } = useSessionState(); + const creds = credentials.status !== "loggedIn" ? undefined : credentials; + + const [amountStr, setAmountStr] = useState<string | undefined>( + `${settings.maxWithdrawalAmount}`, + ); + const [notification, notify, handleError] = useLocalNotification(); + + if (bankState.currentWithdrawalOperationId) { + // FIXME: doing the preventDefault is not optimal + + // const suri = stringifyWithdrawUri({ + // bankIntegrationApiBaseUrl: api.getIntegrationAPI().baseUrl, + // withdrawalOperationId: bankState.currentWithdrawalOperationId, + // }); + // const uri = parseWithdrawUri(suri)! + const url = routeOperationDetails.url({ + wopid: bankState.currentWithdrawalOperationId, + }); + return ( + <Attention + type="warning" + title={i18n.str`There is an operation already`} + onClose={() => { + updateBankState("currentWithdrawalOperationId", undefined); + }} + > + <span ref={focus ? doAutoFocus : undefined} /> + <i18n.Translate>Complete the operation in</i18n.Translate>{" "} + <a + class="font-semibold text-yellow-700 hover:text-yellow-600" + name="complete operation" + href={url} + // onClick={(e) => { + // e.preventDefault() + // walletInegrationApi.publishTalerAction(uri, () => { + // navigateTo(url) + // }) + // }} + > + <i18n.Translate>this page</i18n.Translate> + </a> + </Attention> + ); + } + + const trimmedAmountStr = amountStr?.trim(); + + const parsedAmount = trimmedAmountStr + ? Amounts.parse(`${limit.currency}:${trimmedAmountStr}`) + : undefined; + + const errors = undefinedIfEmpty({ + amount: + trimmedAmountStr == null + ? i18n.str`Required` + : !parsedAmount + ? i18n.str`Invalid` + : Amounts.cmp(limit, parsedAmount) === -1 + ? i18n.str`Balance is not enough` + : undefined, + }); + + async function doStart() { + if (!parsedAmount || !creds) return; + await handleError(async () => { + const resp = await api.createWithdrawal(creds, { + amount: Amounts.stringify(parsedAmount), + }); + if (resp.type === "ok") { + 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: ${resp.body.taler_withdraw_uri}`, + ); + } else { + updateBankState( + "currentWithdrawalOperationId", + uri.withdrawalOperationId, + ); + onOperationCreated(uri.withdrawalOperationId); + } + } else { + switch (resp.case) { + case HttpStatusCode.Conflict: { + notify({ + type: "error", + title: i18n.str`The operation was rejected due to insufficient funds`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + when: AbsoluteTime.now(), + }); + break; + } + case HttpStatusCode.Unauthorized: { + notify({ + type: "error", + title: i18n.str`The operation was rejected due to insufficient funds`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + when: AbsoluteTime.now(), + }); + break; + } + case HttpStatusCode.NotFound: { + notify({ + type: "error", + title: i18n.str`Account not found`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + when: AbsoluteTime.now(), + }); + break; + } + default: + assertUnreachable(resp); + } + } + }); + } + + return ( + <form + class="bg-white shadow-sm ring-1 ring-gray-900/5 sm:rounded-xl md:col-span-2 mt-4" + autoCapitalize="none" + autoCorrect="off" + onSubmit={(e) => { + e.preventDefault(); + }} + > + <LocalNotificationBanner notification={notification} /> + + <div class="px-4 py-6 "> + <div class="grid max-w-xs grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6"> + <div class="sm:col-span-5"> + <label for="withdraw-amount">{i18n.str`Amount`}</label> + <RefAmount + currency={limit.currency} + value={amountStr} + name="withdraw-amount" + onChange={(v) => { + setAmountStr(v); + }} + error={errors?.amount} + ref={focus ? doAutoFocus : undefined} + /> + </div> + </div> + <p class="mt-2 text-sm text-gray-500"> + <i18n.Translate> + Current balance is{" "} + <RenderAmount + value={balance} + spec={config.currency_specification} + /> + </i18n.Translate> + </p> + {Amounts.cmp(limit, balance) > 0 ? ( + <p class="mt-2 text-sm text-gray-500"> + <i18n.Translate> + Your account allows you to withdraw{" "} + <RenderAmount + value={limit} + spec={config.currency_specification} + /> + </i18n.Translate> + </p> + ) : undefined} + <div class="mt-4"> + <div class="sm:inline"> + <button + type="button" + name="set 50" + class=" inline-flex px-6 py-4 text-sm items-center rounded-l-md bg-white text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:z-10" + onClick={(e) => { + e.preventDefault(); + setAmountStr("50.00"); + }} + > + 50.00 + </button> + <button + type="button" + name="set 25" + class=" -ml-px -mr-px inline-flex px-6 py-4 text-sm items-center rounded-r-md sm:rounded-none bg-white text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:z-10" + onClick={(e) => { + e.preventDefault(); + setAmountStr("25.00"); + }} + > + 25.00 + </button> + </div> + <div class="mt-4 sm:inline"> + <button + type="button" + name="set 10" + class=" -ml-px -mr-px inline-flex px-6 py-4 text-sm items-center rounded-l-md sm:rounded-none bg-white text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:z-10" + onClick={(e) => { + e.preventDefault(); + setAmountStr("10.00"); + }} + > + 10.00 + </button> + <button + type="button" + name="set 5" + class=" inline-flex px-6 py-4 text-sm items-center rounded-r-md bg-white text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:z-10" + onClick={(e) => { + e.preventDefault(); + setAmountStr("5.00"); + }} + > + 5.00 + </button> + </div> + </div> + </div> + <div class="flex items-center justify-between gap-x-6 border-t border-gray-900/10 px-4 py-4 sm:px-8"> + <a + href={routeCancel.url({})} + name="cancel" + class="text-sm font-semibold leading-6 text-gray-900" + > + <i18n.Translate>Cancel</i18n.Translate> + </a> + <button + type="submit" + name="continue" + class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" + // disabled={isRawPayto ? !!errorsPayto : !!errorsWire} + onClick={(e) => { + e.preventDefault(); + doStart(); + }} + > + <i18n.Translate>Continue</i18n.Translate> + </button> + </div> + </form> + ); +} + +export function WalletWithdrawForm({ + focus, + limit, + balance, + routeCancel, + onAuthorizationRequired, + onOperationCreated, + onOperationAborted, + routeOperationDetails, +}: { + limit: AmountJson; + balance: AmountJson; + focus?: boolean; + routeOperationDetails: RouteDefinition<{ wopid: string }>; + onAuthorizationRequired: () => void; + onOperationCreated: (wopid: string) => void; + onOperationAborted: () => void; + routeCancel: RouteDefinition; +}): VNode { + const { i18n } = useTranslationContext(); + const [settings, updateSettings] = usePreferences(); + + return ( + <div class="grid grid-cols-1 gap-x-8 gap-y-8 pt-6 md:grid-cols-3 bg-gray-100 my-4 px-4 pb-4 rounded-lg"> + <div class="px-4 sm:px-0"> + <h2 class="text-base font-semibold leading-7 text-gray-900"> + <i18n.Translate>Prepare your Taler wallet</i18n.Translate> + </h2> + <p class="mt-1 text-sm text-gray-500"> + <i18n.Translate> + After using your wallet you will need to confirm or cancel the + operation on this site. + </i18n.Translate> + </p> + </div> + + <div class="col-span-2"> + {settings.showInstallWallet && ( + <Attention + title={i18n.str`You need a Taler wallet`} + onClose={() => { + updateSettings("showInstallWallet", false); + }} + > + <i18n.Translate> + If you don't have one yet you can follow the instruction in + </i18n.Translate>{" "} + <a + target="_blank" + name="wallet page" + rel="noreferrer noopener" + class="font-semibold text-blue-700 hover:text-blue-600" + href="https://taler.net/en/wallet.html" + > + <i18n.Translate>this page</i18n.Translate> + </a> + </Attention> + )} + + {!settings.fastWithdrawal ? ( + <OldWithdrawalForm + focus={focus} + routeOperationDetails={routeOperationDetails} + limit={limit} + balance={balance} + routeCancel={routeCancel} + onOperationCreated={onOperationCreated} + /> + ) : ( + <OperationState + currency={limit.currency} + onAuthorizationRequired={onAuthorizationRequired} + routeClose={routeCancel} + routeHere={routeOperationDetails} + onAbort={onOperationAborted} + // route={routeCancel} + /> + )} + </div> + </div> + ); +} diff --git a/packages/bank-ui/src/pages/WireTransfer.tsx b/packages/bank-ui/src/pages/WireTransfer.tsx new file mode 100644 index 000000000..f45390938 --- /dev/null +++ b/packages/bank-ui/src/pages/WireTransfer.tsx @@ -0,0 +1,119 @@ +/* + This file is part of GNU Taler + (C) 2022-2024 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ +import { + Amounts, + HttpStatusCode, + TalerError, + assertUnreachable, +} from "@gnu-taler/taler-util"; +import { + Loading, + notifyInfo, + useTranslationContext, +} from "@gnu-taler/web-util/browser"; +import { Fragment, VNode, h } from "preact"; +import { ErrorLoadingWithDebug } from "../components/ErrorLoadingWithDebug.js"; +import { useAccountDetails } from "../hooks/account.js"; +import { useSessionState } from "../hooks/session.js"; +import { LoginForm } from "./LoginForm.js"; +import { PaytoWireTransferForm } from "./PaytoWireTransferForm.js"; +import { RouteDefinition } from "@gnu-taler/web-util/browser"; + +export function WireTransfer({ + toAccount, + withSubject, + withAmount, + onAuthorizationRequired, + routeCancel, + routeHere, + onSuccess, +}: { + onSuccess?: () => void; + routeHere: RouteDefinition<{ + account?: string; + subject?: string; + amount?: string; + }>; + toAccount?: string; + withSubject?: string; + withAmount?: string; + routeCancel?: RouteDefinition; + onAuthorizationRequired: () => void; +}): VNode { + const { i18n } = useTranslationContext(); + const r = useSessionState(); + const account = r.state.status !== "loggedOut" ? r.state.username : "admin"; + const result = useAccountDetails(account); + + if (!result) { + return <Loading />; + } + if (result instanceof TalerError) { + return <ErrorLoadingWithDebug error={result} />; + } + if (result.type === "fail") { + switch (result.case) { + case HttpStatusCode.Unauthorized: + return <LoginForm currentUser={account} />; + case HttpStatusCode.NotFound: + return <LoginForm currentUser={account} />; + default: + assertUnreachable(result); + } + } + const { body: data } = result; + + const balance = Amounts.parseOrThrow(data.balance.amount); + const balanceIsDebit = data.balance.credit_debit_indicator == "debit"; + + const debitThreshold = Amounts.parseOrThrow(data.debit_threshold); + + if (!balance) return <Fragment />; + + const limit = balanceIsDebit + ? Amounts.sub(debitThreshold, balance).amount + : Amounts.add(balance, debitThreshold).amount; + + const positiveBalance = balanceIsDebit + ? Amounts.zeroOfAmount(balance) + : balance; + return ( + <div class="px-4 mt-8"> + <div class="sm:flex sm:items-center mb-4"> + <div class="sm:flex-auto"> + <h1 class="text-base font-semibold leading-6 text-gray-900"> + <i18n.Translate>Make a wire transfer</i18n.Translate> + </h1> + </div> + </div> + + <PaytoWireTransferForm + withAccount={toAccount} + withAmount={withAmount} + balance={positiveBalance} + withSubject={withSubject} + routeHere={routeHere} + limit={limit} + onAuthorizationRequired={onAuthorizationRequired} + onSuccess={() => { + notifyInfo(i18n.str`Wire transfer created!`); + if (onSuccess) onSuccess(); + }} + routeCancel={routeCancel} + /> + </div> + ); +} diff --git a/packages/bank-ui/src/pages/WithdrawalConfirmationQuestion.tsx b/packages/bank-ui/src/pages/WithdrawalConfirmationQuestion.tsx new file mode 100644 index 000000000..853dd7bae --- /dev/null +++ b/packages/bank-ui/src/pages/WithdrawalConfirmationQuestion.tsx @@ -0,0 +1,425 @@ +/* + This file is part of GNU Taler + (C) 2022-2024 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +import { + AbsoluteTime, + AmountJson, + HttpStatusCode, + PaytoUri, + PaytoUriIBAN, + PaytoUriTalerBank, + TalerErrorCode, + TranslatedString, + WithdrawUriResult, + assertUnreachable, +} from "@gnu-taler/taler-util"; +import { + Attention, + LocalNotificationBanner, + notifyInfo, + useLocalNotification, + useTranslationContext, +} from "@gnu-taler/web-util/browser"; +import { ComponentChildren, Fragment, VNode, h } from "preact"; +import { mutate } from "swr"; +import { useBankCoreApiContext } from "@gnu-taler/web-util/browser"; +import { useBankState } from "../hooks/bank-state.js"; +import { usePreferences } from "../hooks/preferences.js"; +import { useSessionState } from "../hooks/session.js"; +import { RouteDefinition } from "@gnu-taler/web-util/browser"; +import { LoginForm } from "./LoginForm.js"; +import { RenderAmount } from "./PaytoWireTransferForm.js"; + +interface Props { + onAborted: () => void; + withdrawUri: WithdrawUriResult; + routeHere: RouteDefinition<{ wopid: string }>; + details: { + account: PaytoUri; + reserve: string; + username: string; + amount: AmountJson; + }; + onAuthorizationRequired: () => void; +} +/** + * Additional authentication required to complete the operation. + * Not providing a back button, only abort. + */ +export function WithdrawalConfirmationQuestion({ + onAborted, + details, + onAuthorizationRequired, + routeHere, + withdrawUri, +}: Props): VNode { + const { i18n } = useTranslationContext(); + const [settings] = usePreferences(); + const { state: credentials } = useSessionState(); + const creds = credentials.status !== "loggedIn" ? undefined : credentials; + const [, updateBankState] = useBankState(); + + const [notification, notify, handleError] = useLocalNotification(); + + const { + config, + lib: { bank: api }, + } = useBankCoreApiContext(); + + async function doTransfer() { + await handleError(async () => { + if (!creds) return; + const resp = await api.confirmWithdrawalById( + creds, + 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 TalerErrorCode.BANK_CONFIRM_ABORT_CONFLICT: + 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, + when: AbsoluteTime.now(), + }); + case TalerErrorCode.BANK_CONFIRM_INCOMPLETE: + return notify({ + type: "error", + title: i18n.str`The withdrawal operation can't be confirmed before a wallet accepted the transaction.`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + when: AbsoluteTime.now(), + }); + case HttpStatusCode.BadRequest: + return notify({ + type: "error", + title: i18n.str`The operation id is invalid.`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + when: AbsoluteTime.now(), + }); + case HttpStatusCode.NotFound: + return notify({ + type: "error", + title: i18n.str`The operation was not found.`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + when: AbsoluteTime.now(), + }); + case TalerErrorCode.BANK_UNALLOWED_DEBIT: + return notify({ + type: "error", + title: i18n.str`Your balance is not enough for the operation.`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + when: AbsoluteTime.now(), + }); + case HttpStatusCode.Accepted: { + updateBankState("currentChallenge", { + operation: "confirm-withdrawal", + id: String(resp.body.challenge_id), + location: routeHere.url({ + wopid: withdrawUri.withdrawalOperationId, + }), + sent: AbsoluteTime.never(), + request: withdrawUri.withdrawalOperationId, + }); + return onAuthorizationRequired(); + } + default: + assertUnreachable(resp); + } + } + }); + } + + async function doCancel() { + await handleError(async () => { + if (!creds) return; + const resp = await api.abortWithdrawalById( + creds, + withdrawUri.withdrawalOperationId, + ); + if (resp.type === "ok") { + onAborted(); + } else { + switch (resp.case) { + case HttpStatusCode.Conflict: + 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, + when: AbsoluteTime.now(), + }); + case HttpStatusCode.BadRequest: + return notify({ + type: "error", + title: i18n.str`The operation id is invalid.`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + when: AbsoluteTime.now(), + }); + case HttpStatusCode.NotFound: + return notify({ + type: "error", + title: i18n.str`The operation was not found.`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + when: AbsoluteTime.now(), + }); + default: { + assertUnreachable(resp); + } + } + } + }); + } + + return ( + <Fragment> + <LocalNotificationBanner notification={notification} /> + + <div class="bg-white shadow sm:rounded-lg"> + <div class="px-4 py-5 sm:p-6"> + <h3 class="text-base font-semibold text-gray-900"> + <i18n.Translate>Confirm the withdrawal operation</i18n.Translate> + </h3> + <div class="mt-3 text-sm leading-6"> + <ShouldBeSameUser username={details.username}> + <div class="grid grid-cols-1 gap-x-8 gap-y-8 pt-10 md:grid-cols-2 bg-gray-100 my-4 px-4 pb-4 rounded-lg"> + <form + class="bg-white shadow-sm ring-1 ring-gray-900/5 sm:rounded-xl md:col-span-2" + autoCapitalize="none" + autoCorrect="off" + onSubmit={(e) => { + e.preventDefault(); + }} + > + <div class="px-4 mt-4"> + <div class="w-full"> + <div class="px-4 sm:px-0 text-sm"> + <p> + <i18n.Translate>Wire transfer details</i18n.Translate> + </p> + </div> + <div class="mt-6 border-t border-gray-100"> + <dl class="divide-y divide-gray-100"> + {((): VNode => { + if (!details.account.isKnown) { + return ( + <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0"> + <dt class="text-sm font-medium leading-6 text-gray-900"> + <i18n.Translate> + Payment provider's account + </i18n.Translate> + </dt> + <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0"> + {details.account.targetPath} + </dd> + </div> + ); + } + switch (details.account.targetType) { + case "iban": { + const name = + details.account.params["receiver-name"]; + return ( + <Fragment> + <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0"> + <dt class="text-sm font-medium leading-6 text-gray-900"> + <i18n.Translate> + Payment provider's account number + </i18n.Translate> + </dt> + <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0"> + {details.account.iban} + </dd> + </div> + {name && ( + <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0"> + <dt class="text-sm font-medium leading-6 text-gray-900"> + <i18n.Translate> + Payment provider's name + </i18n.Translate> + </dt> + <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0"> + {name} + </dd> + </div> + )} + </Fragment> + ); + } + case "x-taler-bank": { + const name = + details.account.params["receiver-name"]; + return ( + <Fragment> + <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0"> + <dt class="text-sm font-medium leading-6 text-gray-900"> + <i18n.Translate> + Payment provider's account bank + hostname + </i18n.Translate> + </dt> + <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0"> + {details.account.host} + </dd> + </div> + <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0"> + <dt class="text-sm font-medium leading-6 text-gray-900"> + <i18n.Translate> + Payment provider's account id + </i18n.Translate> + </dt> + <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0"> + {details.account.account} + </dd> + </div> + {name && ( + <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0"> + <dt class="text-sm font-medium leading-6 text-gray-900"> + <i18n.Translate> + Payment provider's name + </i18n.Translate> + </dt> + <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0"> + {name} + </dd> + </div> + )} + </Fragment> + ); + } + case "bitcoin": { + const name = + details.account.params["receiver-name"]; + return ( + <Fragment> + <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0"> + <dt class="text-sm font-medium leading-6 text-gray-900"> + <i18n.Translate> + Payment provider's account address + </i18n.Translate> + </dt> + <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0"> + {details.account.address} + </dd> + </div> + {name && ( + <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0"> + <dt class="text-sm font-medium leading-6 text-gray-900"> + <i18n.Translate> + Payment provider's name + </i18n.Translate> + </dt> + <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0"> + {name} + </dd> + </div> + )} + </Fragment> + ); + } + default: { + assertUnreachable(details.account); + } + } + })()} + <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0"> + <dt class="text-sm font-medium leading-6 text-gray-900"> + <i18n.Translate>Amount</i18n.Translate> + </dt> + <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0"> + <RenderAmount + value={details.amount} + spec={config.currency_specification} + /> + </dd> + </div> + </dl> + </div> + </div> + </div> + + <div class="flex items-center justify-between gap-x-6 border-t border-gray-900/10 px-4 py-4 sm:px-8"> + <button + type="button" + name="cancel" + class="text-sm font-semibold leading-6 text-gray-900" + onClick={doCancel} + > + <i18n.Translate>Cancel</i18n.Translate> + </button> + <button + type="submit" + name="transfer" + class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" + onClick={(e) => { + e.preventDefault(); + doTransfer(); + }} + > + <i18n.Translate>Transfer</i18n.Translate> + </button> + </div> + </form> + </div> + </ShouldBeSameUser> + </div> + </div> + </div> + </Fragment> + ); +} + +export function ShouldBeSameUser({ + username, + children, +}: { + username: string; + children: ComponentChildren; +}): VNode { + const { state: credentials } = useSessionState(); + const { i18n } = useTranslationContext(); + if (credentials.status === "loggedOut") { + return ( + <Fragment> + <Attention type="info" title={i18n.str`Authentication required`} /> + <LoginForm currentUser={username} fixedUser /> + </Fragment> + ); + } + if (credentials.username !== username) { + return ( + <Fragment> + <Attention + type="warning" + title={i18n.str`This operation was created with other username`} + /> + <LoginForm currentUser={username} fixedUser /> + </Fragment> + ); + } + return <Fragment>{children}</Fragment>; +} diff --git a/packages/bank-ui/src/pages/WithdrawalOperationPage.tsx b/packages/bank-ui/src/pages/WithdrawalOperationPage.tsx new file mode 100644 index 000000000..c0c55f14b --- /dev/null +++ b/packages/bank-ui/src/pages/WithdrawalOperationPage.tsx @@ -0,0 +1,73 @@ +/* + This file is part of GNU Taler + (C) 2022-2024 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +import { parseWithdrawUri, stringifyWithdrawUri } from "@gnu-taler/taler-util"; +import { Attention, useTranslationContext } from "@gnu-taler/web-util/browser"; +import { VNode, h } from "preact"; +import { useBankCoreApiContext } from "@gnu-taler/web-util/browser"; +import { useBankState } from "../hooks/bank-state.js"; +import { RouteDefinition } from "@gnu-taler/web-util/browser"; +import { WithdrawalQRCode } from "./WithdrawalQRCode.js"; + +export function WithdrawalOperationPage({ + operationId, + onAuthorizationRequired, + onOperationAborted, + routeClose, + routeWithdrawalDetails, +}: { + onAuthorizationRequired: () => void; + operationId: string; + purpose: "after-creation" | "after-confirmation"; + onOperationAborted: () => void; + routeClose: RouteDefinition; + routeWithdrawalDetails: RouteDefinition<{ wopid: string }>; +}): VNode { + const { + lib: { bank: api }, + } = useBankCoreApiContext(); + const uri = stringifyWithdrawUri({ + bankIntegrationApiBaseUrl: api.getIntegrationAPI().href, + withdrawalOperationId: operationId, + }); + const parsedUri = parseWithdrawUri(uri); + const { i18n } = useTranslationContext(); + const [, updateBankState] = useBankState(); + + if (!parsedUri) { + return ( + <Attention + type="danger" + title={i18n.str`The Withdrawal URI is not valid`} + > + {uri} + </Attention> + ); + } + + return ( + <WithdrawalQRCode + withdrawUri={parsedUri} + routeWithdrawalDetails={routeWithdrawalDetails} + onAuthorizationRequired={onAuthorizationRequired} + onOperationAborted={() => { + updateBankState("currentWithdrawalOperationId", undefined); + onOperationAborted(); + }} + routeClose={routeClose} + /> + ); +} diff --git a/packages/bank-ui/src/pages/WithdrawalQRCode.tsx b/packages/bank-ui/src/pages/WithdrawalQRCode.tsx new file mode 100644 index 000000000..b61f0cc8f --- /dev/null +++ b/packages/bank-ui/src/pages/WithdrawalQRCode.tsx @@ -0,0 +1,310 @@ +/* + This file is part of GNU Taler + (C) 2022-2024 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +import { + Amounts, + HttpStatusCode, + TalerError, + WithdrawUriResult, + assertUnreachable, + parsePaytoUri, +} from "@gnu-taler/taler-util"; +import { + Attention, + Loading, + notifyInfo, + useTranslationContext, +} from "@gnu-taler/web-util/browser"; +import { VNode, h } from "preact"; +import { ErrorLoadingWithDebug } from "../components/ErrorLoadingWithDebug.js"; +import { useWithdrawalDetails } from "../hooks/account.js"; +import { RouteDefinition } from "@gnu-taler/web-util/browser"; +import { QrCodeSection } from "./QrCodeSection.js"; +import { WithdrawalConfirmationQuestion } from "./WithdrawalConfirmationQuestion.js"; + +interface Props { + withdrawUri: WithdrawUriResult; + onOperationAborted: () => void; + routeClose: RouteDefinition; + routeWithdrawalDetails: RouteDefinition<{ wopid: string }>; + onAuthorizationRequired: () => void; +} +/** + * Offer the QR code (and a clickable taler://-link) to + * permit the passing of exchange and reserve details to + * the bank. Poll the backend until such operation is done. + */ +export function WithdrawalQRCode({ + withdrawUri, + onOperationAborted, + routeClose, + routeWithdrawalDetails, + onAuthorizationRequired, +}: Props): VNode { + const { i18n } = useTranslationContext(); + const result = useWithdrawalDetails(withdrawUri.withdrawalOperationId); + + if (!result) { + return <Loading />; + } + if (result instanceof TalerError) { + return <ErrorLoadingWithDebug error={result} />; + } + if (result.type === "fail") { + switch (result.case) { + case HttpStatusCode.BadRequest: + case HttpStatusCode.NotFound: + return <OperationNotFound routeClose={routeClose} />; + default: + assertUnreachable(result); + } + } + + const { body: data } = result; + + if (data.status === "aborted") { + return ( + <div class="relative ml-auto mr-auto transform overflow-hidden rounded-lg bg-white px-4 pb-4 pt-5 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-sm sm:p-6"> + <div> + <div class="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-yellow-100"> + <svg + class="h-5 w-5 text-yellow-400" + viewBox="0 0 20 20" + fill="currentColor" + aria-hidden="true" + > + <path + fill-rule="evenodd" + d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495zM10 5a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 0110 5zm0 9a1 1 0 100-2 1 1 0 000 2z" + clip-rule="evenodd" + /> + </svg> + </div> + <div class="mt-3 text-center sm:mt-5"> + <h3 + class="text-base font-semibold leading-6 text-gray-900" + id="modal-title" + > + <i18n.Translate>Operation aborted</i18n.Translate> + </h3> + <div class="mt-2"> + <p class="text-sm text-gray-500"> + <i18n.Translate> + The wire transfer to the payment provider's account was + aborted from somewhere else, your balance was not affected. + </i18n.Translate> + </p> + </div> + </div> + </div> + <div class="mt-5 sm:mt-6"> + <a + href={routeClose.url({})} + name="continue" + class="inline-flex w-full justify-center rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" + > + <i18n.Translate>Continue</i18n.Translate> + </a> + </div> + </div> + ); + } + + if (data.status === "confirmed") { + return ( + <div class="relative ml-auto mr-auto transform overflow-hidden rounded-lg bg-white px-4 pb-4 pt-5 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-sm sm:p-6"> + <div> + <div class="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-green-100"> + <svg + class="h-6 w-6 text-green-600" + fill="none" + viewBox="0 0 24 24" + stroke-width="1.5" + stroke="currentColor" + aria-hidden="true" + > + <path + stroke-linecap="round" + stroke-linejoin="round" + d="M4.5 12.75l6 6 9-13.5" + /> + </svg> + </div> + <div class="mt-3 text-center sm:mt-5"> + <h3 + class="text-base font-semibold leading-6 text-gray-900" + id="modal-title" + > + <i18n.Translate>Withdrawal confirmed</i18n.Translate> + </h3> + <div class="mt-2"> + <p class="text-sm text-gray-500"> + <i18n.Translate> + The wire transfer to the Taler operator has been initiated. + You will soon receive the requested amount in your Taler + wallet. + </i18n.Translate> + </p> + </div> + </div> + </div> + <div class="mt-5 sm:mt-6"> + <a + href={routeClose.url({})} + name="done" + class="inline-flex w-full justify-center rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" + > + <i18n.Translate>Done</i18n.Translate> + </a> + </div> + </div> + ); + } + + if (data.status === "pending") { + return ( + <QrCodeSection + withdrawUri={withdrawUri} + onAborted={() => { + notifyInfo(i18n.str`Operation canceled`); + onOperationAborted(); + }} + /> + ); + } + + const account = !data.selected_exchange_account + ? undefined + : parsePaytoUri(data.selected_exchange_account); + + if (!data.selected_reserve_pub && account) { + return ( + <Attention + type="danger" + title={i18n.str`The operation is marked as 'selected' but some step in the withdrawal failed`} + > + <i18n.Translate> + The account is selected but no withdrawal identification found. + </i18n.Translate> + </Attention> + ); + } + + if (!account && data.selected_reserve_pub) { + return ( + <Attention + type="danger" + title={i18n.str`The operation is marked as 'selected' but some step in the withdrawal failed`} + > + <i18n.Translate> + There is a withdrawal identification but no account has been selected + or the selected account is invalid. + </i18n.Translate> + </Attention> + ); + } + + if (!account || !data.selected_reserve_pub) { + return ( + <Attention + type="danger" + title={i18n.str`The operation is marked as 'selected' but some step in the withdrawal failed`} + > + <i18n.Translate> + No withdrawal ID found and no account has been selected or the + selected account is invalid. + </i18n.Translate> + </Attention> + ); + } + + return ( + <WithdrawalConfirmationQuestion + withdrawUri={withdrawUri} + routeHere={routeWithdrawalDetails} + details={{ + username: data.username, + account, + reserve: data.selected_reserve_pub, + amount: Amounts.parseOrThrow(data.amount), + }} + onAuthorizationRequired={onAuthorizationRequired} + onAborted={() => { + notifyInfo(i18n.str`Operation canceled`); + onOperationAborted(); + }} + /> + ); +} + +export function OperationNotFound({ + routeClose, +}: { + routeClose: RouteDefinition | undefined; +}): VNode { + const { i18n } = useTranslationContext(); + return ( + <div class="relative ml-auto mr-auto transform overflow-hidden rounded-lg bg-white px-4 pb-4 pt-5 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-sm sm:p-6"> + <div> + <div class="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-red-100 "> + <svg + class="h-6 w-6 text-red-600" + fill="none" + viewBox="0 0 24 24" + stroke-width="1.5" + stroke="currentColor" + aria-hidden="true" + > + <path + stroke-linecap="round" + stroke-linejoin="round" + d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" + /> + </svg> + </div> + + <div class="mt-3 text-center sm:mt-5"> + <h3 + class="text-base font-semibold leading-6 text-gray-900" + id="modal-title" + > + <i18n.Translate>Operation not found</i18n.Translate> + </h3> + <div class="mt-2"> + <p class="text-sm text-gray-500"> + <i18n.Translate> + This 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> + {routeClose && ( + <div class="mt-5 sm:mt-6"> + <a + href={routeClose.url({})} + name="continue to dashboard" + class="inline-flex w-full justify-center rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" + > + <i18n.Translate>Cotinue to dashboard</i18n.Translate> + </a> + </div> + )} + </div> + ); +} diff --git a/packages/bank-ui/src/pages/account/CashoutListForAccount.tsx b/packages/bank-ui/src/pages/account/CashoutListForAccount.tsx new file mode 100644 index 000000000..fd6379895 --- /dev/null +++ b/packages/bank-ui/src/pages/account/CashoutListForAccount.tsx @@ -0,0 +1,89 @@ +/* + This file is part of GNU Taler + (C) 2022-2024 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ +import { useTranslationContext } from "@gnu-taler/web-util/browser"; +import { Fragment, VNode, h } from "preact"; +import { Cashouts } from "../../components/Cashouts/index.js"; +import { useSessionState } from "../../hooks/session.js"; +import { ProfileNavigation } from "../ProfileNavigation.js"; +import { CreateCashout } from "../regional/CreateCashout.js"; +import { RouteDefinition } from "@gnu-taler/web-util/browser"; + +interface Props { + account: string; + routeClose: RouteDefinition; + onAuthorizationRequired: () => void; + onCashout: () => void; + routeCashoutDetails: RouteDefinition<{ cid: string }>; + routeMyAccountDetails: RouteDefinition; + routeMyAccountDelete: RouteDefinition; + routeMyAccountPassword: RouteDefinition; + routeMyAccountCashout: RouteDefinition; + routeCreateCashout: RouteDefinition; + routeConversionConfig: RouteDefinition; +} + +export function CashoutListForAccount({ + account, + onAuthorizationRequired, + onCashout, + routeCreateCashout, + routeCashoutDetails, + routeMyAccountCashout, + routeMyAccountDelete, + routeMyAccountDetails, + routeConversionConfig, + routeMyAccountPassword, + routeClose, +}: Props): VNode { + const { i18n } = useTranslationContext(); + + const { state: credentials } = useSessionState(); + + const accountIsTheCurrentUser = + credentials.status === "loggedIn" + ? credentials.username === account + : false; + + return ( + <Fragment> + {accountIsTheCurrentUser ? ( + <ProfileNavigation + current="cashouts" + routeMyAccountCashout={routeMyAccountCashout} + routeMyAccountDelete={routeMyAccountDelete} + routeMyAccountDetails={routeMyAccountDetails} + routeMyAccountPassword={routeMyAccountPassword} + routeConversionConfig={routeConversionConfig} + /> + ) : ( + <h1 class="text-base font-semibold leading-6 text-gray-900"> + <i18n.Translate>Cashout for account {account}</i18n.Translate> + </h1> + )} + + <CreateCashout + focus + routeHere={routeCreateCashout} + routeClose={routeClose} + onCashout={onCashout} + onAuthorizationRequired={onAuthorizationRequired} + account={account} + /> + + <Cashouts account={account} routeCashoutDetails={routeCashoutDetails} /> + </Fragment> + ); +} diff --git a/packages/bank-ui/src/pages/account/ShowAccountDetails.tsx b/packages/bank-ui/src/pages/account/ShowAccountDetails.tsx new file mode 100644 index 000000000..6db0e5512 --- /dev/null +++ b/packages/bank-ui/src/pages/account/ShowAccountDetails.tsx @@ -0,0 +1,500 @@ +/* + This file is part of GNU Taler + (C) 2022-2024 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ +import { + AbsoluteTime, + HttpStatusCode, + TalerCorebankApi, + TalerError, + TalerErrorCode, + TranslatedString, + assertUnreachable, + parsePaytoUri +} from "@gnu-taler/taler-util"; +import { + CopyButton, + Loading, + LocalNotificationBanner, + RouteDefinition, + notifyInfo, + useBankCoreApiContext, + useLocalNotification, + useTranslationContext, +} from "@gnu-taler/web-util/browser"; +import { Fragment, VNode, h } from "preact"; +import { useState } from "preact/hooks"; +import { ErrorLoadingWithDebug } from "../../components/ErrorLoadingWithDebug.js"; +import { useAccountDetails } from "../../hooks/account.js"; +import { useBankState } from "../../hooks/bank-state.js"; +import { useSessionState } from "../../hooks/session.js"; +import { LoginForm } from "../LoginForm.js"; +import { ProfileNavigation } from "../ProfileNavigation.js"; +import { AccountForm } from "../admin/AccountForm.js"; + +export function ShowAccountDetails({ + account, + routeClose, + onUpdateSuccess, + onAuthorizationRequired, + routeMyAccountCashout, + routeMyAccountDelete, + routeMyAccountDetails, + routeHere, + routeMyAccountPassword, + routeConversionConfig, +}: { + routeClose: RouteDefinition; + routeHere: RouteDefinition<{ account: string }>; + routeMyAccountDetails: RouteDefinition; + routeMyAccountDelete: RouteDefinition; + routeMyAccountPassword: RouteDefinition; + routeMyAccountCashout: RouteDefinition; + routeConversionConfig: RouteDefinition; + onUpdateSuccess: () => void; + onAuthorizationRequired: () => void; + account: string; +}): VNode { + const { i18n } = useTranslationContext(); + const { state: credentials } = useSessionState(); + const creds = credentials.status !== "loggedIn" ? undefined : credentials; + const { + lib: { bank }, + } = useBankCoreApiContext(); + const accountIsTheCurrentUser = + credentials.status === "loggedIn" + ? credentials.username === account + : false; + + const [submitAccount, setSubmitAccount] = useState< + TalerCorebankApi.AccountReconfiguration | undefined + >(); + const [notification, notify, handleError] = useLocalNotification(); + const [, updateBankState] = useBankState(); + + const result = useAccountDetails(account); + if (!result) { + return <Loading />; + } + if (result instanceof TalerError) { + return <ErrorLoadingWithDebug error={result} />; + } + if (result.type === "fail") { + switch (result.case) { + case HttpStatusCode.Unauthorized: + case HttpStatusCode.NotFound: + return <LoginForm currentUser={account} />; + default: + assertUnreachable(result); + } + } + + async function doUpdate() { + if (!submitAccount || !creds) return; + await handleError(async () => { + const resp = await bank.updateAccount( + { + token: creds.token, + username: account, + }, + submitAccount, + ); + + if (resp.type === "ok") { + notifyInfo(i18n.str`Account updated`); + onUpdateSuccess(); + } else { + switch (resp.case) { + case HttpStatusCode.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, + when: AbsoluteTime.now(), + }); + case HttpStatusCode.NotFound: + return notify({ + type: "error", + title: i18n.str`The username was not found`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + when: AbsoluteTime.now(), + }); + case TalerErrorCode.BANK_NON_ADMIN_PATCH_LEGAL_NAME: + return notify({ + type: "error", + title: i18n.str`You can't change the legal name, please contact the your account administrator.`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + when: AbsoluteTime.now(), + }); + case TalerErrorCode.BANK_NON_ADMIN_PATCH_DEBT_LIMIT: + return notify({ + type: "error", + title: i18n.str`You can't change the debt limit, please contact the your account administrator.`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + when: AbsoluteTime.now(), + }); + case TalerErrorCode.BANK_NON_ADMIN_PATCH_CASHOUT: + return notify({ + type: "error", + title: i18n.str`You can't change the cashout address, please contact the your account administrator.`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + when: AbsoluteTime.now(), + }); + case TalerErrorCode.BANK_MISSING_TAN_INFO: + return notify({ + type: "error", + title: i18n.str`No information for the selected authentication channel.`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + when: AbsoluteTime.now(), + }); + case HttpStatusCode.Accepted: { + updateBankState("currentChallenge", { + operation: "update-account", + id: String(resp.body.challenge_id), + location: routeHere.url({ account }), + sent: AbsoluteTime.never(), + request: submitAccount, + }); + return onAuthorizationRequired(); + } + case TalerErrorCode.BANK_TAN_CHANNEL_NOT_SUPPORTED: { + return notify({ + type: "error", + title: i18n.str`Authentication channel is not supported.`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + when: AbsoluteTime.now(), + }); + } + case TalerErrorCode.BANK_NON_ADMIN_SET_MIN_CASHOUT: { + return notify({ + type: "error", + title: i18n.str`Only the administrator can change the minimum cashout limit.`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + when: AbsoluteTime.now(), + }); + } + default: + assertUnreachable(resp); + } + } + }); + } + + const url = bank.getRevenueAPI(account); + url.username = account; + const baseURL = url.href; + + const ac = parsePaytoUri(result.body.payto_uri); + const payto = !ac?.isKnown ? undefined : ac; + let accountLetter: string | undefined = undefined; + if (payto) { + switch (payto.targetType) { + case "iban": { + accountLetter = `account-info-url=${url.href}\naccount-type=${payto.targetType}\niban=${payto.iban}\nreceiver-name=${result.body.name}\n`; + break; + } + case "x-taler-bank": { + accountLetter = `account-info-url=${url.href}\naccount-type=${payto.targetType}\naccount=${payto.account}\nhost=${payto.host}\nreceiver-name=${result.body.name}\n`; + break; + } + case "bitcoin": { + accountLetter = `account-info-url=${url.href}\naccount-type=${payto.targetType}\naddress=${payto.address}\nreceiver-name=${result.body.name}\n`; + break; + } + } + } + + return ( + <Fragment> + <LocalNotificationBanner notification={notification} showDebug={true} /> + {accountIsTheCurrentUser ? ( + <ProfileNavigation + current="details" + routeMyAccountCashout={routeMyAccountCashout} + routeMyAccountDelete={routeMyAccountDelete} + routeConversionConfig={routeConversionConfig} + routeMyAccountDetails={routeMyAccountDetails} + routeMyAccountPassword={routeMyAccountPassword} + /> + ) : ( + <h1 class="text-base font-semibold leading-6 text-gray-900"> + <i18n.Translate>Account "{account}"</i18n.Translate> + </h1> + )} + + <div class="grid grid-cols-1 gap-x-8 gap-y-8 pt-6 md:grid-cols-3 bg-gray-100 my-4 px-4 pb-4 rounded-lg"> + <div class="px-4 sm:px-0"> + <h2 class="text-base font-semibold leading-7 text-gray-900"> + <div class="flex items-center justify-between"> + <span class="flex flex-grow flex-col"> + <span + class="text-sm text-black font-semibold leading-6 " + id="availability-label" + > + <i18n.Translate>Change details</i18n.Translate> + </span> + </span> + </div> + </h2> + </div> + + <AccountForm + focus={true} + username={account} + template={result.body} + purpose="update" + onChange={(a) => setSubmitAccount(a)} + > + <div class="flex items-center justify-between gap-x-6 border-t border-gray-900/10 px-4 py-4 sm:px-8"> + <a + href={routeClose.url({})} + name="cancel" + class="text-sm font-semibold leading-6 text-gray-900" + > + <i18n.Translate>Cancel</i18n.Translate> + </a> + <button + type="submit" + name="update" + class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" + disabled={!submitAccount} + onClick={doUpdate} + > + <i18n.Translate>Update</i18n.Translate> + </button> + </div> + </AccountForm> + </div> + <div class="grid grid-cols-1 gap-x-8 gap-y-8 pt-6 md:grid-cols-3 bg-gray-100 my-4 px-4 pb-4 rounded-lg"> + <div class="px-4 sm:px-0"> + <h2 class="text-base font-semibold leading-7 text-gray-900"> + <div class="flex items-center justify-between"> + <span class="flex flex-grow flex-col"> + <span + class="text-sm text-black font-semibold leading-6 " + id="availability-label" + > + <i18n.Translate>Merchant integration</i18n.Translate> + </span> + </span> + </div> + </h2> + <p class="mt-2 text-sm text-gray-500"> + <i18n.Translate> + Use this information to link your Taler Merchant Backoffice + account with the current bank account. You can start by copying + the values, then go to your merchant backoffice service provider, + login into your account and look for the "import" button in the + "bank account" section. + </i18n.Translate> + </p> + </div> + + {payto !== undefined && ( + <div class="bg-white shadow-sm ring-1 ring-gray-900/5 sm:rounded-xl md:col-span-2"> + <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"> + <label + class="block text-sm font-medium leading-6 text-gray-900" + for="account-type" + > + {i18n.str`Account type`} + </label> + <div class="mt-2"> + <input + type="text" + 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="account-type" + id="account-type" + disabled={true} + value={account} + autocomplete="off" + /> + </div> + <p class="mt-2 text-sm text-gray-500"> + <i18n.Translate> + Method to use for wire transfer. + </i18n.Translate> + </p> + </div> + {((payto) => { + switch (payto.targetType) { + case "iban": { + return ( + <div class="sm:col-span-5"> + <label + class="block text-sm font-medium leading-6 text-gray-900" + for="iban" + > + {i18n.str`IBAN`} + </label> + <div class="mt-2"> + <input + type="text" + 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="iban" + id="iban" + disabled={true} + value={payto.iban} + autocomplete="off" + /> + </div> + <p class="mt-2 text-sm text-gray-500"> + <i18n.Translate> + International Bank Account Number. + </i18n.Translate> + </p> + </div> + ); + } + case "x-taler-bank": { + return ( + <div class="sm:col-span-5"> + <label + class="block text-sm font-medium leading-6 text-gray-900" + for="iban" + > + {i18n.str`IBAN`} + </label> + <div class="mt-2"> + <input + type="text" + 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="iban" + id="iban" + disabled={true} + value={payto.account} + autocomplete="off" + /> + </div> + <p class="mt-2 text-sm text-gray-500"> + <i18n.Translate> + International Bank Account Number. + </i18n.Translate> + </p> + </div> + ); + } + case "bitcoin": { + return ( + <div class="sm:col-span-5"> + <label + class="block text-sm font-medium leading-6 text-gray-900" + for="iban" + > + {i18n.str`Address`} + </label> + <div class="mt-2"> + <input + type="text" + 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="iban" + id="iban" + disabled={true} + value={"DE1231231231"} + autocomplete="off" + /> + </div> + <p class="mt-2 text-sm text-gray-500"> + <i18n.Translate> + International Bank Account Number. + </i18n.Translate> + </p> + </div> + ); + } + } + })(payto)} + + <div class="sm:col-span-5"> + <label + class="block text-sm font-medium leading-6 text-gray-900" + for="iban" + > + {i18n.str`Owner's name`} + </label> + <div class="mt-2"> + <input + type="text" + 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="iban" + id="iban" + disabled={true} + value={result.body.name} + autocomplete="off" + /> + </div> + <p class="mt-2 text-sm text-gray-500"> + <i18n.Translate> + Legal name of the person holding the account. + </i18n.Translate> + </p> + </div> + <div class="sm:col-span-5"> + <label + class="block text-sm font-medium leading-6 text-gray-900" + for="iban" + > + {i18n.str`Account info URL`} + </label> + <div class="mt-2"> + <input + type="text" + 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="iban" + id="iban" + disabled={true} + value={baseURL} + autocomplete="off" + /> + </div> + <p class="mt-2 text-sm text-gray-500"> + <i18n.Translate> + From where the merchant can download information about + incoming wire transfers to this account. + </i18n.Translate> + </p> + </div> + </div> + </div> + <div class="flex items-center justify-between gap-x-6 border-t border-gray-900/10 px-4 py-4 sm:px-8"> + <a + href={routeClose.url({})} + name="cancel" + class="text-sm font-semibold leading-6 text-gray-900" + > + <i18n.Translate>Cancel</i18n.Translate> + </a> + <CopyButton + getContent={() => accountLetter ?? ""} + class="flex text-center disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" + > + <i18n.Translate>Copy</i18n.Translate> + </CopyButton> + </div> + </div> + )} + </div> + </Fragment> + ); +} diff --git a/packages/bank-ui/src/pages/account/UpdateAccountPassword.tsx b/packages/bank-ui/src/pages/account/UpdateAccountPassword.tsx new file mode 100644 index 000000000..2724fba11 --- /dev/null +++ b/packages/bank-ui/src/pages/account/UpdateAccountPassword.tsx @@ -0,0 +1,319 @@ +/* + This file is part of GNU Taler + (C) 2022-2024 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ +import { + AbsoluteTime, + HttpStatusCode, + TalerErrorCode, + TranslatedString, + assertUnreachable, +} from "@gnu-taler/taler-util"; +import { + LocalNotificationBanner, + ShowInputErrorLabel, + notifyInfo, + useLocalNotification, + useTranslationContext, +} from "@gnu-taler/web-util/browser"; +import { Fragment, VNode, h } from "preact"; +import { useState } from "preact/hooks"; +import { useBankCoreApiContext } from "@gnu-taler/web-util/browser"; +import { useSessionState } from "../../hooks/session.js"; +import { useBankState } from "../../hooks/bank-state.js"; +import { RouteDefinition } from "@gnu-taler/web-util/browser"; +import { undefinedIfEmpty } from "../../utils.js"; +import { doAutoFocus } from "../PaytoWireTransferForm.js"; +import { ProfileNavigation } from "../ProfileNavigation.js"; + +export function UpdateAccountPassword({ + account: accountName, + routeClose, + onUpdateSuccess, + onAuthorizationRequired, + routeMyAccountCashout, + routeMyAccountDelete, + routeMyAccountDetails, + routeMyAccountPassword, + routeConversionConfig, + focus, + routeHere, +}: { + routeClose: RouteDefinition; + routeHere: RouteDefinition<{ account: string }>; + routeMyAccountDetails: RouteDefinition; + routeMyAccountDelete: RouteDefinition; + routeMyAccountPassword: RouteDefinition; + routeMyAccountCashout: RouteDefinition; + routeConversionConfig: RouteDefinition; + focus?: boolean; + onAuthorizationRequired: () => void; + onUpdateSuccess: () => void; + account: string; +}): VNode { + const { i18n } = useTranslationContext(); + const { state: credentials } = useSessionState(); + const token = + credentials.status !== "loggedIn" ? undefined : credentials.token; + const { + lib: { bank: api }, + } = useBankCoreApiContext(); + + const [current, setCurrent] = useState<string | undefined>(); + const [password, setPassword] = useState<string | undefined>(); + const [repeat, setRepeat] = useState<string | undefined>(); + const [, updateBankState] = useBankState(); + + const accountIsTheCurrentUser = + credentials.status === "loggedIn" + ? credentials.username === accountName + : false; + + const errors = undefinedIfEmpty({ + current: !accountIsTheCurrentUser + ? undefined + : !current + ? i18n.str`Required` + : undefined, + password: !password ? i18n.str`Required` : undefined, + repeat: !repeat + ? i18n.str`Required` + : password !== repeat + ? i18n.str`Repeated password doesn't match` + : undefined, + }); + const [notification, notify, handleError] = useLocalNotification(); + + async function doChangePassword() { + if (!!errors || !password || !token) return; + await handleError(async () => { + const request = { + old_password: current, + new_password: password, + }; + const resp = await api.updatePassword( + { username: accountName, token }, + request, + ); + if (resp.type === "ok") { + notifyInfo(i18n.str`Password changed`); + onUpdateSuccess(); + } else { + switch (resp.case) { + case HttpStatusCode.Unauthorized: + return notify({ + type: "error", + title: i18n.str`Not authorized to change the password, maybe the session is invalid.`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + when: AbsoluteTime.now(), + }); + case HttpStatusCode.NotFound: + return notify({ + type: "error", + title: i18n.str`Account not found`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + when: AbsoluteTime.now(), + }); + case TalerErrorCode.BANK_NON_ADMIN_PATCH_MISSING_OLD_PASSWORD: + return notify({ + type: "error", + title: i18n.str`You need to provide the old password. If you don't have it contact your account administrator.`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + when: AbsoluteTime.now(), + }); + case TalerErrorCode.BANK_PATCH_BAD_OLD_PASSWORD: + return notify({ + type: "error", + title: i18n.str`Your current password doesn't match, can't change to a new password.`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + when: AbsoluteTime.now(), + }); + case HttpStatusCode.Accepted: { + updateBankState("currentChallenge", { + operation: "update-password", + id: String(resp.body.challenge_id), + location: routeHere.url({ account: accountName }), + sent: AbsoluteTime.never(), + request, + }); + return onAuthorizationRequired(); + } + default: + assertUnreachable(resp); + } + } + }); + } + + return ( + <Fragment> + <LocalNotificationBanner notification={notification} /> + {accountIsTheCurrentUser ? ( + <ProfileNavigation + current="credentials" + routeMyAccountCashout={routeMyAccountCashout} + routeMyAccountDelete={routeMyAccountDelete} + routeMyAccountDetails={routeMyAccountDetails} + routeMyAccountPassword={routeMyAccountPassword} + routeConversionConfig={routeConversionConfig} + /> + ) : ( + <h1 class="text-base font-semibold leading-6 text-gray-900"> + <i18n.Translate>Account "{accountName}"</i18n.Translate> + </h1> + )} + + <div class="grid grid-cols-1 gap-x-8 gap-y-8 pt-6 md:grid-cols-3 bg-gray-100 my-4 px-4 pb-4 rounded-lg"> + <div class="px-4 sm:px-0"> + <h2 class="text-base font-semibold leading-7 text-gray-900"> + <i18n.Translate>Update password</i18n.Translate> + </h2> + </div> + <form + class="bg-white shadow-sm ring-1 ring-gray-900/5 sm:rounded-xl md:col-span-2" + autoCapitalize="none" + autoCorrect="off" + onSubmit={(e) => { + e.preventDefault(); + }} + > + <div class="px-4 py-6 sm:p-8"> + <div class="grid max-w-2xl grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6"> + {accountIsTheCurrentUser ? ( + <div class="sm:col-span-5"> + <label + class="block text-sm font-medium leading-6 text-gray-900" + for="password" + > + {i18n.str`Current password`} + <b style={{ color: "red" }}> *</b> + </label> + <div class="mt-2"> + <input + type="password" + class="block w-full 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="current" + id="current-password" + data-error={!!errors?.current && current !== undefined} + value={current ?? ""} + onChange={(e) => { + setCurrent(e.currentTarget.value); + }} + autocomplete="off" + /> + <ShowInputErrorLabel + message={errors?.current} + isDirty={current !== undefined} + /> + </div> + <p class="mt-2 text-sm text-gray-500"> + <i18n.Translate> + Your current password, for security + </i18n.Translate> + </p> + </div> + ) : undefined} + + <div class="sm:col-span-5"> + <label + class="block text-sm font-medium leading-6 text-gray-900" + for="password" + > + {i18n.str`New password`} + <b style={{ color: "red" }}> *</b> + </label> + <div class="mt-2"> + <input + ref={focus ? doAutoFocus : undefined} + type="password" + class="block w-full 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="password" + id="password" + data-error={!!errors?.password && password !== undefined} + value={password ?? ""} + onChange={(e) => { + setPassword(e.currentTarget.value); + }} + autocomplete="off" + /> + <ShowInputErrorLabel + message={errors?.password} + isDirty={password !== undefined} + /> + </div> + </div> + + <div class="sm:col-span-5"> + <label + class="block text-sm font-medium leading-6 text-gray-900" + for="repeat" + > + {i18n.str`Type it again`} + <b style={{ color: "red" }}> *</b> + </label> + <div class="mt-2"> + <input + type="password" + class="block w-full 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="repeat" + id="repeat" + data-error={!!errors?.repeat && repeat !== undefined} + value={repeat ?? ""} + onChange={(e) => { + setRepeat(e.currentTarget.value); + }} + // placeholder="" + autocomplete="off" + /> + <ShowInputErrorLabel + message={errors?.repeat} + isDirty={repeat !== undefined} + /> + </div> + <p class="mt-2 text-sm text-gray-500"> + <i18n.Translate>Repeat the same password</i18n.Translate> + </p> + </div> + </div> + </div> + <div class="flex items-center justify-between gap-x-6 border-t border-gray-900/10 px-4 py-4 sm:px-8"> + <a + href={routeClose.url({})} + name="cancel" + class="text-sm font-semibold leading-6 text-gray-900" + > + <i18n.Translate>Cancel</i18n.Translate> + </a> + <button + type="submit" + name="change" + class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" + disabled={!!errors} + onClick={(e) => { + e.preventDefault(); + doChangePassword(); + }} + > + <i18n.Translate>Change</i18n.Translate> + </button> + </div> + </form> + </div> + </Fragment> + ); +} diff --git a/packages/bank-ui/src/pages/admin/AccountForm.tsx b/packages/bank-ui/src/pages/admin/AccountForm.tsx new file mode 100644 index 000000000..ba5da609f --- /dev/null +++ b/packages/bank-ui/src/pages/admin/AccountForm.tsx @@ -0,0 +1,859 @@ +/* + This file is part of GNU Taler + (C) 2022-2024 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ +import { + AmountString, + Amounts, + PaytoString, + TalerCorebankApi, + assertUnreachable, + buildPayto, + parsePaytoUri, + stringifyPaytoUri, +} from "@gnu-taler/taler-util"; +import { + CopyButton, + ShowInputErrorLabel, + useTranslationContext, +} from "@gnu-taler/web-util/browser"; +import { ComponentChildren, VNode, h } from "preact"; +import { useState } from "preact/hooks"; +import { useBankCoreApiContext } from "@gnu-taler/web-util/browser"; +import { useSessionState } from "../../hooks/session.js"; +import { + ErrorMessageMappingFor, + TanChannel, + undefinedIfEmpty, + validateIBAN, + validateTalerBank, +} from "../../utils.js"; +import { + InputAmount, + TextField, + doAutoFocus, +} from "../PaytoWireTransferForm.js"; +import { getRandomPassword } from "../rnd.js"; + +const EMAIL_REGEX = + /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/; +const REGEX_JUST_NUMBERS_REGEX = /^\+[0-9 ]*$/; + +export type AccountFormData = { + debit_threshold?: string; + min_cashout?: string; + isExchange?: boolean; + isPublic?: boolean; + name?: string; + username?: string; + payto_uri?: string; + cashout_payto_uri?: string; + email?: string; + phone?: string; + tan_channel?: TanChannel | "remove"; +}; + +type ChangeByPurposeType = { + create: (a: TalerCorebankApi.RegisterAccountRequest | undefined) => void; + update: (a: TalerCorebankApi.AccountReconfiguration | undefined) => void; + show: undefined; +}; +/** + * FIXME: + * is_public is missing on PATCH + * account email/password should require 2FA + * + * + * @param param0 + * @returns + */ +export function AccountForm<PurposeType extends keyof ChangeByPurposeType>({ + template, + username, + purpose, + onChange, + focus, + children, +}: { + focus?: boolean; + children: ComponentChildren; + username?: string; + template: TalerCorebankApi.AccountData | undefined; + onChange: ChangeByPurposeType[PurposeType]; + purpose: PurposeType; +}): VNode { + const { config, url } = useBankCoreApiContext(); + const { i18n } = useTranslationContext(); + const { state: credentials } = useSessionState(); + const [form, setForm] = useState<AccountFormData>({}); + + const [errors, setErrors] = useState< + ErrorMessageMappingFor<typeof defaultValue> | undefined + >(undefined); + + const paytoType = + config.wire_type === "X_TALER_BANK" + ? ("x-taler-bank" as const) + : ("iban" as const); + const cashoutPaytoType: typeof paytoType = "iban" as const; + + const defaultValue: AccountFormData = { + debit_threshold: Amounts.stringifyValue( + template?.debit_threshold ?? config.default_debit_threshold, + ), + min_cashout: Amounts.stringifyValue( + template?.min_cashout ?? `${config.currency}:0`, + ), + isExchange: template?.is_taler_exchange, + isPublic: template?.is_public, + name: template?.name ?? "", + cashout_payto_uri: + getAccountId(cashoutPaytoType, template?.cashout_payto_uri) ?? + ("" as PaytoString), + payto_uri: + getAccountId(paytoType, template?.payto_uri) ?? ("" as PaytoString), + email: template?.contact_data?.email ?? "", + phone: template?.contact_data?.phone ?? "", + username: username ?? "", + tan_channel: template?.tan_channel, + }; + + const userIsAdmin = + credentials.status !== "loggedIn" ? false : credentials.isUserAdministrator; + + const editableUsername = purpose === "create"; + const editableName = + purpose === "create" || + (purpose === "update" && (config.allow_edit_name || userIsAdmin)); + + const isCashoutEnabled = config.allow_conversion; + const editableCashout = + purpose === "create" || + (purpose === "update" && + (config.allow_edit_cashout_payto_uri || userIsAdmin)); + const editableThreshold = + userIsAdmin && (purpose === "create" || purpose === "update"); + const editableMinCashout = + userIsAdmin && (purpose === "create" || purpose === "update"); + const editableAccount = purpose === "create" && userIsAdmin; + + function updateForm(newForm: typeof defaultValue): void { + const trimmedMinCashoutStr = newForm.min_cashout?.trim(); + const parsedMinCashout = Amounts.parse( + `${config.currency}:${trimmedMinCashoutStr}`, + ); + const trimmedDebitThresholdStr = newForm.debit_threshold?.trim(); + const parsedDebitThreshold = Amounts.parse( + `${config.currency}:${trimmedDebitThresholdStr}`, + ); + + const errors = undefinedIfEmpty< + ErrorMessageMappingFor<typeof defaultValue> + >({ + cashout_payto_uri: !newForm.cashout_payto_uri + ? undefined + : !editableCashout + ? undefined + : !newForm.cashout_payto_uri + ? undefined + : cashoutPaytoType === "iban" + ? validateIBAN(newForm.cashout_payto_uri, i18n) + : cashoutPaytoType === "x-taler-bank" + ? validateTalerBank(newForm.cashout_payto_uri, i18n) + : undefined, + + payto_uri: !newForm.payto_uri + ? undefined + : !editableAccount + ? undefined + : !newForm.payto_uri + ? undefined + : paytoType === "iban" + ? validateIBAN(newForm.payto_uri, i18n) + : paytoType === "x-taler-bank" + ? validateTalerBank(newForm.payto_uri, i18n) + : undefined, + + email: !newForm.email + ? undefined + : !EMAIL_REGEX.test(newForm.email) + ? i18n.str`Doesn't have the pattern of an email` + : undefined, + phone: !newForm.phone + ? undefined + : !newForm.phone.startsWith("+") // FIXME: better phone number check + ? i18n.str`Should start with +` + : !REGEX_JUST_NUMBERS_REGEX.test(newForm.phone) + ? i18n.str`Phone number can't have other than numbers` + : undefined, + debit_threshold: !editableThreshold + ? undefined + : !trimmedDebitThresholdStr + ? undefined + : !parsedDebitThreshold + ? i18n.str`Not valid` + : undefined, + min_cashout: !editableMinCashout + ? undefined + : !trimmedMinCashoutStr + ? undefined + : !parsedMinCashout + ? i18n.str`Not valid` + : undefined, + name: !editableName + ? undefined // disabled + : purpose === "update" && newForm.name === undefined ? undefined // the field hasn't been changed + : !newForm.name + ? i18n.str`Required` + : undefined, + username: !editableUsername + ? undefined + : !newForm.username + ? i18n.str`Required` + : undefined, + }); + setErrors(errors); + + setForm(newForm); + if (!onChange) return; + + if (errors) { + onChange(undefined); + } else { + let cashout; + if (newForm.cashout_payto_uri) + switch (cashoutPaytoType) { + case "x-taler-bank": { + cashout = buildPayto( + "x-taler-bank", + url.host, + newForm.cashout_payto_uri, + ); + break; + } + case "iban": { + cashout = buildPayto("iban", newForm.cashout_payto_uri, undefined); + break; + } + default: + assertUnreachable(cashoutPaytoType); + } + const cashoutURI = !cashout ? undefined : stringifyPaytoUri(cashout); + let internal; + if (newForm.payto_uri) + switch (paytoType) { + case "x-taler-bank": { + internal = buildPayto("x-taler-bank", url.host, newForm.payto_uri); + break; + } + case "iban": { + internal = buildPayto("iban", newForm.payto_uri, undefined); + break; + } + default: + assertUnreachable(paytoType); + } + const internalURI = !internal ? undefined : stringifyPaytoUri(internal); + + const threshold = !parsedDebitThreshold + ? undefined + : Amounts.stringify(parsedDebitThreshold); + const minCashout = !parsedMinCashout + ? undefined + : Amounts.stringify(parsedMinCashout); + + switch (purpose) { + case "create": { + // typescript doesn't correctly narrow a generic type + const callback = onChange as ChangeByPurposeType["create"]; + const result: TalerCorebankApi.RegisterAccountRequest = { + name: newForm.name!, + password: getRandomPassword(), + username: newForm.username!, + contact_data: undefinedIfEmpty({ + email: !newForm.email ? undefined : newForm.email, + phone: !newForm.phone ? undefined : newForm.phone, + }), + debit_threshold: threshold ?? config.default_debit_threshold, + min_cashout: minCashout, + cashout_payto_uri: cashoutURI, + payto_uri: internalURI, + is_public: newForm.isPublic, + is_taler_exchange: newForm.isExchange, + tan_channel: + newForm.tan_channel === "remove" + ? undefined + : newForm.tan_channel, + }; + callback(result); + return; + } + case "update": { + // typescript doesn't correctly narrow a generic type + const callback = onChange as ChangeByPurposeType["update"]; + + const result: TalerCorebankApi.AccountReconfiguration = { + cashout_payto_uri: cashoutURI, + contact_data: undefinedIfEmpty({ + email: !newForm.email ? undefined : newForm.email, + phone: !newForm.phone ? undefined : newForm.phone, + }), + debit_threshold: threshold, + min_cashout: minCashout, + is_public: newForm.isPublic, + name: newForm.name, + tan_channel: + newForm.tan_channel === "remove" ? null : newForm.tan_channel, + }; + callback(result); + return; + } + case "show": { + return; + } + default: { + assertUnreachable(purpose); + } + } + } + } + return ( + <form + class="bg-white shadow-sm ring-1 ring-gray-900/5 sm:rounded-xl md:col-span-2" + autoCapitalize="none" + autoCorrect="off" + onSubmit={(e) => { + e.preventDefault(); + }} + > + <div class="px-4 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"> + <label + class="block text-sm font-medium leading-6 text-gray-900" + for="username" + > + {i18n.str`Login username`} + {editableUsername && <b style={{ color: "red" }}> *</b>} + </label> + <div class="mt-2"> + <input + ref={focus && purpose === "create" ? doAutoFocus : undefined} + type="text" + 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="username" + id="username" + data-error={!!errors?.username && form.username !== undefined} + disabled={!editableUsername} + value={form.username ?? defaultValue.username} + onChange={(e) => { + form.username = e.currentTarget.value; + updateForm(structuredClone(form)); + }} + // placeholder="" + autocomplete="off" + /> + <ShowInputErrorLabel + message={errors?.username} + isDirty={form.username !== undefined} + /> + </div> + <p class="mt-2 text-sm text-gray-500"> + <i18n.Translate>Account id for authentication</i18n.Translate> + </p> + </div> + + <div class="sm:col-span-5"> + <label + class="block text-sm font-medium leading-6 text-gray-900" + for="name" + > + {i18n.str`Full name`} + {editableName && <b style={{ color: "red" }}> *</b>} + </label> + <div class="mt-2"> + <input + type="text" + 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="name" + data-error={!!errors?.name && form.name !== undefined} + id="name" + disabled={!editableName} + value={form.name ?? defaultValue.name} + onChange={(e) => { + form.name = e.currentTarget.value; + updateForm(structuredClone(form)); + }} + // placeholder="" + autocomplete="off" + /> + <ShowInputErrorLabel + message={errors?.name} + isDirty={form.name !== undefined} + /> + </div> + <p class="mt-2 text-sm text-gray-500"> + <i18n.Translate>Name of the account holder</i18n.Translate> + </p> + </div> + + {purpose === "create" ? undefined : ( + <TextField + id="internal-account" + label={i18n.str`Internal account`} + help={ + purpose === "create" + ? i18n.str`If empty a random account id will be assigned` + : i18n.str`Share this id to receive bank transfers` + } + error={errors?.payto_uri} + onChange={(e) => { + form.payto_uri = e as PaytoString; + updateForm(structuredClone(form)); + }} + rightIcons={ + <CopyButton + class="p-2 rounded-full text-black shadow-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 " + getContent={() => + form.payto_uri ?? defaultValue.payto_uri ?? "" + } + /> + } + value={(form.payto_uri ?? defaultValue.payto_uri) as PaytoString} + disabled={!editableAccount} + /> + )} + + <div class="sm:col-span-5"> + <label + class="block text-sm font-medium leading-6 text-gray-900" + for="email" + > + {i18n.str`Email`} + </label> + <div class="mt-2"> + <input + type="email" + 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?.email && form.email !== undefined} + disabled={purpose === "show"} + value={form.email ?? defaultValue.email} + onChange={(e) => { + form.email = e.currentTarget.value; + updateForm(structuredClone(form)); + }} + autocomplete="off" + /> + <ShowInputErrorLabel + message={errors?.email} + isDirty={form.email !== undefined} + /> + </div> + <p class="mt-2 text-sm text-gray-500"> + <i18n.Translate> + To be used when second factor authentication is enabled + </i18n.Translate> + </p> + </div> + + <div class="sm:col-span-5"> + <label + class="block text-sm font-medium leading-6 text-gray-900" + for="phone" + > + {i18n.str`Phone`} + </label> + <div class="mt-2"> + <input + type="text" + 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="phone" + id="phone" + disabled={purpose === "show"} + value={form.phone ?? defaultValue.phone} + data-error={!!errors?.phone && form.phone !== undefined} + onChange={(e) => { + form.phone = e.currentTarget.value; + updateForm(structuredClone(form)); + }} + autocomplete="off" + /> + <ShowInputErrorLabel + message={errors?.phone} + isDirty={form.phone !== undefined} + /> + </div> + <p class="mt-2 text-sm text-gray-500"> + <i18n.Translate> + To be used when second factor authentication is enabled + </i18n.Translate> + </p> + </div> + + {isCashoutEnabled && ( + <TextField + id="cashout-account" + label={i18n.str`Cashout account`} + help={i18n.str`External account number where the money is going to be sent when doing cashouts`} + error={errors?.cashout_payto_uri} + onChange={(e) => { + form.cashout_payto_uri = e as PaytoString; + updateForm(structuredClone(form)); + }} + value={ + (form.cashout_payto_uri ?? + defaultValue.cashout_payto_uri) as PaytoString + } + disabled={!editableCashout} + /> + )} + + <div class="sm:col-span-5"> + <label + for="debit" + class="block text-sm font-medium leading-6 text-gray-900" + >{i18n.str`Max debt`}</label> + <InputAmount + name="debit" + left + currency={config.currency} + value={form.debit_threshold ?? defaultValue.debit_threshold} + onChange={ + !editableThreshold + ? undefined + : (e) => { + form.debit_threshold = e as AmountString; + updateForm(structuredClone(form)); + } + } + /> + <ShowInputErrorLabel + message={ + errors?.debit_threshold + ? String(errors?.debit_threshold) + : undefined + } + isDirty={form.debit_threshold !== undefined} + /> + <p class="mt-2 text-sm text-gray-500"> + <i18n.Translate> + How much the balance can go below zero. + </i18n.Translate> + </p> + </div> + + <div class="sm:col-span-5"> + <label + for="minCashout" + class="block text-sm font-medium leading-6 text-gray-900" + >{i18n.str`Minimum cashout`}</label> + <InputAmount + name="minCashout" + left + currency={config.currency} + value={form.min_cashout ?? defaultValue.min_cashout} + onChange={ + !editableMinCashout + ? undefined + : (e) => { + form.min_cashout = e as AmountString; + updateForm(structuredClone(form)); + } + } + /> + <ShowInputErrorLabel + message={ + errors?.min_cashout ? String(errors?.min_cashout) : undefined + } + isDirty={form.min_cashout !== undefined} + /> + <p class="mt-2 text-sm text-gray-500"> + <i18n.Translate> + Custom minimum cashout amount for this account. + </i18n.Translate> + </p> + </div> + + <div class="sm:col-span-5"> + <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>Is this account public?</i18n.Translate> + </span> + </span> + <button + type="button" + name="is public" + data-enabled={ + form.isPublic ?? defaultValue.isPublic ? "true" : "false" + } + 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={() => { + form.isPublic = !(form.isPublic ?? defaultValue.isPublic); + updateForm(structuredClone(form)); + }} + > + <span + aria-hidden="true" + data-enabled={ + form.isPublic ?? defaultValue.isPublic ? "true" : "false" + } + class="translate-x-5 data-[enabled=false]: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> + <p class="mt-2 text-sm text-gray-500"> + <i18n.Translate> + Public accounts have their balance publicly accessible + </i18n.Translate> + </p> + </div> + + {purpose !== "create" || !userIsAdmin ? undefined : ( + <div class="sm:col-span-5"> + <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> + Is this account a payment provider? + </i18n.Translate> + </span> + </span> + <button + type="button" + name="is exchange" + data-enabled={ + form.isExchange ?? defaultValue.isExchange + ? "true" + : "false" + } + 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={() => { + form.isExchange = !form.isExchange; + updateForm(structuredClone(form)); + }} + > + <span + aria-hidden="true" + data-enabled={ + form.isExchange ?? defaultValue.isExchange + ? "true" + : "false" + } + class="translate-x-5 data-[enabled=false]: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> + </div> + {children} + </form> + ); +} + +function getAccountId( + type: "iban" | "x-taler-bank", + s: PaytoString | undefined, +): string | undefined { + if (s === undefined) return undefined; + const p = parsePaytoUri(s); + if (p === undefined) return undefined; + if (!p.isKnown) return "<unknown>"; + if (type === "iban" && p.targetType === "iban") return p.iban; + if (type === "x-taler-bank" && p.targetType === "x-taler-bank") + return p.account; + return "<unsupported>"; +} + +{ + /* <div class="sm:col-span-5"> + <label + class="block text-sm font-medium leading-6 text-gray-900" + for="cashout" + > + {} + </label> + <div class="mt-2"> + <input + type="text" + ref={focus && purpose === "update" ? doAutoFocus : 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_payto_uri ?? defaultValue.cashout_payto_uri} + onChange={(e) => { + form.cashout_payto_uri = e.currentTarget.value as PaytoString; + if (!form.cashout_payto_uri) { + form.cashout_payto_uri = undefined + } + updateForm(structuredClone(form)); + }} + autocomplete="off" + /> + <ShowInputErrorLabel + message={errors?.cashout_payto_uri} + isDirty={form.cashout_payto_uri !== undefined} + /> + </div> + <p class="mt-2 text-sm text-gray-500" > + <i18n.Translate></i18n.Translate> + </p> + </div> */ +} + +// function PaytoField({ +// name, +// label, +// help, +// type, +// value, +// disabled, +// onChange, +// error, +// }: { +// error: TranslatedString | undefined; +// name: string; +// label: TranslatedString; +// help: TranslatedString; +// onChange: (s: string) => void; +// type: "iban" | "x-taler-bank" | "bitcoin"; +// disabled?: boolean; +// value: string | undefined; +// }): VNode { +// if (type === "iban") { +// return ( +// <div class="sm:col-span-5"> +// <label +// class="block text-sm font-medium leading-6 text-gray-900" +// for={name} +// > +// {label} +// </label> +// <div class="mt-2"> +// <div class="flex justify-between"> +// <input +// type="text" +// class="mr-4 w-full block-inline 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={name} +// id={name} +// disabled={disabled} +// value={value ?? ""} +// onChange={(e) => { +// onChange(e.currentTarget.value); +// }} +// /> +// <CopyButton +// class="p-2 rounded-full text-black shadow-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 " +// getContent={() => value ?? ""} +// /> +// </div> +// <ShowInputErrorLabel message={error} isDirty={value !== undefined} /> +// </div> +// <p class="mt-2 text-sm text-gray-500">{help}</p> +// </div> +// ); +// } +// if (type === "x-taler-bank") { +// return ( +// <div class="sm:col-span-5"> +// <label +// class="block text-sm font-medium leading-6 text-gray-900" +// for={name} +// > +// {label} +// </label> +// <div class="mt-2"> +// <div class="flex justify-between"> +// <input +// type="text" +// class="mr-4 w-full block-inline 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={name} +// id={name} +// disabled={disabled} +// value={value ?? ""} +// onChange={(e) => { +// onChange(e.currentTarget.value); +// }} +// /> +// <CopyButton +// class="p-2 rounded-full text-black shadow-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 " +// getContent={() => value ?? ""} +// /> +// </div> +// <ShowInputErrorLabel message={error} isDirty={value !== undefined} /> +// </div> +// <p class="mt-2 text-sm text-gray-500"> +// {help} +// </p> +// </div> +// ); +// } +// if (type === "bitcoin") { +// return ( +// <div class="sm:col-span-5"> +// <label +// class="block text-sm font-medium leading-6 text-gray-900" +// for={name} +// > +// {label} +// </label> +// <div class="mt-2"> +// <div class="flex justify-between"> +// <input +// type="text" +// class="mr-4 w-full block-inline 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={name} +// id={name} +// disabled={disabled} +// value={value ?? ""} +// /> +// <CopyButton +// class="p-2 rounded-full text-black shadow-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 " +// getContent={() => value ?? ""} +// /> +// <ShowInputErrorLabel +// message={error} +// isDirty={value !== undefined} +// /> +// </div> +// </div> +// <p class="mt-2 text-sm text-gray-500"> +// {/* <i18n.Translate>bitcoin address</i18n.Translate> */} +// {help} +// </p> +// </div> +// ); +// } +// assertUnreachable(type); +// } diff --git a/packages/bank-ui/src/pages/admin/AccountList.tsx b/packages/bank-ui/src/pages/admin/AccountList.tsx new file mode 100644 index 000000000..6402c2bcd --- /dev/null +++ b/packages/bank-ui/src/pages/admin/AccountList.tsx @@ -0,0 +1,234 @@ +/* + This file is part of GNU Taler + (C) 2022-2024 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ +import { + Amounts, + HttpStatusCode, + TalerError, + assertUnreachable, +} from "@gnu-taler/taler-util"; +import { Loading, useTranslationContext } from "@gnu-taler/web-util/browser"; +import { Fragment, VNode, h } from "preact"; +import { ErrorLoadingWithDebug } from "../../components/ErrorLoadingWithDebug.js"; +import { useBankCoreApiContext } from "@gnu-taler/web-util/browser"; +import { useBusinessAccounts } from "../../hooks/regional.js"; +import { RouteDefinition } from "@gnu-taler/web-util/browser"; +import { RenderAmount } from "../PaytoWireTransferForm.js"; + +interface Props { + routeCreate: RouteDefinition; + + routeShowAccount: RouteDefinition<{ account: string }>; + routeRemoveAccount: RouteDefinition<{ account: string }>; + routeUpdatePasswordAccount: RouteDefinition<{ account: string }>; +} + +export function AccountList({ + routeCreate, + routeRemoveAccount, + routeShowAccount, + routeUpdatePasswordAccount, +}: Props): VNode { + const result = useBusinessAccounts(); + const { i18n } = useTranslationContext(); + const { config } = useBankCoreApiContext(); + + if (!result) { + return <Loading />; + } + if (result instanceof TalerError) { + return <ErrorLoadingWithDebug error={result} />; + } + if (result.type === "fail") { + switch (result.case) { + case HttpStatusCode.Unauthorized: + return <Fragment />; + default: + assertUnreachable(result.case); + } + } + + const onGoStart = result.isFirstPage ? undefined : result.loadFirst; + const onGoNext = result.isLastPage ? undefined : result.loadNext; + + const accounts = result.body; + return ( + <Fragment> + <div class="px-4 sm:px-6 lg:px-8 mt-8"> + <div class="sm:flex sm:items-center"> + <div class="sm:flex-auto"> + <h1 class="text-base font-semibold leading-6 text-gray-900"> + <i18n.Translate>Accounts</i18n.Translate> + </h1> + </div> + <div class="mt-4 sm:ml-16 sm:mt-0 sm:flex-none"> + <a + href={routeCreate.url({})} + name="create account" + type="button" + class="block rounded-md bg-indigo-600 px-3 py-2 text-center text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" + > + <i18n.Translate>Create account</i18n.Translate> + </a> + </div> + </div> + <div class="mt-4 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"> + {!accounts.length ? ( + <div>{/* FIXME: ADD empty list */}</div> + ) : ( + <table class="min-w-full divide-y divide-gray-300"> + <thead> + <tr> + <th + scope="col" + class="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 sm:pl-0" + >{i18n.str`Username`}</th> + <th + scope="col" + class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900" + >{i18n.str`Name`}</th> + <th + scope="col" + class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900" + >{i18n.str`Balance`}</th> + <th scope="col" class="relative py-3.5 pl-3 pr-4 sm:pr-0"> + <span class="sr-only">{i18n.str`Actions`}</span> + </th> + </tr> + </thead> + <tbody class="divide-y divide-gray-200"> + {accounts.map((item, idx) => { + const balance = !item.balance + ? undefined + : Amounts.parse(item.balance.amount); + const noBalance = Amounts.isZero(item.balance.amount); + const balanceIsDebit = + item.balance && + item.balance.credit_debit_indicator == "debit"; + + return ( + <tr key={idx}> + <td class="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-gray-900 sm:pl-0"> + <a + name={`show account ${item.username}`} + href={routeShowAccount.url({ + account: item.username, + })} + class="text-indigo-600 hover:text-indigo-900" + > + {item.username} + </a> + </td> + <td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500"> + {item.name} + </td> + <td + data-negative={ + noBalance + ? undefined + : balanceIsDebit + ? "true" + : "false" + } + class="whitespace-nowrap px-3 py-4 text-sm text-gray-500 data-[negative=false]:text-green-600 data-[negative=true]:text-red-600 " + > + {!balance ? ( + i18n.str`Unknown` + ) : ( + <span class="amount"> + <RenderAmount + value={balance} + negative={balanceIsDebit} + spec={config.currency_specification} + /> + </span> + )} + </td> + <td class="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-0"> + <a + name={`update password ${item.username}`} + href={routeUpdatePasswordAccount.url({ + account: item.username, + })} + class="text-indigo-600 hover:text-indigo-900" + > + <i18n.Translate>Change password</i18n.Translate> + </a> + <br /> + {/* {config.allow_conversion ? + <Fragment> + + <a + name={`show cashout ${item.username}`} + href={routeShowCashoutsAccount.url({ + account: item.username, + })} + class="text-indigo-600 hover:text-indigo-900" + > + <i18n.Translate>Cashouts</i18n.Translate> + </a> + <br /> + </Fragment> + : undefined} */} + {noBalance ? ( + <a + name={`remove account ${item.username}`} + href={routeRemoveAccount.url({ + account: item.username, + })} + class="text-indigo-600 hover:text-indigo-900" + > + <i18n.Translate>Remove</i18n.Translate> + </a> + ) : undefined} + </td> + </tr> + ); + })} + </tbody> + </table> + )} + </div> + <nav + class="flex items-center justify-between border-t border-gray-200 bg-white px-4 py-3 sm:px-6 rounded-lg" + aria-label="Pagination" + > + <div class="flex flex-1 justify-between sm:justify-end"> + <button + name="first page" + class="relative disabled:bg-gray-100 disabled:text-gray-500 inline-flex items-center rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus-visible:outline-offset-0" + disabled={!onGoStart} + onClick={onGoStart} + > + <i18n.Translate>First page</i18n.Translate> + </button> + <button + name="next page" + class="relative disabled:bg-gray-100 disabled:text-gray-500 ml-3 inline-flex items-center rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus-visible:outline-offset-0" + disabled={!onGoNext} + onClick={onGoNext} + > + <i18n.Translate>Next</i18n.Translate> + </button> + </div> + </nav> + </div> + </div> + </div> + </Fragment> + ); +} diff --git a/packages/bank-ui/src/pages/admin/AdminHome.tsx b/packages/bank-ui/src/pages/admin/AdminHome.tsx new file mode 100644 index 000000000..34c121235 --- /dev/null +++ b/packages/bank-ui/src/pages/admin/AdminHome.tsx @@ -0,0 +1,622 @@ +/* + This file is part of GNU Taler + (C) 2022-2024 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ +import { + AbsoluteTime, + AmountString, + Amounts, + CurrencySpecification, + HttpStatusCode, + TalerCorebankApi, + TalerError, + assertUnreachable, +} from "@gnu-taler/taler-util"; +import { + Attention, + RouteDefinition, + useBankCoreApiContext, + useTranslationContext, +} from "@gnu-taler/web-util/browser"; +import { + format, + sub +} from "date-fns"; +import { Fragment, VNode, h } from "preact"; +import { useState } from "preact/hooks"; +import { ErrorLoadingWithDebug } from "../../components/ErrorLoadingWithDebug.js"; +import { Transactions } from "../../components/Transactions/index.js"; +import { useConversionInfo, useLastMonitorInfo } from "../../hooks/regional.js"; +import { RenderAmount } from "../PaytoWireTransferForm.js"; +import { WireTransfer } from "../WireTransfer.js"; +import { AccountList } from "./AccountList.js"; + +/** + * Query account information and show QR code if there is pending withdrawal + */ +interface Props { + routeCreate: RouteDefinition; + routeDownloadStats: RouteDefinition; + routeCreateWireTransfer: RouteDefinition<{ + account?: string; + subject?: string; + amount?: string; + }>; + + routeShowAccount: RouteDefinition<{ account: string }>; + routeRemoveAccount: RouteDefinition<{ account: string }>; + routeUpdatePasswordAccount: RouteDefinition<{ account: string }>; + routeShowCashoutsAccount: RouteDefinition<{ account: string }>; + onAuthorizationRequired: () => void; +} +export function AdminHome({ + routeCreate, + routeRemoveAccount, + routeShowAccount, + routeUpdatePasswordAccount, + routeDownloadStats, + routeCreateWireTransfer, + onAuthorizationRequired, +}: Props): VNode { + return ( + <Fragment> + <Metrics routeDownloadStats={routeDownloadStats} /> + <WireTransfer + routeHere={routeCreateWireTransfer} + onAuthorizationRequired={onAuthorizationRequired} + /> + <Transactions + account="admin" + routeCreateWireTransfer={routeCreateWireTransfer} + /> + <AccountList + routeCreate={routeCreate} + routeRemoveAccount={routeRemoveAccount} + routeShowAccount={routeShowAccount} + routeUpdatePasswordAccount={routeUpdatePasswordAccount} + /> + </Fragment> + ); +} + +function getDateForTimeframe( + date: AbsoluteTime, + timeframe: TalerCorebankApi.MonitorTimeframeParam, + locale: Locale, +): string { + if (date.t_ms === "never") return "--"; + switch (timeframe) { + case TalerCorebankApi.MonitorTimeframeParam.hour: + return `${format(date.t_ms, "HH", { locale })}hs`; + case TalerCorebankApi.MonitorTimeframeParam.day: + return format(date.t_ms, "EEEE", { locale }); + case TalerCorebankApi.MonitorTimeframeParam.month: + return format(date.t_ms, "MMMM", { locale }); + case TalerCorebankApi.MonitorTimeframeParam.year: + return format(date.t_ms, "yyyy", { locale }); + case TalerCorebankApi.MonitorTimeframeParam.decade: + return format(date.t_ms, "yyyy", { locale }); + } + assertUnreachable(timeframe); +} + +export function getTimeframesForDate( + time: Date, + timeframe: TalerCorebankApi.MonitorTimeframeParam, +): { current: AbsoluteTime; previous: AbsoluteTime } { + switch (timeframe) { + case TalerCorebankApi.MonitorTimeframeParam.hour: + return { + current: AbsoluteTime.fromMilliseconds( + sub(time, { hours: 1 }).getTime(), + ), + previous: AbsoluteTime.fromMilliseconds( + sub(time, { hours: 2 }).getTime(), + ), + }; + case TalerCorebankApi.MonitorTimeframeParam.day: + return { + current: AbsoluteTime.fromMilliseconds( + sub(time, { days: 1 }).getTime(), + ), + previous: AbsoluteTime.fromMilliseconds( + sub(time, { days: 4 }).getTime(), + ), + }; + case TalerCorebankApi.MonitorTimeframeParam.month: + return { + current: AbsoluteTime.fromMilliseconds( + sub(time, { months: 1 }).getTime(), + ), + previous: AbsoluteTime.fromMilliseconds( + sub(time, { months: 2 }).getTime(), + ), + }; + case TalerCorebankApi.MonitorTimeframeParam.year: + return { + current: AbsoluteTime.fromMilliseconds( + sub(time, { years: 1 }).getTime(), + ), + previous: AbsoluteTime.fromMilliseconds( + sub(time, { years: 2 }).getTime(), + ), + }; + case TalerCorebankApi.MonitorTimeframeParam.decade: + return { + current: AbsoluteTime.fromMilliseconds( + sub(time, { years: 10 }).getTime(), + ), + previous: AbsoluteTime.fromMilliseconds( + sub(time, { years: 20 }).getTime(), + ), + }; + default: + assertUnreachable(timeframe); + } +} + +function Metrics({ + routeDownloadStats, +}: { + routeDownloadStats: RouteDefinition; +}): VNode { + const { i18n, dateLocale } = useTranslationContext(); + const [metricType, setMetricType] = + useState<TalerCorebankApi.MonitorTimeframeParam>( + TalerCorebankApi.MonitorTimeframeParam.hour, + ); + const { config } = useBankCoreApiContext(); + const respInfo = useConversionInfo(); + const params = getTimeframesForDate(new Date(), metricType); + + const resp = useLastMonitorInfo(params.current, params.previous, metricType); + if (!resp) return <Fragment />; + if (resp instanceof TalerError) { + return <ErrorLoadingWithDebug error={resp} />; + } + if (!respInfo) return <Fragment />; + if (respInfo instanceof TalerError) { + return <ErrorLoadingWithDebug error={respInfo} />; + } + if (respInfo.type === "fail") { + switch (respInfo.case) { + case HttpStatusCode.NotImplemented: { + return ( + <Attention type="danger" title={i18n.str`Cashout are disabled`}> + <i18n.Translate> + Cashout should be enable by configuration and the conversion rate + should be initialized with fee, ratio and rounding mode. + </i18n.Translate> + </Attention> + ); + } + default: { + assertUnreachable(respInfo.case); + } + } + } + + if (resp.current.type !== "ok") { + switch (resp.current.case) { + case HttpStatusCode.BadRequest: + return ( + <Attention + type="warning" + title={i18n.str`Querying for the current stats failed`} + > + <i18n.Translate>The request parameters are wrong</i18n.Translate> + </Attention> + ); + case HttpStatusCode.Unauthorized: + return ( + <Attention + type="warning" + title={i18n.str`Querying for the current stats failed`} + > + <i18n.Translate>The user is unauthorized</i18n.Translate> + </Attention> + ); + default: { + assertUnreachable(resp.current); + } + } + } + if (resp.previous.type !== "ok") { + switch (resp.previous.case) { + case HttpStatusCode.BadRequest: + return ( + <Attention + type="warning" + title={i18n.str`Querying for the previous stats failed`} + > + <i18n.Translate>The request parameters are wrong</i18n.Translate> + </Attention> + ); + case HttpStatusCode.Unauthorized: + return ( + <Attention + type="warning" + title={i18n.str`Querying for the previous stats failed`} + > + <i18n.Translate>The user is unauthorized</i18n.Translate> + </Attention> + ); + default: { + assertUnreachable(resp.previous); + } + } + } + return ( + <div class="px-4 mt-4"> + <div class="sm:flex sm:items-center mb-4"> + <div class="sm:flex-auto"> + <h1 class="text-base font-semibold leading-6 text-gray-900"> + <i18n.Translate>Transaction volume report</i18n.Translate> + </h1> + </div> + </div> + + <div class="sm:hidden"> + <label for="tabs" class="sr-only"> + <i18n.Translate>Select a section</i18n.Translate> + </label> + <select + id="tabs" + name="tabs" + class="block w-full rounded-md border-gray-300 focus:border-indigo-500 focus:ring-indigo-500" + onChange={(e) => { + setMetricType( + parseInt(e.currentTarget + .value, 10) as TalerCorebankApi.MonitorTimeframeParam, + ); + }} + > + <option + value={TalerCorebankApi.MonitorTimeframeParam.hour} + selected={metricType == TalerCorebankApi.MonitorTimeframeParam.hour} + > + <i18n.Translate>Last hour</i18n.Translate> + </option> + <option + value={TalerCorebankApi.MonitorTimeframeParam.day} + selected={metricType == TalerCorebankApi.MonitorTimeframeParam.day} + > + <i18n.Translate>Previous day</i18n.Translate> + </option> + <option + value={TalerCorebankApi.MonitorTimeframeParam.month} + selected={ + metricType == TalerCorebankApi.MonitorTimeframeParam.month + } + > + <i18n.Translate>Last month</i18n.Translate> + </option> + <option + value={TalerCorebankApi.MonitorTimeframeParam.year} + selected={metricType == TalerCorebankApi.MonitorTimeframeParam.year} + > + <i18n.Translate>Last year</i18n.Translate> + </option> + </select> + </div> + <div class="hidden sm:block"> + {/* FIXME: This should be LINKS */} + <nav + class="isolate flex divide-x divide-gray-200 rounded-lg shadow" + aria-label="Tabs" + > + <button + type="button" + name="set last hour" + onClick={(e) => { + e.preventDefault(); + setMetricType(TalerCorebankApi.MonitorTimeframeParam.hour); + }} + data-selected={ + metricType == TalerCorebankApi.MonitorTimeframeParam.hour + } + class="rounded-l-lg text-gray-500 hover:text-gray-700 data-[selected=true]:text-gray-900 group relative min-w-0 flex-1 overflow-hidden bg-white py-4 px-4 text-center text-sm font-medium hover:bg-gray-50 focus:z-10" + > + <span> + <i18n.Translate>Last hour</i18n.Translate> + </span> + <span + aria-hidden="true" + data-selected={ + metricType == TalerCorebankApi.MonitorTimeframeParam.hour + } + class="bg-transparent data-[selected=true]:bg-indigo-500 absolute inset-x-0 bottom-0 h-0.5" + ></span> + </button> + <button + type="button" + name="set previous day" + onClick={(e) => { + e.preventDefault(); + setMetricType(TalerCorebankApi.MonitorTimeframeParam.day); + }} + data-selected={ + metricType == TalerCorebankApi.MonitorTimeframeParam.day + } + class=" text-gray-500 hover:text-gray-700 data-[selected=true]:text-gray-900 group relative min-w-0 flex-1 overflow-hidden bg-white py-4 px-4 text-center text-sm font-medium hover:bg-gray-50 focus:z-10" + > + <span> + <i18n.Translate>Previous day</i18n.Translate> + </span> + <span + aria-hidden="true" + data-selected={ + metricType == TalerCorebankApi.MonitorTimeframeParam.day + } + class="bg-transparent data-[selected=true]:bg-indigo-500 absolute inset-x-0 bottom-0 h-0.5" + ></span> + </button> + <button + type="button" + name="set last month" + onClick={(e) => { + e.preventDefault(); + setMetricType(TalerCorebankApi.MonitorTimeframeParam.month); + }} + data-selected={ + metricType == TalerCorebankApi.MonitorTimeframeParam.month + } + class="rounded-r-lg text-gray-500 hover:text-gray-700 data-[selected=true]:text-gray-900 group relative min-w-0 flex-1 overflow-hidden bg-white py-4 px-4 text-center text-sm font-medium hover:bg-gray-50 focus:z-10" + > + <span> + <i18n.Translate>Last month</i18n.Translate> + </span> + <span + aria-hidden="true" + data-selected={ + metricType == TalerCorebankApi.MonitorTimeframeParam.month + } + class="bg-transparent data-[selected=true]:bg-indigo-500 absolute inset-x-0 bottom-0 h-0.5" + ></span> + </button> + <button + type="button" + name="set last year" + onClick={(e) => { + e.preventDefault(); + setMetricType(TalerCorebankApi.MonitorTimeframeParam.year); + }} + data-selected={ + metricType == TalerCorebankApi.MonitorTimeframeParam.year + } + class="rounded-r-lg text-gray-500 hover:text-gray-700 data-[selected=true]:text-gray-900 group relative min-w-0 flex-1 overflow-hidden bg-white py-4 px-4 text-center text-sm font-medium hover:bg-gray-50 focus:z-10" + > + <span> + <i18n.Translate>Last Year</i18n.Translate> + </span> + <span + aria-hidden="true" + data-selected={ + metricType == TalerCorebankApi.MonitorTimeframeParam.year + } + class="bg-transparent data-[selected=true]:bg-indigo-500 absolute inset-x-0 bottom-0 h-0.5" + ></span> + </button> + </nav> + </div> + + <div class="w-full flex justify-between"> + <h1 class="text-base text-gray-900 mt-5"> + {i18n.str`Trading volume on ${getDateForTimeframe( + params.current, + metricType, + dateLocale, + )} compared to ${getDateForTimeframe( + params.previous, + metricType, + dateLocale, + )}`} + </h1> + </div> + <dl class="mt-5 grid grid-cols-1 md:grid-cols-2 divide-y divide-gray-200 overflow-hidden rounded-lg bg-white shadow-lg md:divide-x md:divide-y-0"> + {resp.current.body.type !== "with-conversions" || + resp.previous.body.type !== "with-conversions" ? undefined : ( + <Fragment> + <div class="px-4 py-5 sm:p-6"> + <dt class="text-base font-normal text-gray-900"> + <i18n.Translate>Cashin</i18n.Translate> + <div class="text-xs text-gray-500"> + <i18n.Translate> + Transferred from an external account to an account in this + bank. + </i18n.Translate> + </div> + </dt> + <MetricValue + current={resp.current.body.cashinFiatVolume} + previous={resp.previous.body.cashinFiatVolume} + spec={respInfo.body.fiat_currency_specification} + /> + </div> + <div class="px-4 py-5 sm:p-6"> + <dt class="text-base font-normal text-gray-900"> + <i18n.Translate>Cashout</i18n.Translate> + </dt> + <div class="text-xs text-gray-500"> + <i18n.Translate> + Transferred from an account in this bank to an external + account. + </i18n.Translate> + </div> + <MetricValue + current={resp.current.body.cashoutFiatVolume} + previous={resp.previous.body.cashoutFiatVolume} + spec={respInfo.body.fiat_currency_specification} + /> + </div> + </Fragment> + )} + <div class="px-4 py-5 sm:p-6"> + <dt class="text-base font-normal text-gray-900"> + <i18n.Translate>Payin</i18n.Translate> + <div class="text-xs text-gray-500"> + <i18n.Translate> + Transferred from an account to a Taler exchange. + </i18n.Translate> + </div> + </dt> + <MetricValue + current={resp.current.body.talerInVolume} + previous={resp.previous.body.talerInVolume} + spec={config.currency_specification} + /> + </div> + <div class="px-4 py-5 sm:p-6"> + <dt class="text-base font-normal text-gray-900"> + <i18n.Translate>Payout</i18n.Translate> + <div class="text-xs text-gray-500"> + <i18n.Translate> + Transferred from a Taler exchange to another account. + </i18n.Translate> + </div> + </dt> + <MetricValue + current={resp.current.body.talerOutVolume} + previous={resp.previous.body.talerOutVolume} + spec={config.currency_specification} + /> + </div> + </dl> + <div class="flex justify-end mt-4"> + <a + href={routeDownloadStats.url({})} + name="download stats" + 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" + > + <i18n.Translate>Download stats as CSV</i18n.Translate> + </a> + </div> + </div> + ); +} + +function MetricValue({ + current, + previous, + spec, +}: { + spec: CurrencySpecification; + current: AmountString | undefined; + previous: AmountString | undefined; +}): VNode { + const { i18n } = useTranslationContext(); + const cmp = current && previous ? Amounts.cmp(current, previous) : 0; + const cv = !current ? undefined : Amounts.stringifyValue(current); + const currAmount = !cv ? undefined : Number.parseFloat(cv); + const prevAmount = !previous + ? undefined + : Number.parseFloat(Amounts.stringifyValue(previous)); + + const rate = + !currAmount || + Number.isNaN(currAmount) || + !prevAmount || + Number.isNaN(prevAmount) + ? 0 + : cmp === -1 + ? 1 - Math.round(currAmount) / Math.round(prevAmount) + : cmp === 1 + ? Math.round(currAmount) / Math.round(prevAmount) - 1 + : 0; + + const negative = cmp === 0 ? undefined : cmp === -1; + const rateStr = `${(Math.abs(rate) * 100).toFixed(2)}%`; + return ( + <Fragment> + <dd class="mt-1 block "> + <div class="flex justify-start text-2xl items-baseline font-semibold text-indigo-600"> + {!current ? ( + "-" + ) : ( + <RenderAmount + value={Amounts.parseOrThrow(current)} + spec={spec} + hideSmall + /> + )} + </div> + <div class="flex flex-col"> + <div class="flex justify-end items-baseline text-2xl font-semibold text-indigo-600"> + <small class="ml-2 text-sm font-medium text-gray-500"> + <i18n.Translate>from</i18n.Translate>{" "} + {!previous ? ( + "-" + ) : ( + <RenderAmount + value={Amounts.parseOrThrow(previous)} + spec={spec} + hideSmall + /> + )} + </small> + </div> + {!!rate && ( + <span + data-negative={negative} + class="flex items-center gap-x-1.5 w-fit rounded-md bg-green-100 text-green-800 data-[negative=true]:bg-red-100 px-2 py-1 text-xs font-medium data-[negative=true]:text-red-700 whitespace-pre" + > + {negative ? ( + <svg + xmlns="http://www.w3.org/2000/svg" + fill="none" + viewBox="0 0 24 24" + stroke-width="1.5" + stroke="currentColor" + class="w-6 h-6" + > + <path + stroke-linecap="round" + stroke-linejoin="round" + d="M12 4.5v15m0 0l6.75-6.75M12 19.5l-6.75-6.75" + /> + </svg> + ) : ( + <svg + xmlns="http://www.w3.org/2000/svg" + fill="none" + viewBox="0 0 24 24" + stroke-width="1.5" + stroke="currentColor" + class="w-6 h-6" + > + <path + stroke-linecap="round" + stroke-linejoin="round" + d="M12 19.5v-15m0 0l-6.75 6.75M12 4.5l6.75 6.75" + /> + </svg> + )} + + {negative ? ( + <span class="sr-only"> + <i18n.Translate>Decreased by</i18n.Translate> + </span> + ) : ( + <span class="sr-only"> + <i18n.Translate>Increased by</i18n.Translate> + </span> + )} + {rateStr} + </span> + )} + </div> + </dd> + </Fragment> + ); +} diff --git a/packages/bank-ui/src/pages/admin/CreateNewAccount.tsx b/packages/bank-ui/src/pages/admin/CreateNewAccount.tsx new file mode 100644 index 000000000..68f39fb9f --- /dev/null +++ b/packages/bank-ui/src/pages/admin/CreateNewAccount.tsx @@ -0,0 +1,226 @@ +/* + This file is part of GNU Taler + (C) 2022-2024 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ +import { + AbsoluteTime, + HttpStatusCode, + TalerCorebankApi, + TalerErrorCode, + TranslatedString, + assertUnreachable, +} from "@gnu-taler/taler-util"; +import { + Attention, + LocalNotificationBanner, + notifyInfo, + useLocalNotification, + useTranslationContext, +} from "@gnu-taler/web-util/browser"; +import { Fragment, VNode, h } from "preact"; +import { useState } from "preact/hooks"; +import { useBankCoreApiContext } from "@gnu-taler/web-util/browser"; +import { useSessionState } from "../../hooks/session.js"; +import { RouteDefinition } from "@gnu-taler/web-util/browser"; +import { AccountForm } from "./AccountForm.js"; + +export function CreateNewAccount({ + routeCancel, + onCreateSuccess, +}: { + routeCancel: RouteDefinition; + onCreateSuccess: () => void; +}): VNode { + const { i18n } = useTranslationContext(); + const { state: credentials } = useSessionState(); + const token = + credentials.status !== "loggedIn" ? undefined : credentials.token; + const { + lib: { bank: api }, + } = useBankCoreApiContext(); + + const [submitAccount, setSubmitAccount] = useState< + TalerCorebankApi.RegisterAccountRequest | undefined + >(); + const [notification, notify, handleError] = useLocalNotification(); + + async function doCreate() { + if (!submitAccount || !token) return; + await handleError(async () => { + const resp = await api.createAccount(token, submitAccount); + if (resp.type === "ok") { + notifyInfo( + i18n.str`Account created with password "${submitAccount.password}".`, + ); + onCreateSuccess(); + } else { + switch (resp.case) { + case HttpStatusCode.BadRequest: + return notify({ + type: "error", + title: i18n.str`Server replied that phone or email is invalid`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + when: AbsoluteTime.now(), + }); + case HttpStatusCode.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, + when: AbsoluteTime.now(), + }); + case TalerErrorCode.BANK_REGISTER_USERNAME_REUSE: + return notify({ + type: "error", + title: i18n.str`Account username is already taken`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + when: AbsoluteTime.now(), + }); + case TalerErrorCode.BANK_REGISTER_PAYTO_URI_REUSE: + return notify({ + type: "error", + title: i18n.str`Account id is already taken`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + when: AbsoluteTime.now(), + }); + case TalerErrorCode.BANK_UNALLOWED_DEBIT: + return notify({ + type: "error", + title: i18n.str`Bank ran out of bonus credit.`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + when: AbsoluteTime.now(), + }); + case TalerErrorCode.BANK_RESERVED_USERNAME_CONFLICT: + return notify({ + type: "error", + title: i18n.str`Account username can't be used because is reserved`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + when: AbsoluteTime.now(), + }); + case TalerErrorCode.BANK_NON_ADMIN_PATCH_DEBT_LIMIT: + return notify({ + type: "error", + title: i18n.str`Only admin is allow to set debt limit.`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + when: AbsoluteTime.now(), + }); + case TalerErrorCode.BANK_MISSING_TAN_INFO: + return notify({ + type: "error", + title: i18n.str`No information for the selected authentication channel.`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + when: AbsoluteTime.now(), + }); + case TalerErrorCode.BANK_TAN_CHANNEL_NOT_SUPPORTED: + return notify({ + type: "error", + title: i18n.str`Authentication channel is not supported.`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + when: AbsoluteTime.now(), + }); + case TalerErrorCode.BANK_NON_ADMIN_SET_TAN_CHANNEL: + return notify({ + type: "error", + title: i18n.str`Only admin can create accounts with second factor authentication.`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + when: AbsoluteTime.now(), + }); + case TalerErrorCode.BANK_NON_ADMIN_SET_MIN_CASHOUT: { + return notify({ + type: "error", + title: i18n.str`Only the administrator can change the minimum cashout limit.`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + when: AbsoluteTime.now(), + }); + } + default: + assertUnreachable(resp); + } + } + }); + } + + if (!(credentials.status === "loggedIn" && credentials.isUserAdministrator)) { + return ( + <Fragment> + <Attention type="warning" title={i18n.str`Can't create accounts`}> + <i18n.Translate> + Only system admin can create accounts. + </i18n.Translate> + </Attention> + <div class="mt-5 sm:mt-6"> + <a + href={routeCancel.url({})} + name="close" + class="inline-flex w-full justify-center rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" + > + <i18n.Translate>Close</i18n.Translate> + </a> + </div> + </Fragment> + ); + } + + return ( + <div class="grid grid-cols-1 gap-x-8 gap-y-8 pt-6 md:grid-cols-3 bg-gray-100 my-4 px-4 pb-4 rounded-lg"> + <LocalNotificationBanner notification={notification} /> + + <div class="px-4 sm:px-0"> + <h2 class="text-base font-semibold leading-7 text-gray-900"> + <i18n.Translate>New bank account</i18n.Translate> + </h2> + </div> + <AccountForm + template={undefined} + purpose="create" + onChange={(a) => { + setSubmitAccount(a); + }} + > + <div class="flex items-center justify-between gap-x-6 border-t border-gray-900/10 px-4 py-4 sm:px-8"> + <a + href={routeCancel.url({})} + name="cancel" + class="text-sm font-semibold leading-6 text-gray-900" + > + <i18n.Translate>Cancel</i18n.Translate> + </a> + <button + type="submit" + name="create" + class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" + disabled={!submitAccount} + onClick={(e) => { + e.preventDefault(); + doCreate(); + }} + > + <i18n.Translate>Create</i18n.Translate> + </button> + </div> + </AccountForm> + </div> + ); +} diff --git a/packages/bank-ui/src/pages/admin/DownloadStats.tsx b/packages/bank-ui/src/pages/admin/DownloadStats.tsx new file mode 100644 index 000000000..8f6bb7c23 --- /dev/null +++ b/packages/bank-ui/src/pages/admin/DownloadStats.tsx @@ -0,0 +1,588 @@ +/* + This file is part of GNU Taler + (C) 2022-2024 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +import { + AccessToken, + AmountString, + TalerCoreBankHttpClient, + TalerCorebankApi, + TalerError, +} from "@gnu-taler/taler-util"; +import { + Attention, + LocalNotificationBanner, + useLocalNotification, + useTranslationContext, +} from "@gnu-taler/web-util/browser"; +import { VNode, h } from "preact"; +import { useState } from "preact/hooks"; +import { useBankCoreApiContext } from "@gnu-taler/web-util/browser"; +import { useSessionState } from "../../hooks/session.js"; +import { RouteDefinition } from "@gnu-taler/web-util/browser"; +import { getTimeframesForDate } from "./AdminHome.js"; + +interface Props { + routeCancel: RouteDefinition; +} + +type Options = { + dayMetric: boolean; + hourMetric: boolean; + monthMetric: boolean; + yearMetric: boolean; + compareWithPrevious: boolean; + endOnFirstFail: boolean; + includeHeader: boolean; +}; + +/** + * Show histories of public accounts. + */ +export function DownloadStats({ routeCancel }: Props): VNode { + const { i18n } = useTranslationContext(); + + const { state: credentials } = useSessionState(); + const creds = + credentials.status !== "loggedIn" || !credentials.isUserAdministrator + ? undefined + : credentials; + const { + lib: { bank: api }, + } = useBankCoreApiContext(); + + const [options, setOptions] = useState<Options>({ + compareWithPrevious: true, + dayMetric: true, + endOnFirstFail: false, + hourMetric: true, + includeHeader: true, + monthMetric: true, + yearMetric: true, + }); + const [lastStep, setLastStep] = useState<{ step: number; total: number }>(); + const [downloaded, setDownloaded] = useState<string>(); + const referenceDates = [new Date()]; + const [notification, , handleError] = useLocalNotification(); + + if (!creds) { + return <div>only admin can download stats</div>; + } + + return ( + <div> + <div class="grid grid-cols-1 gap-x-8 gap-y-8 pt-6 md:grid-cols-3 bg-gray-100 my-4 px-4 pb-4 rounded-lg"> + <LocalNotificationBanner notification={notification} /> + + <div class="px-4 sm:px-0"> + <h2 class="text-base font-semibold leading-7 text-gray-900"> + <i18n.Translate>Download bank stats</i18n.Translate> + </h2> + </div> + + <form + class="bg-white shadow-sm ring-1 ring-gray-900/5 sm:rounded-xl md:col-span-2" + autoCapitalize="none" + autoCorrect="off" + onSubmit={(e) => { + e.preventDefault(); + }} + > + <div class="px-4 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="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>Include hour metric</i18n.Translate> + </span> + </span> + <button + type="button" + name={`hour switch`} + data-enabled={options.hourMetric} + 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={() => { + setOptions({ + ...options, + hourMetric: !options.hourMetric, + }); + }} + > + <span + aria-hidden="true" + data-enabled={options.hourMetric} + class="translate-x-5 data-[enabled=false]: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="sm:col-span-5"> + <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>Include day metric</i18n.Translate> + </span> + </span> + <button + type="button" + name={`day switch`} + data-enabled={!!options.dayMetric} + 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={() => { + setOptions({ ...options, dayMetric: !options.dayMetric }); + }} + > + <span + aria-hidden="true" + data-enabled={options.dayMetric} + class="translate-x-5 data-[enabled=false]: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="sm:col-span-5"> + <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>Include month metric</i18n.Translate> + </span> + </span> + <button + type="button" + name={`month switch`} + data-enabled={!!options.monthMetric} + 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={() => { + setOptions({ + ...options, + monthMetric: !options.monthMetric, + }); + }} + > + <span + aria-hidden="true" + data-enabled={options.monthMetric} + class="translate-x-5 data-[enabled=false]: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="sm:col-span-5"> + <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>Include year metric</i18n.Translate> + </span> + </span> + <button + type="button" + name={`year switch`} + data-enabled={!!options.yearMetric} + 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={() => { + setOptions({ + ...options, + yearMetric: !options.yearMetric, + }); + }} + > + <span + aria-hidden="true" + data-enabled={options.yearMetric} + class="translate-x-5 data-[enabled=false]: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="sm:col-span-5"> + <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>Include table header</i18n.Translate> + </span> + </span> + <button + type="button" + name={`header switch`} + data-enabled={!!options.includeHeader} + 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={() => { + setOptions({ + ...options, + includeHeader: !options.includeHeader, + }); + }} + > + <span + aria-hidden="true" + data-enabled={options.includeHeader} + class="translate-x-5 data-[enabled=false]: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="sm:col-span-5"> + <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> + Add previous metric for compare + </i18n.Translate> + </span> + </span> + <button + type="button" + name={`compare switch`} + data-enabled={!!options.compareWithPrevious} + 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={() => { + setOptions({ + ...options, + compareWithPrevious: !options.compareWithPrevious, + }); + }} + > + <span + aria-hidden="true" + data-enabled={options.compareWithPrevious} + class="translate-x-5 data-[enabled=false]: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="sm:col-span-5"> + <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>Fail on first error</i18n.Translate> + </span> + </span> + <button + type="button" + name={`fail switch`} + data-enabled={!!options.endOnFirstFail} + 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={() => { + setOptions({ + ...options, + endOnFirstFail: !options.endOnFirstFail, + }); + }} + > + <span + aria-hidden="true" + data-enabled={options.endOnFirstFail} + class="translate-x-5 data-[enabled=false]: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> + </div> + + <div class="flex items-center justify-between gap-x-6 border-t border-gray-900/10 px-4 py-4 sm:px-8"> + <a + name="cancel" + href={routeCancel.url({})} + class="text-sm font-semibold leading-6 text-gray-900" + > + <i18n.Translate>Cancel</i18n.Translate> + </a> + <button + type="submit" + name="download" + class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" + disabled={lastStep !== undefined} + onClick={async () => { + setDownloaded(undefined); + await handleError(async () => { + const csv = await fetchAllStatus( + api, + creds.token, + options, + referenceDates, + (step, total) => { + setLastStep({ step, total }); + }, + ); + setDownloaded(csv); + }); + setLastStep(undefined); + }} + > + <i18n.Translate>Download</i18n.Translate> + </button> + </div> + </form> + </div> + {!lastStep || lastStep.step === lastStep.total ? ( + <div class="h-5 mb-5" /> + ) : ( + <div> + <div class="relative mb-5 h-5 rounded-full bg-gray-200"> + <div + class="h-full animate-pulse rounded-full bg-blue-500" + style={{ + width: `${Math.round((lastStep.step / lastStep.total) * 100)}%`, + }} + > + <span class="absolute inset-0 flex items-center justify-center text-xs font-semibold text-white"> + <i18n.Translate> + downloading...{" "} + {Math.round((lastStep.step / lastStep.total) * 100)} + </i18n.Translate> + </span> + </div> + </div> + </div> + )} + {!downloaded ? ( + <div class="h-5 mb-5" /> + ) : ( + <a + href={ + "data:text/plain;charset=utf-8," + encodeURIComponent(downloaded) + } + name="save file" + download={"bank-stats.csv"} + > + <Attention title={i18n.str`Download completed`}> + <i18n.Translate> + Click here to save the file in your computer. + </i18n.Translate> + </Attention> + </a> + )} + </div> + ); +} + +async function fetchAllStatus( + api: TalerCoreBankHttpClient, + token: AccessToken, + options: Options, + references: Date[], + progress: (current: number, total: number) => void, +): Promise<string> { + const allMetrics: TalerCorebankApi.MonitorTimeframeParam[] = []; + if (options.hourMetric) { + allMetrics.push(TalerCorebankApi.MonitorTimeframeParam.hour); + } + if (options.dayMetric) { + allMetrics.push(TalerCorebankApi.MonitorTimeframeParam.day); + } + if (options.monthMetric) { + allMetrics.push(TalerCorebankApi.MonitorTimeframeParam.month); + } + if (options.yearMetric) { + allMetrics.push(TalerCorebankApi.MonitorTimeframeParam.year); + } + + /** + * convert request into frames + */ + const allFrames = allMetrics.flatMap((timeframe) => + references.map((reference) => ({ + reference, + timeframe, + moment: getTimeframesForDate(reference, timeframe), + })), + ); + const total = allFrames.length; + + /** + * call API for info + */ + const allInfo = await allFrames.reduce( + async (prev, frame, index) => { + const accumulatedMap = await prev; + progress(index, total); + // await delay() + const previous = options.compareWithPrevious + ? await api.getMonitor(token, { + timeframe: frame.timeframe, + date: frame.moment.previous, + }) + : undefined; + + if (previous && previous.type === "fail" && options.endOnFirstFail) { + throw TalerError.fromUncheckedDetail(previous.detail); + } + + const current = await api.getMonitor(token, { + timeframe: frame.timeframe, + date: frame.moment.current, + }); + + if (current.type === "fail" && options.endOnFirstFail) { + throw TalerError.fromUncheckedDetail(current.detail); + } + + const metricName = + TalerCorebankApi.MonitorTimeframeParam[allMetrics[index]]; + accumulatedMap[metricName] = { + reference: frame.reference, + current: current.type !== "ok" ? undefined : current.body, + previous: + !previous || previous.type !== "ok" ? undefined : previous.body, + }; + return accumulatedMap; + }, + Promise.resolve({} as Record<string, Data>), + ); + progress(total, total); + + /** + * convert into table format + * + */ + const table: Array<string[]> = []; + if (options.includeHeader) { + table.push([ + "date", + "metric", + "reference", + "talerInCount", + "talerInVolume", + "talerOutCount", + "talerOutVolume", + "cashinCount", + "cashinFiatVolume", + "cashinRegionalVolume", + "cashoutCount", + "cashoutFiatVolume", + "cashoutRegionalVolume", + ]); + } + Object.entries(allInfo).forEach(([name, data]) => { + if (data.current) { + const row: TableRow = { + date: data.reference.getTime(), + metric: name, + reference: "current", + ...dataToRow(data.current), + }; + table.push(Object.values(row) as string[]); + } + + if (data.previous) { + const row: TableRow = { + date: data.reference.getTime(), + metric: name, + reference: "previous", + ...dataToRow(data.previous), + }; + table.push(Object.values(row) as string[]); + } + }); + + const csv = table.reduce((acc, row) => { + return acc + row.join(",") + "\n"; + }, ""); + + return csv; +} + +type JustData = Omit<Omit<Omit<TableRow, "metric">, "date">, "reference">; +function dataToRow(info: TalerCorebankApi.MonitorResponse): JustData { + return { + talerInCount: info.talerInCount, + talerInVolume: info.talerInVolume, + talerOutCount: info.talerOutCount, + talerOutVolume: info.talerOutVolume, + cashinCount: info.type === "no-conversions" ? undefined : info.cashinCount, + cashinFiatVolume: + info.type === "no-conversions" ? undefined : info.cashinFiatVolume, + cashinRegionalVolume: + info.type === "no-conversions" ? undefined : info.cashinRegionalVolume, + cashoutCount: + info.type === "no-conversions" ? undefined : info.cashoutCount, + cashoutFiatVolume: + info.type === "no-conversions" ? undefined : info.cashoutFiatVolume, + cashoutRegionalVolume: + info.type === "no-conversions" ? undefined : info.cashoutRegionalVolume, + }; +} + +type Data = { + reference: Date; + previous: TalerCorebankApi.MonitorResponse | undefined; + current: TalerCorebankApi.MonitorResponse | undefined; +}; +type TableRow = { + date: number; + metric: string; + reference: "current" | "previous"; + cashinCount?: number; + cashinRegionalVolume?: AmountString; + cashinFiatVolume?: AmountString; + cashoutCount?: number; + cashoutRegionalVolume?: AmountString; + cashoutFiatVolume?: AmountString; + talerInCount: number; + talerInVolume: AmountString; + talerOutCount: number; + talerOutVolume: AmountString; +}; diff --git a/packages/bank-ui/src/pages/admin/RemoveAccount.tsx b/packages/bank-ui/src/pages/admin/RemoveAccount.tsx new file mode 100644 index 000000000..dbeebf719 --- /dev/null +++ b/packages/bank-ui/src/pages/admin/RemoveAccount.tsx @@ -0,0 +1,273 @@ +/* + This file is part of GNU Taler + (C) 2022-2024 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ +import { + AbsoluteTime, + Amounts, + HttpStatusCode, + TalerError, + TalerErrorCode, + TranslatedString, + assertUnreachable, +} from "@gnu-taler/taler-util"; +import { + Attention, + Loading, + LocalNotificationBanner, + ShowInputErrorLabel, + notifyInfo, + useLocalNotification, + useTranslationContext, +} from "@gnu-taler/web-util/browser"; +import { Fragment, VNode, h } from "preact"; +import { useState } from "preact/hooks"; +import { ErrorLoadingWithDebug } from "../../components/ErrorLoadingWithDebug.js"; +import { useBankCoreApiContext } from "@gnu-taler/web-util/browser"; +import { useAccountDetails } from "../../hooks/account.js"; +import { useSessionState } from "../../hooks/session.js"; +import { undefinedIfEmpty } from "../../utils.js"; +import { LoginForm } from "../LoginForm.js"; +import { doAutoFocus } from "../PaytoWireTransferForm.js"; +import { useBankState } from "../../hooks/bank-state.js"; +import { RouteDefinition } from "@gnu-taler/web-util/browser"; + +export function RemoveAccount({ + account, + routeCancel, + onUpdateSuccess, + onAuthorizationRequired, + focus, + routeHere, +}: { + focus?: boolean; + routeHere: RouteDefinition<{ account: string }>; + onAuthorizationRequired: () => void; + routeCancel: RouteDefinition; + onUpdateSuccess: () => void; + account: string; +}): VNode { + const { i18n } = useTranslationContext(); + const result = useAccountDetails(account); + const [accountName, setAccountName] = useState<string | undefined>(); + + const { state } = useSessionState(); + const token = state.status !== "loggedIn" ? undefined : state.token; + const { + lib: { bank: api }, + } = useBankCoreApiContext(); + const [notification, notify, handleError] = useLocalNotification(); + const [, updateBankState] = useBankState(); + + if (!result) { + return <Loading />; + } + if (result instanceof TalerError) { + return <ErrorLoadingWithDebug error={result} />; + } + if (result.type === "fail") { + switch (result.case) { + case HttpStatusCode.Unauthorized: + return <LoginForm currentUser={account} />; + case HttpStatusCode.NotFound: + return <LoginForm currentUser={account} />; + default: + assertUnreachable(result); + } + } + + const balance = Amounts.parse(result.body.balance.amount); + if (!balance) { + return <div>there was an error reading the balance</div>; + } + const isBalanceEmpty = Amounts.isZero(balance); + if (!isBalanceEmpty) { + return ( + <Fragment> + <Attention type="warning" title={i18n.str`Can't delete the account`}> + <i18n.Translate> + The account can't be delete while still holding some balance. First + make sure that the owner make a complete cashout. + </i18n.Translate> + </Attention> + <div class="mt-5 sm:mt-6"> + <a + href={routeCancel.url({})} + name="close" + class="inline-flex w-full justify-center rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" + > + <i18n.Translate>Close</i18n.Translate> + </a> + </div> + </Fragment> + ); + } + + async function doRemove() { + if (!token) return; + await handleError(async () => { + const resp = await api.deleteAccount({ username: account, token }); + if (resp.type === "ok") { + notifyInfo(i18n.str`Account removed`); + onUpdateSuccess(); + } else { + switch (resp.case) { + case HttpStatusCode.Unauthorized: + return notify({ + type: "error", + title: i18n.str`No enough permission to delete the account.`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + when: AbsoluteTime.now(), + }); + case HttpStatusCode.NotFound: + return notify({ + type: "error", + title: i18n.str`The username was not found.`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + when: AbsoluteTime.now(), + }); + case TalerErrorCode.BANK_RESERVED_USERNAME_CONFLICT: + return notify({ + type: "error", + title: i18n.str`Can't delete a reserved username.`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + when: AbsoluteTime.now(), + }); + case TalerErrorCode.BANK_ACCOUNT_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, + when: AbsoluteTime.now(), + }); + case HttpStatusCode.Accepted: { + updateBankState("currentChallenge", { + operation: "delete-account", + id: String(resp.body.challenge_id), + sent: AbsoluteTime.never(), + location: routeHere.url({ account }), + request: account, + }); + return onAuthorizationRequired(); + } + default: { + assertUnreachable(resp); + } + } + } + }); + } + + const errors = undefinedIfEmpty({ + accountName: !accountName + ? i18n.str`Required` + : account !== accountName + ? i18n.str`Name doesn't match` + : undefined, + }); + + return ( + <div> + <LocalNotificationBanner notification={notification} /> + + <Attention + type="warning" + title={i18n.str`You are going to remove the account`} + > + <i18n.Translate>This step can't be undone.</i18n.Translate> + </Attention> + + <div class="grid grid-cols-1 gap-x-8 gap-y-8 pt-6 md:grid-cols-3 bg-gray-100 my-4 px-4 pb-4 rounded-lg"> + <div class="px-4 sm:px-0"> + <h2 class="text-base font-semibold leading-7 text-gray-900"> + <i18n.Translate>Deleting account "{account}"</i18n.Translate> + </h2> + </div> + <form + class="bg-white shadow-sm ring-1 ring-gray-900/5 sm:rounded-xl md:col-span-2" + autoCapitalize="none" + autoCorrect="off" + onSubmit={(e) => { + e.preventDefault(); + }} + > + <div class="px-4 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"> + <label + class="block text-sm font-medium leading-6 text-gray-900" + for="password" + > + {i18n.str`Verification`} + </label> + <div class="mt-2"> + <input + ref={focus ? doAutoFocus : undefined} + type="text" + class="block w-full 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="password" + id="password" + data-error={ + !!errors?.accountName && accountName !== undefined + } + value={accountName ?? ""} + onChange={(e) => { + setAccountName(e.currentTarget.value); + }} + placeholder={account} + autocomplete="off" + /> + <ShowInputErrorLabel + message={errors?.accountName} + isDirty={accountName !== undefined} + /> + </div> + <p class="mt-2 text-sm text-gray-500"> + <i18n.Translate> + Enter the account name that is going to be deleted + </i18n.Translate> + </p> + </div> + </div> + </div> + <div class="flex items-center justify-between gap-x-6 border-t border-gray-900/10 px-4 py-4 sm:px-8"> + <a + href={routeCancel.url({})} + name="cancel" + class="text-sm font-semibold leading-6 text-gray-900" + > + <i18n.Translate>Cancel</i18n.Translate> + </a> + <button + type="submit" + name="delete" + class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-red-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-600" + disabled={!!errors} + onClick={(e) => { + e.preventDefault(); + doRemove(); + }} + > + <i18n.Translate>Delete</i18n.Translate> + </button> + </div> + </form> + </div> + </div> + ); +} diff --git a/packages/bank-ui/src/pages/index.stories.tsx b/packages/bank-ui/src/pages/index.stories.tsx new file mode 100644 index 000000000..823def5d7 --- /dev/null +++ b/packages/bank-ui/src/pages/index.stories.tsx @@ -0,0 +1,20 @@ +/* + This file is part of GNU Taler + (C) 2022-2024 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +export * as qr from "./QrCodeSection.stories.js"; +export * as po from "./PaymentOptions.stories.js"; +export * as ptf from "./PaytoWireTransferForm.stories.js"; +export * as frame from "./BankFrame.stories.js"; diff --git a/packages/bank-ui/src/pages/regional/ConversionConfig.tsx b/packages/bank-ui/src/pages/regional/ConversionConfig.tsx new file mode 100644 index 000000000..485ef5490 --- /dev/null +++ b/packages/bank-ui/src/pages/regional/ConversionConfig.tsx @@ -0,0 +1,1170 @@ +/* + This file is part of GNU Taler + (C) 2022-2024 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +import { + AbsoluteTime, + AmountJson, + Amounts, + HttpStatusCode, + TalerBankConversionApi, + TalerError, + TranslatedString, + assertUnreachable, +} from "@gnu-taler/taler-util"; +import { + Attention, + InternationalizationAPI, + LocalNotificationBanner, + ShowInputErrorLabel, + useLocalNotification, + useTranslationContext, + utils, +} from "@gnu-taler/web-util/browser"; +import { Fragment, VNode, h } from "preact"; +import { useEffect, useState } from "preact/hooks"; +import { useBankCoreApiContext } from "@gnu-taler/web-util/browser"; +import { useSessionState } from "../../hooks/session.js"; +import { + TransferCalculation, + useCashinEstimator, + useCashoutEstimator, + useConversionInfo, +} from "../../hooks/regional.js"; +import { RouteDefinition } from "@gnu-taler/web-util/browser"; +import { undefinedIfEmpty } from "../../utils.js"; +import { InputAmount, RenderAmount } from "../PaytoWireTransferForm.js"; +import { ProfileNavigation } from "../ProfileNavigation.js"; +import { + FormErrors, + FormStatus, + FormValues, + RecursivePartial, + UIField, + useFormState, +} from "../../hooks/form.js"; + +interface Props { + routeMyAccountDetails: RouteDefinition; + routeMyAccountDelete: RouteDefinition; + routeMyAccountPassword: RouteDefinition; + routeMyAccountCashout: RouteDefinition; + routeConversionConfig: RouteDefinition; + routeCancel: RouteDefinition; + onUpdateSuccess: () => void; +} + +type FormType = { + amount: AmountJson; + conv: TalerBankConversionApi.ConversionRate; +}; + +function useComponentState({ + routeCancel, + routeConversionConfig, + routeMyAccountCashout, + routeMyAccountDelete, + routeMyAccountDetails, + routeMyAccountPassword, +}: Props): utils.RecursiveState<VNode> { + const { i18n } = useTranslationContext(); + + const result = useConversionInfo(); + const info = + result && !(result instanceof TalerError) && result.type === "ok" + ? result.body + : undefined; + + const { state: credentials } = useSessionState(); + const creds = + credentials.status !== "loggedIn" || !credentials.isUserAdministrator + ? undefined + : credentials; + + if (!info) { + return <i18n.Translate>loading...</i18n.Translate>; + } + + if (!creds) { + return <i18n.Translate>only admin can setup conversion</i18n.Translate>; + } + + return function afterComponentLoads() { + const { i18n } = useTranslationContext(); + + const { + lib: { conversion }, + } = useBankCoreApiContext(); + + const [notification, notify, handleError] = useLocalNotification(); + + const initalState: FormValues<FormType> = { + amount: "100", + conv: { + cashin_min_amount: info.conversion_rate.cashin_min_amount.split(":")[1], + cashin_tiny_amount: + info.conversion_rate.cashin_tiny_amount.split(":")[1], + cashin_fee: info.conversion_rate.cashin_fee.split(":")[1], + cashin_ratio: info.conversion_rate.cashin_ratio, + cashin_rounding_mode: info.conversion_rate.cashin_rounding_mode, + cashout_min_amount: + info.conversion_rate.cashout_min_amount.split(":")[1], + cashout_tiny_amount: + info.conversion_rate.cashout_tiny_amount.split(":")[1], + cashout_fee: info.conversion_rate.cashout_fee.split(":")[1], + cashout_ratio: info.conversion_rate.cashout_ratio, + cashout_rounding_mode: info.conversion_rate.cashout_rounding_mode, + }, + }; + + const [form, status] = useFormState<FormType>( + initalState, + createFormValidator(i18n, info.regional_currency, info.fiat_currency), + ); + + const { estimateByDebit: calculateCashoutFromDebit } = + useCashoutEstimator(); + + const { estimateByDebit: calculateCashinFromDebit } = useCashinEstimator(); + + const [calculationResult, setCalc] = useState<{ + cashin: TransferCalculation; + cashout: TransferCalculation; + }>(); + + useEffect(() => { + async function doAsync() { + await handleError(async () => { + if (!info) return; + if (!form.amount?.value || form.amount.error) return; + const in_amount = Amounts.parseOrThrow( + `${info.fiat_currency}:${form.amount.value}`, + ); + const in_fee = Amounts.parseOrThrow(info.conversion_rate.cashin_fee); + const cashin = await calculateCashinFromDebit(in_amount, in_fee); + + if (cashin === "amount-is-too-small") { + setCalc(undefined); + return; + } + // const out_amount = Amounts.parseOrThrow(`${info.regional_currency}:${form.amount.value}`) + const out_fee = Amounts.parseOrThrow( + info.conversion_rate.cashout_fee, + ); + const cashout = await calculateCashoutFromDebit( + cashin.credit, + out_fee, + ); + + setCalc({ cashin, cashout }); + }); + } + doAsync(); + }, [ + form.amount?.value, + form.conv?.cashin_fee?.value, + form.conv?.cashout_fee?.value, + ]); + + const [section, setSection] = useState<"detail" | "cashout" | "cashin">( + "detail", + ); + const cashinCalc = + calculationResult?.cashin === "amount-is-too-small" + ? undefined + : calculationResult?.cashin; + const cashoutCalc = + calculationResult?.cashout === "amount-is-too-small" + ? undefined + : calculationResult?.cashout; + async function doUpdate() { + if (!creds) return; + await handleError(async () => { + if (status.status === "fail") return; + const resp = await conversion.updateConversionRate( + creds.token, + status.result.conv, + ); + if (resp.type === "ok") { + setSection("detail"); + } else { + switch (resp.case) { + case HttpStatusCode.Unauthorized: { + return notify({ + type: "error", + title: i18n.str`Wrong credentials`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + when: AbsoluteTime.now(), + }); + } + case HttpStatusCode.NotImplemented: { + return notify({ + type: "error", + title: i18n.str`Conversion is disabled`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + when: AbsoluteTime.now(), + }); + } + default: + assertUnreachable(resp); + } + } + }); + } + + const in_ratio = Number.parseFloat(info.conversion_rate.cashin_ratio); + const out_ratio = Number.parseFloat(info.conversion_rate.cashout_ratio); + + const both_high = in_ratio > 1 && out_ratio > 1; + const both_low = in_ratio < 1 && out_ratio < 1; + + return ( + <div> + <ProfileNavigation + current="conversion" + routeMyAccountCashout={routeMyAccountCashout} + routeMyAccountDelete={routeMyAccountDelete} + routeMyAccountDetails={routeMyAccountDetails} + routeMyAccountPassword={routeMyAccountPassword} + routeConversionConfig={routeConversionConfig} + /> + + <LocalNotificationBanner notification={notification} /> + <div class="grid grid-cols-1 gap-x-8 gap-y-8 pt-6 md:grid-cols-3 bg-gray-100 my-4 px-4 pb-4 rounded-lg"> + <div class="px-4 sm:px-0"> + <h2 class="text-base font-semibold leading-7 text-gray-900"> + <i18n.Translate>Conversion</i18n.Translate> + </h2> + <div class="px-2 mt-2 grid grid-cols-1 gap-y-4 sm:gap-x-4"> + <label + data-enabled={section === "detail"} + class="relative flex cursor-pointer rounded-lg border bg-white p-4 shadow-sm focus:outline-none border-gray-300 data-[enabled=true]:border-indigo-600 data-[enabled=true]:ring-2 data-[enabled=true]:ring-indigo-600" + > + <input + type="radio" + name="project-type" + value="Newsletter" + class="sr-only" + aria-labelledby="project-type-0-label" + aria-describedby="project-type-0-description-0 project-type-0-description-1" + onChange={() => { + setSection("detail"); + }} + /> + <span class="flex flex-1"> + <span class="flex flex-col"> + <span class="block text-sm font-medium text-gray-900"> + <i18n.Translate>Details</i18n.Translate> + </span> + </span> + </span> + </label> + + <label + data-enabled={section === "cashout"} + class="relative flex cursor-pointer rounded-lg border bg-white p-4 shadow-sm focus:outline-none border-gray-300 -- data-[enabled=true]:border-indigo-600 data-[enabled=true]:ring-2 data-[enabled=true]:ring-indigo-600" + > + <input + type="radio" + name="project-type" + value="Existing Customers" + class="sr-only" + aria-labelledby="project-type-1-label" + aria-describedby="project-type-1-description-0 project-type-1-description-1" + onChange={() => { + setSection("cashout"); + }} + /> + <span class="flex flex-1"> + <span class="flex flex-col"> + <span class="block text-sm font-medium text-gray-900"> + <i18n.Translate>Config cashout</i18n.Translate> + </span> + </span> + </span> + </label> + <label + data-enabled={section === "cashin"} + class="relative flex cursor-pointer rounded-lg border bg-white p-4 shadow-sm focus:outline-none border-gray-300 -- data-[enabled=true]:border-indigo-600 data-[enabled=true]:ring-2 data-[enabled=true]:ring-indigo-600" + > + <input + type="radio" + name="project-type" + value="Existing Customers" + class="sr-only" + aria-labelledby="project-type-1-label" + aria-describedby="project-type-1-description-0 project-type-1-description-1" + onChange={() => { + setSection("cashin"); + }} + /> + <span class="flex flex-1"> + <span class="flex flex-col"> + <span class="block text-sm font-medium text-gray-900"> + <i18n.Translate>Config cashin</i18n.Translate> + </span> + </span> + </span> + </label> + </div> + </div> + + <form + class="bg-white shadow-sm ring-1 ring-gray-900/5 sm:rounded-xl md:col-span-2" + autoCapitalize="none" + autoCorrect="off" + onSubmit={(e) => { + e.preventDefault(); + }} + > + {section == "cashin" && ( + <ConversionForm + id="cashin" + inputCurrency={info.fiat_currency} + outputCurrency={info.regional_currency} + fee={form?.conv?.cashin_fee} + minimum={form?.conv?.cashin_min_amount} + ratio={form?.conv?.cashin_ratio} + rounding={form?.conv?.cashin_rounding_mode} + tiny={form?.conv?.cashin_tiny_amount} + /> + )} + + {section == "cashout" && ( + <Fragment> + <ConversionForm + id="cashout" + inputCurrency={info.regional_currency} + outputCurrency={info.fiat_currency} + fee={form?.conv?.cashout_fee} + minimum={form?.conv?.cashout_min_amount} + ratio={form?.conv?.cashout_ratio} + rounding={form?.conv?.cashout_rounding_mode} + tiny={form?.conv?.cashout_tiny_amount} + /> + </Fragment> + )} + + {section == "detail" && ( + <Fragment> + <div class="px-6 pt-6"> + <div class="justify-between items-center flex "> + <dt class="text-sm text-gray-600"> + <i18n.Translate>Cashin ratio</i18n.Translate> + </dt> + <dd class="text-sm text-gray-900"> + {info.conversion_rate.cashin_ratio} + </dd> + </div> + </div> + + <div class="px-6 pt-6"> + <div class="justify-between items-center flex "> + <dt class="text-sm text-gray-600"> + <i18n.Translate>Cashout ratio</i18n.Translate> + </dt> + <dd class="text-sm text-gray-900"> + {info.conversion_rate.cashout_ratio} + </dd> + </div> + </div> + + {both_low || both_high ? ( + <div class="p-4"> + <Attention title={i18n.str`Bad ratios`} type="warning"> + <i18n.Translate> + One of the ratios should be higher or equal than 1 an + the other should be lower or equal than 1. + </i18n.Translate> + </Attention> + </div> + ) : undefined} + + <div class="px-6 pt-6"> + <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"> + <label + for="amount" + class="block text-sm font-medium leading-6 text-gray-900" + >{i18n.str`Initial amount`}</label> + <InputAmount + name="amount" + left + currency={info.fiat_currency} + value={form.amount?.value ?? ""} + onChange={form.amount?.onUpdate} + /> + <ShowInputErrorLabel + message={form.amount?.error} + isDirty={form.amount?.value !== undefined} + /> + <p class="mt-2 text-sm text-gray-500"> + <i18n.Translate> + Use it to test how the conversion will affect the + amount. + </i18n.Translate> + </p> + </div> + </div> + </div> + + {!cashoutCalc || !cashinCalc ? undefined : ( + <div class="px-6 pt-6"> + <div class="sm:col-span-5"> + <dl class="mt-4 space-y-4"> + <div class="justify-between items-center flex "> + <dt class="text-sm text-gray-600"> + <i18n.Translate> + Sending to this bank + </i18n.Translate> + </dt> + <dd class="text-sm text-gray-900"> + <RenderAmount + value={cashinCalc.debit} + negative + withColor + spec={info.regional_currency_specification} + /> + </dd> + </div> + + {Amounts.isZero(cashinCalc.beforeFee) ? undefined : ( + <div class="flex items-center justify-between afu "> + <dt class="flex items-center text-sm text-gray-600"> + <span> + <i18n.Translate>Converted</i18n.Translate> + </span> + </dt> + <dd class="text-sm text-gray-900"> + <RenderAmount + value={cashinCalc.beforeFee} + spec={info.fiat_currency_specification} + /> + </dd> + </div> + )} + <div class="flex justify-between items-center border-t-2 afu pt-4"> + <dt class="text-lg text-gray-900 font-medium"> + <i18n.Translate>Cashin after fee</i18n.Translate> + </dt> + <dd class="text-lg text-gray-900 font-medium"> + <RenderAmount + value={cashinCalc.credit} + withColor + spec={info.fiat_currency_specification} + /> + </dd> + </div> + </dl> + </div> + + <div class="sm:col-span-5"> + <dl class="mt-4 space-y-4"> + <div class="justify-between items-center flex "> + <dt class="text-sm text-gray-600"> + <i18n.Translate> + Sending from this bank + </i18n.Translate> + </dt> + <dd class="text-sm text-gray-900"> + <RenderAmount + value={cashoutCalc.debit} + negative + withColor + spec={info.fiat_currency_specification} + /> + </dd> + </div> + + {Amounts.isZero(cashoutCalc.beforeFee) ? undefined : ( + <div class="flex items-center justify-between afu"> + <dt class="flex items-center text-sm text-gray-600"> + <span> + <i18n.Translate>Converted</i18n.Translate> + </span> + </dt> + <dd class="text-sm text-gray-900"> + <RenderAmount + value={cashoutCalc.beforeFee} + spec={info.regional_currency_specification} + /> + </dd> + </div> + )} + <div class="flex justify-between items-center border-t-2 afu pt-4"> + <dt class="text-lg text-gray-900 font-medium"> + <i18n.Translate>Cashout after fee</i18n.Translate> + </dt> + <dd class="text-lg text-gray-900 font-medium"> + <RenderAmount + value={cashoutCalc.credit} + withColor + spec={info.regional_currency_specification} + /> + </dd> + </div> + </dl> + </div> + + {cashoutCalc && + status.status === "ok" && + Amounts.cmp(status.result.amount, cashoutCalc.credit) < + 0 ? ( + <div class="p-4"> + <Attention + title={i18n.str`Bad configuration`} + type="warning" + > + <i18n.Translate> + This configuration allows users to cash out more of + what has been cashed in. + </i18n.Translate> + </Attention> + </div> + ) : undefined} + </div> + )} + </Fragment> + )} + + <div class="flex items-center justify-between mt-4 gap-x-6 border-t border-gray-900/10 px-4 py-4"> + <a + name="cancel" + href={routeCancel.url({})} + class="text-sm font-semibold leading-6 text-gray-900" + > + <i18n.Translate>Cancel</i18n.Translate> + </a> + {section == "cashin" || section == "cashout" ? ( + <Fragment> + <button + type="submit" + name="update conversion" + 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={async () => { + doUpdate(); + }} + > + <i18n.Translate>Update</i18n.Translate> + </button> + </Fragment> + ) : ( + <div /> + )} + </div> + </form> + </div> + </div> + ); + }; +} + +export const ConversionConfig = utils.recursive(useComponentState); + +/** + * + * @param i18n + * @param regional + * @param fiat + * @returns form validator + */ +function createFormValidator( + i18n: InternationalizationAPI, + regional: string, + fiat: string, +) { + return function check(state: FormValues<FormType>): FormStatus<FormType> { + const cashin_min_amount = Amounts.parse( + `${fiat}:${state.conv.cashin_min_amount}`, + ); + const cashin_tiny_amount = Amounts.parse( + `${regional}:${state.conv.cashin_tiny_amount}`, + ); + const cashin_fee = Amounts.parse(`${regional}:${state.conv.cashin_fee}`); + + const cashout_min_amount = Amounts.parse( + `${regional}:${state.conv.cashout_min_amount}`, + ); + const cashout_tiny_amount = Amounts.parse( + `${fiat}:${state.conv.cashout_tiny_amount}`, + ); + const cashout_fee = Amounts.parse(`${fiat}:${state.conv.cashout_fee}`); + + const am = Amounts.parse(`${fiat}:${state.amount}`); + + const cashin_ratio = Number.parseFloat(state.conv.cashin_ratio ?? ""); + const cashout_ratio = Number.parseFloat(state.conv.cashout_ratio ?? ""); + + const errors = undefinedIfEmpty<FormErrors<FormType>>({ + conv: undefinedIfEmpty<FormErrors<FormType["conv"]>>({ + cashin_min_amount: !state.conv.cashin_min_amount + ? i18n.str`required` + : !cashin_min_amount + ? i18n.str`invalid` + : undefined, + cashin_tiny_amount: !state.conv.cashin_tiny_amount + ? i18n.str`required` + : !cashin_tiny_amount + ? i18n.str`invalid` + : undefined, + cashin_fee: !state.conv.cashin_fee + ? i18n.str`required` + : !cashin_fee + ? i18n.str`invalid` + : undefined, + + cashout_min_amount: !state.conv.cashout_min_amount + ? i18n.str`required` + : !cashout_min_amount + ? i18n.str`invalid` + : undefined, + cashout_tiny_amount: !state.conv.cashin_tiny_amount + ? i18n.str`required` + : !cashout_tiny_amount + ? i18n.str`invalid` + : undefined, + cashout_fee: !state.conv.cashin_fee + ? i18n.str`required` + : !cashout_fee + ? i18n.str`invalid` + : undefined, + + cashin_rounding_mode: !state.conv.cashin_rounding_mode + ? i18n.str`required` + : undefined, + cashout_rounding_mode: !state.conv.cashout_rounding_mode + ? i18n.str`required` + : undefined, + + cashin_ratio: !state.conv.cashin_ratio + ? i18n.str`required` + : Number.isNaN(cashin_ratio) + ? i18n.str`invalid` + : undefined, + cashout_ratio: !state.conv.cashout_ratio + ? i18n.str`required` + : Number.isNaN(cashout_ratio) + ? i18n.str`invalid` + : undefined, + }), + + amount: !state.amount + ? i18n.str`required` + : !am + ? i18n.str`invalid` + : undefined, + }); + + const result: RecursivePartial<FormType> = { + amount: am, + conv: { + cashin_fee: !errors?.conv?.cashin_fee + ? Amounts.stringify(cashin_fee!) + : undefined, + cashin_min_amount: !errors?.conv?.cashin_min_amount + ? Amounts.stringify(cashin_min_amount!) + : undefined, + cashin_ratio: !errors?.conv?.cashin_ratio + ? String(cashin_ratio!) + : undefined, + cashin_rounding_mode: !errors?.conv?.cashin_rounding_mode + ? state.conv.cashin_rounding_mode! + : undefined, + cashin_tiny_amount: !errors?.conv?.cashin_tiny_amount + ? Amounts.stringify(cashin_tiny_amount!) + : undefined, + cashout_fee: !errors?.conv?.cashout_fee + ? Amounts.stringify(cashout_fee!) + : undefined, + cashout_min_amount: !errors?.conv?.cashout_min_amount + ? Amounts.stringify(cashout_min_amount!) + : undefined, + cashout_ratio: !errors?.conv?.cashout_ratio + ? String(cashout_ratio!) + : undefined, + cashout_rounding_mode: !errors?.conv?.cashout_rounding_mode + ? state.conv.cashout_rounding_mode! + : undefined, + cashout_tiny_amount: !errors?.conv?.cashout_tiny_amount + ? Amounts.stringify(cashout_tiny_amount!) + : undefined, + }, + }; + return errors === undefined + ? { status: "ok", result: result as FormType, errors } + : { status: "fail", result, errors }; + }; +} + +function ConversionForm({ + id, + inputCurrency, + outputCurrency, + fee, + minimum, + ratio, + rounding, + tiny, +}: { + inputCurrency: string; + outputCurrency: string; + minimum: UIField | undefined; + tiny: UIField | undefined; + fee: UIField | undefined; + rounding: UIField | undefined; + ratio: UIField | undefined; + id: string; +}): VNode { + const { i18n } = useTranslationContext(); + return ( + <Fragment> + <div class="px-6 pt-6"> + <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"> + <label + for={`${id}_min_amount`} + class="block text-sm font-medium leading-6 text-gray-900" + >{i18n.str`Minimum amount`}</label> + <InputAmount + name={`${id}_min_amount`} + left + currency={inputCurrency} + value={minimum?.value ?? ""} + onChange={minimum?.onUpdate} + /> + <ShowInputErrorLabel + message={minimum?.error} + isDirty={minimum?.value !== undefined} + /> + <p class="mt-2 text-sm text-gray-500"> + <i18n.Translate> + Only cashout operation above this threshold will be allowed + </i18n.Translate> + </p> + </div> + </div> + </div> + + <div class="px-6 pt-6"> + <label + class="block text-sm font-medium leading-6 text-gray-900" + for={`${id}_ratio`} + > + {i18n.str`Ratio`} + </label> + <div class="mt-2"> + <input + type="number" + class="block 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="current" + id={`${id}_ratio`} + data-error={!!ratio?.error && ratio?.value !== undefined} + value={ratio?.value ?? ""} + onChange={(e) => { + ratio?.onUpdate(e.currentTarget.value); + }} + autocomplete="off" + /> + <ShowInputErrorLabel + message={ratio?.error} + isDirty={ratio?.value !== undefined} + /> + </div> + <p class="mt-2 text-sm text-gray-500"> + <i18n.Translate>Conversion ratio between currencies</i18n.Translate> + </p> + </div> + + <div class="px-6 pt-4"> + <Attention title={i18n.str`Example conversion`}> + <i18n.Translate> + 1 {inputCurrency} will be converted into {ratio?.value}{" "} + {outputCurrency} + </i18n.Translate> + </Attention> + </div> + + <div class="px-6 pt-6"> + <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"> + <label + for={`${id}_tiny_amount`} + class="block text-sm font-medium leading-6 text-gray-900" + >{i18n.str`Rounding value`}</label> + <InputAmount + name={`${id}_tiny_amount`} + left + currency={outputCurrency} + value={tiny?.value ?? ""} + onChange={tiny?.onUpdate} + /> + <ShowInputErrorLabel + message={tiny?.error} + isDirty={tiny?.value !== undefined} + /> + <p class="mt-2 text-sm text-gray-500"> + <i18n.Translate> + Smallest difference between two amounts after the ratio is + applied. + </i18n.Translate> + </p> + </div> + </div> + </div> + + <div class="px-6 pt-6"> + <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"> + <label + class="block text-sm font-medium leading-6 text-gray-900" + for={`${id}_channel`} + > + {i18n.str`Rounding mode`} + </label> + <div class="mt-2 max-w-xl text-sm text-gray-500"> + <div class="px-4 mt-4 grid grid-cols-1 gap-y-6"> + <label + onClick={(e) => { + e.preventDefault(); + rounding?.onUpdate("zero"); + }} + data-selected={rounding?.value === "zero"} + class="relative flex data-[disabled=false]:cursor-pointer rounded-lg border bg-white data-[disabled=true]:bg-gray-200 p-4 shadow-sm focus:outline-none border-gray-300 data-[selected=true]:ring-2 data-[selected=true]:ring-indigo-600" + > + <input + type="radio" + name="channel" + value="Newsletter" + class="sr-only" + /> + <span class="flex flex-1"> + <span class="flex flex-col"> + <span class="block text-sm font-medium text-gray-900 "> + <i18n.Translate>Zero</i18n.Translate> + </span> + <i18n.Translate> + Amount will be round below to the largest possible value + smaller than the input. + </i18n.Translate> + </span> + </span> + <svg + data-selected={rounding?.value === "zero"} + class="h-5 w-5 text-indigo-600 data-[selected=false]:hidden" + viewBox="0 0 20 20" + fill="currentColor" + aria-hidden="true" + > + <path + fill-rule="evenodd" + d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z" + clip-rule="evenodd" + /> + </svg> + </label> + + <label + onClick={(e) => { + e.preventDefault(); + rounding?.onUpdate("up"); + }} + data-selected={rounding?.value === "up"} + class="relative flex data-[disabled=false]:cursor-pointer rounded-lg border data-[disabled=true]:bg-gray-200 p-4 shadow-sm focus:outline-none border-gray-300 data-[selected=true]:ring-2 data-[selected=true]:ring-indigo-600" + > + <input + type="radio" + name="channel" + value="Existing Customers" + class="sr-only" + /> + <span class="flex flex-1"> + <span class="flex flex-col"> + <span class="block text-sm font-medium text-gray-900 "> + <i18n.Translate>Up</i18n.Translate> + </span> + <i18n.Translate> + Amount will be round up to the smallest possible value + larger than the input. + </i18n.Translate> + </span> + </span> + <svg + data-selected={rounding?.value === "up"} + class="h-5 w-5 text-indigo-600 data-[selected=false]:hidden" + viewBox="0 0 20 20" + fill="currentColor" + aria-hidden="true" + > + <path + fill-rule="evenodd" + d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z" + clip-rule="evenodd" + /> + </svg> + </label> + <label + onClick={(e) => { + e.preventDefault(); + rounding?.onUpdate("nearest"); + }} + data-selected={rounding?.value === "nearest"} + class="relative flex data-[disabled=false]:cursor-pointer rounded-lg border data-[disabled=true]:bg-gray-200 p-4 shadow-sm focus:outline-none border-gray-300 data-[selected=true]:ring-2 data-[selected=true]:ring-indigo-600" + > + <input + type="radio" + name="channel" + value="Existing Customers" + class="sr-only" + /> + <span class="flex flex-1"> + <span class="flex flex-col"> + <span class="block text-sm font-medium text-gray-900 "> + <i18n.Translate>Nearest</i18n.Translate> + </span> + <i18n.Translate> + Amount will be round to the closest possible value. + </i18n.Translate> + </span> + </span> + <svg + data-selected={rounding?.value === "nearest"} + class="h-5 w-5 text-indigo-600 data-[selected=false]:hidden" + viewBox="0 0 20 20" + fill="currentColor" + aria-hidden="true" + > + <path + fill-rule="evenodd" + d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z" + clip-rule="evenodd" + /> + </svg> + </label> + </div> + </div> + </div> + </div> + </div> + + <div class="px-6 pt-4"> + <Attention title={i18n.str`Examples`}> + <section class="grid grid-cols-1 gap-y-3 text-gray-600"> + <details class="group text-sm"> + <summary class="flex cursor-pointer flex-row items-center justify-between "> + <i18n.Translate> + Rounding an amount of 1.24 with rounding value 0.1 + </i18n.Translate> + <svg + class="h-6 w-6 rotate-0 transform group-open:rotate-180" + xmlns="http://www.w3.org/2000/svg" + fill="none" + viewBox="0 0 24 24" + stroke-width="2" + stroke="currentColor" + aria-hidden="true" + > + <path + stroke-linecap="round" + stroke-linejoin="round" + d="M19 9l-7 7-7-7" + ></path> + </svg> + </summary> + <p class="text-gray-900 my-4"> + <i18n.Translate> + Given the rounding value of 0.1 the possible values closest to + 1.24 are: 1.1, 1.2, 1.3, 1.4. + </i18n.Translate> + </p> + <p class="text-gray-900 my-4"> + <i18n.Translate> + With the "zero" mode the value will be rounded to 1.2 + </i18n.Translate> + </p> + <p class="text-gray-900 my-4"> + <i18n.Translate> + With the "nearest" mode the value will be rounded to 1.2 + </i18n.Translate> + </p> + <p class="text-gray-900 mt-4"> + <i18n.Translate> + With the "up" mode the value will be rounded to 1.3 + </i18n.Translate> + </p> + </details> + <details class="group "> + <summary class="flex cursor-pointer flex-row items-center justify-between "> + <i18n.Translate> + Rounding an amount of 1.26 with rounding value 0.1 + </i18n.Translate> + <svg + class="h-6 w-6 rotate-0 transform group-open:rotate-180" + xmlns="http://www.w3.org/2000/svg" + fill="none" + viewBox="0 0 24 24" + stroke-width="2" + stroke="currentColor" + aria-hidden="true" + > + <path + stroke-linecap="round" + stroke-linejoin="round" + d="M19 9l-7 7-7-7" + ></path> + </svg> + </summary> + <p class="text-gray-900 my-4"> + <i18n.Translate> + Given the rounding value of 0.1 the possible values closest to + 1.24 are: 1.1, 1.2, 1.3, 1.4. + </i18n.Translate> + </p> + <p class="text-gray-900 my-4"> + <i18n.Translate> + With the "zero" mode the value will be rounded to 1.2 + </i18n.Translate> + </p> + <p class="text-gray-900 my-4"> + <i18n.Translate> + With the "nearest" mode the value will be rounded to 1.3 + </i18n.Translate> + </p> + <p class="text-gray-900 my-4"> + <i18n.Translate> + With the "up" mode the value will be rounded to 1.3 + </i18n.Translate> + </p> + </details> + <details class="group "> + <summary class="flex cursor-pointer flex-row items-center justify-between "> + <i18n.Translate> + Rounding an amount of 1.24 with rounding value 0.3 + </i18n.Translate> + <svg + class="h-6 w-6 rotate-0 transform group-open:rotate-180" + xmlns="http://www.w3.org/2000/svg" + fill="none" + viewBox="0 0 24 24" + stroke-width="2" + stroke="currentColor" + aria-hidden="true" + > + <path + stroke-linecap="round" + stroke-linejoin="round" + d="M19 9l-7 7-7-7" + ></path> + </svg> + </summary> + <p class="text-gray-900 my-4"> + <i18n.Translate> + Given the rounding value of 0.3 the possible values closest to + 1.24 are: 0.9, 1.2, 1.5, 1.8. + </i18n.Translate> + </p> + <p class="text-gray-900 my-4"> + <i18n.Translate> + With the "zero" mode the value will be rounded to 1.2 + </i18n.Translate> + </p> + <p class="text-gray-900 my-4"> + <i18n.Translate> + With the "nearest" mode the value will be rounded to 1.2 + </i18n.Translate> + </p> + <p class="text-gray-900 my-4"> + <i18n.Translate> + With the "up" mode the value will be rounded to 1.5 + </i18n.Translate> + </p> + </details> + <details class="group "> + <summary class="flex cursor-pointer flex-row items-center justify-between "> + <i18n.Translate> + Rounding an amount of 1.26 with rounding value 0.3 + </i18n.Translate> + <svg + class="h-6 w-6 rotate-0 transform group-open:rotate-180" + xmlns="http://www.w3.org/2000/svg" + fill="none" + viewBox="0 0 24 24" + stroke-width="2" + stroke="currentColor" + aria-hidden="true" + > + <path + stroke-linecap="round" + stroke-linejoin="round" + d="M19 9l-7 7-7-7" + ></path> + </svg> + </summary> + <p class="text-gray-900 my-4"> + <i18n.Translate> + Given the rounding value of 0.3 the possible values closest to + 1.24 are: 0.9, 1.2, 1.5, 1.8. + </i18n.Translate> + </p> + <p class="text-gray-900 my-4"> + <i18n.Translate> + With the "zero" mode the value will be rounded to 1.2 + </i18n.Translate> + </p> + <p class="text-gray-900 my-4"> + <i18n.Translate> + With the "nearest" mode the value will be rounded to 1.3 + </i18n.Translate> + </p> + <p class="text-gray-900 my-4"> + <i18n.Translate> + With the "up" mode the value will be rounded to 1.3 + </i18n.Translate> + </p> + </details> + </section> + </Attention> + </div> + + <div class="px-6 pt-6"> + <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"> + <label + for={`${id}_fee`} + class="block text-sm font-medium leading-6 text-gray-900" + >{i18n.str`Fee`}</label> + <InputAmount + name={`${id}_fee`} + left + currency={outputCurrency} + value={fee?.value ?? ""} + onChange={fee?.onUpdate} + /> + <ShowInputErrorLabel + message={fee?.error} + isDirty={fee?.value !== undefined} + /> + <p class="mt-2 text-sm text-gray-500"> + <i18n.Translate> + Amount to be deducted before amount is credited. + </i18n.Translate> + </p> + </div> + </div> + </div> + </Fragment> + ); +} diff --git a/packages/bank-ui/src/pages/regional/CreateCashout.tsx b/packages/bank-ui/src/pages/regional/CreateCashout.tsx new file mode 100644 index 000000000..c51b96b8b --- /dev/null +++ b/packages/bank-ui/src/pages/regional/CreateCashout.tsx @@ -0,0 +1,732 @@ +/* + This file is part of GNU Taler + (C) 2022-2024 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ +import { + AbsoluteTime, + Amounts, + HttpStatusCode, + TalerError, + TalerErrorCode, + TranslatedString, + assertUnreachable, + encodeCrock, + getRandomBytes, + parsePaytoUri, +} from "@gnu-taler/taler-util"; +import { + Attention, + Loading, + LocalNotificationBanner, + ShowInputErrorLabel, + notifyInfo, + useLocalNotification, + useTranslationContext, +} from "@gnu-taler/web-util/browser"; +import { Fragment, VNode, h } from "preact"; +import { useEffect, useState } from "preact/hooks"; +import { ErrorLoadingWithDebug } from "../../components/ErrorLoadingWithDebug.js"; +import { useBankCoreApiContext } from "@gnu-taler/web-util/browser"; +import { useAccountDetails } from "../../hooks/account.js"; +import { useBankState } from "../../hooks/bank-state.js"; +import { + TransferCalculation, + useCashoutEstimator, + useConversionInfo, +} from "../../hooks/regional.js"; +import { useSessionState } from "../../hooks/session.js"; +import { RouteDefinition } from "@gnu-taler/web-util/browser"; +import { TanChannel, undefinedIfEmpty } from "../../utils.js"; +import { LoginForm } from "../LoginForm.js"; +import { + InputAmount, + RenderAmount, + doAutoFocus, +} from "../PaytoWireTransferForm.js"; + +interface Props { + account: string; + focus?: boolean; + onAuthorizationRequired: () => void; + onCashout: () => void; + routeClose: RouteDefinition; + routeHere: RouteDefinition; +} + +type FormType = { + isDebit: boolean; + amount: string; + subject: string; + channel: TanChannel; +}; +type ErrorFrom<T> = { + [P in keyof T]+?: string; +}; + +export function CreateCashout({ + account: accountName, + onAuthorizationRequired, + onCashout, + focus, + routeHere, + routeClose, +}: Props): VNode { + const { i18n } = useTranslationContext(); + const resultAccount = useAccountDetails(accountName); + const { + estimateByCredit: calculateFromCredit, + estimateByDebit: calculateFromDebit, + } = useCashoutEstimator(); + const { state: credentials } = useSessionState(); + const creds = credentials.status !== "loggedIn" ? undefined : credentials; + const [, updateBankState] = useBankState(); + + const { + lib: { bank: api }, + config, + } = useBankCoreApiContext(); + const [form, setForm] = useState<Partial<FormType>>({ isDebit: true }); + const [notification, notify, handleError] = useLocalNotification(); + const info = useConversionInfo(); + + if (!config.allow_conversion) { + return ( + <Fragment> + <Attention type="warning" title={i18n.str`Unable to create a cashout`}> + <i18n.Translate> + The bank configuration does not support cashout operations. + </i18n.Translate> + </Attention> + <div class="mt-5 sm:mt-6"> + <a + href={routeClose.url({})} + name="close" + class="inline-flex w-full justify-center rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" + > + <i18n.Translate>Close</i18n.Translate> + </a> + </div> + </Fragment> + ); + } + + if (!resultAccount) { + return <Loading />; + } + if (resultAccount instanceof TalerError) { + return <ErrorLoadingWithDebug error={resultAccount} />; + } + if (resultAccount.type === "fail") { + switch (resultAccount.case) { + case HttpStatusCode.Unauthorized: + return <LoginForm currentUser={accountName} />; + case HttpStatusCode.NotFound: + return <LoginForm currentUser={accountName} />; + default: + assertUnreachable(resultAccount); + } + } + if (!info) { + return <Loading />; + } + + if (info instanceof TalerError) { + return <ErrorLoadingWithDebug error={info} />; + } + if (info.type === "fail") { + switch (info.case) { + case HttpStatusCode.NotImplemented: { + return ( + <Attention type="danger" title={i18n.str`Cashout are disabled`}> + <i18n.Translate> + Cashout should be enable by configuration and the conversion rate + should be initialized with fee, ratio and rounding mode. + </i18n.Translate> + </Attention> + ); + } + default: + assertUnreachable(info.case); + } + } + + const conversionInfo = info.body.conversion_rate; + if (!conversionInfo) { + return ( + <div>conversion enabled but server replied without conversion_rate</div> + ); + } + + const { + fiat_currency, + regional_currency, + fiat_currency_specification, + regional_currency_specification, + } = info.body; + const regionalZero = Amounts.zeroOfCurrency(regional_currency); + const fiatZero = Amounts.zeroOfCurrency(fiat_currency); + + const account = { + balance: Amounts.parseOrThrow(resultAccount.body.balance.amount), + balanceIsDebit: + resultAccount.body.balance.credit_debit_indicator == "debit", + debitThreshold: Amounts.parseOrThrow(resultAccount.body.debit_threshold), + minCashout: resultAccount.body.min_cashout === undefined ? regionalZero : Amounts.parseOrThrow(resultAccount.body.min_cashout) + }; + + const limit = account.balanceIsDebit + ? Amounts.sub(account.debitThreshold, account.balance).amount + : Amounts.add(account.balance, account.debitThreshold).amount; + + const zeroCalc = { + debit: regionalZero, + credit: fiatZero, + beforeFee: fiatZero, + }; + const [calculationResult, setCalculation] = + useState<TransferCalculation>(zeroCalc); + const sellFee = Amounts.parseOrThrow(conversionInfo.cashout_fee); + const sellRate = conversionInfo.cashout_ratio; + /** + * can be in regional currency or fiat currency + * depending on the isDebit flag + */ + const inputAmount = Amounts.parseOrThrow( + `${form.isDebit ? regional_currency : fiat_currency}:${ + !form.amount ? "0" : form.amount + }`, + ); + + useEffect(() => { + async function doAsync() { + await handleError(async () => { + const higerThanMin = form.isDebit + ? Amounts.cmp(inputAmount, conversionInfo.cashout_min_amount) === 1 + : true; + const notZero = Amounts.isNonZero(inputAmount); + if (notZero && higerThanMin) { + const resp = await (form.isDebit + ? calculateFromDebit(inputAmount, sellFee) + : calculateFromCredit(inputAmount, sellFee)); + setCalculation(resp); + } else { + setCalculation(zeroCalc); + } + }); + } + doAsync(); + }, [form.amount, form.isDebit]); + + const calc = + calculationResult === "amount-is-too-small" ? zeroCalc : calculationResult; + + const balanceAfter = Amounts.sub(account.balance, calc.debit).amount; + + function updateForm(newForm: typeof form): void { + setForm(newForm); + } + const errors = undefinedIfEmpty<ErrorFrom<typeof form>>({ + subject: !form.subject ? i18n.str`Required` : undefined, + amount: !form.amount + ? i18n.str`Required` + : !inputAmount + ? i18n.str`Invalid` + : Amounts.cmp(limit, calc.debit) === -1 + ? i18n.str`Balance is not enough` + : calculationResult === "amount-is-too-small" + ? i18n.str`Amount needs to be higher` + : Amounts.cmp(calc.debit, conversionInfo.cashout_min_amount) < 0 + ? i18n.str`No account can't cashout less than ${ + Amounts.stringifyValueWithSpec( + Amounts.parseOrThrow(conversionInfo.cashout_min_amount), + regional_currency_specification, + ).normal + }` + : Amounts.cmp(calc.debit, account.minCashout) < 0 + ? i18n.str`Your account can't cashout less than ${ + Amounts.stringifyValueWithSpec( + Amounts.parseOrThrow(account.minCashout), + regional_currency_specification, + ).normal + }` + + : Amounts.isZero(calc.credit) + ? i18n.str`The total transfer at destination will be zero` + : undefined, + }); + const trimmedAmountStr = form.amount?.trim(); + + async function createCashout() { + const request_uid = encodeCrock(getRandomBytes(32)); + await handleError(async () => { + if (!creds || !form.subject) return; + const request = { + request_uid, + amount_credit: Amounts.stringify(calc.credit), + amount_debit: Amounts.stringify(calc.debit), + subject: form.subject, + }; + const resp = await api.createCashout(creds, request); + if (resp.type === "ok") { + notifyInfo(i18n.str`Cashout created`); + onCashout(); + } else { + switch (resp.case) { + case HttpStatusCode.Accepted: { + updateBankState("currentChallenge", { + operation: "create-cashout", + id: String(resp.body.challenge_id), + sent: AbsoluteTime.never(), + location: routeHere.url({}), + request, + }); + return onAuthorizationRequired(); + } + case HttpStatusCode.NotFound: + return notify({ + type: "error", + title: i18n.str`Account not found`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + when: AbsoluteTime.now(), + }); + case TalerErrorCode.BANK_TRANSFER_REQUEST_UID_REUSED: + return notify({ + type: "error", + title: i18n.str`Duplicated request detected, check if the operation succeeded or try again.`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + when: AbsoluteTime.now(), + }); + case TalerErrorCode.BANK_BAD_CONVERSION: + return notify({ + type: "error", + title: i18n.str`The conversion rate was incorrectly applied`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + when: AbsoluteTime.now(), + }); + case TalerErrorCode.BANK_UNALLOWED_DEBIT: + return notify({ + type: "error", + title: i18n.str`The account does not have sufficient funds`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + when: AbsoluteTime.now(), + }); + case HttpStatusCode.NotImplemented: + return notify({ + type: "error", + title: i18n.str`Cashout are disabled`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + when: AbsoluteTime.now(), + }); + case TalerErrorCode.BANK_CONFIRM_INCOMPLETE: + return notify({ + type: "error", + title: i18n.str`Missing cashout URI in the profile`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + when: AbsoluteTime.now(), + }); + case TalerErrorCode.BANK_CONVERSION_AMOUNT_TO_SMALL: + return notify({ + type: "error", + title: i18n.str`The amount is less than the minimum allowed.`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + when: AbsoluteTime.now(), + }); + + case TalerErrorCode.BANK_TAN_CHANNEL_SCRIPT_FAILED: + return notify({ + type: "error", + title: i18n.str`Sending the confirmation message failed, retry later or contact the administrator.`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + when: AbsoluteTime.now(), + }); + } + assertUnreachable(resp); + } + }); + } + const cashoutDisabled = + config.supported_tan_channels.length < 1 || + !resultAccount.body.cashout_payto_uri; + + const cashoutAccount = !resultAccount.body.cashout_payto_uri + ? undefined + : parsePaytoUri(resultAccount.body.cashout_payto_uri); + const cashoutAccountName = !cashoutAccount + ? undefined + : cashoutAccount.targetPath; + + const cashoutLegalName = !cashoutAccount + ? undefined + : cashoutAccount.params["receiver-name"]; + + return ( + <div> + <LocalNotificationBanner notification={notification} /> + + <div class="grid grid-cols-1 gap-x-8 gap-y-8 pt-6 md:grid-cols-3 bg-gray-100 my-4 px-4 pb-4 rounded-lg"> + <section class="mt-4 rounded-sm px-4 py-6 p-8 "> + <h2 id="summary-heading" class="font-medium text-lg"> + <i18n.Translate>Cashout</i18n.Translate> + </h2> + + <dl class="mt-4 space-y-4"> + <div class="justify-between items-center flex"> + <dt class="text-sm text-gray-600"> + <i18n.Translate>Conversion rate</i18n.Translate> + </dt> + <dd class="text-sm text-gray-900">{sellRate}</dd> + </div> + + <div class="flex items-center justify-between border-t-2 afu pt-4"> + <dt class="flex items-center text-sm text-gray-600"> + <span> + <i18n.Translate>Balance</i18n.Translate> + </span> + </dt> + <dd class="text-sm text-gray-900"> + <RenderAmount + value={account.balance} + spec={regional_currency_specification} + /> + </dd> + </div> + <div class="flex items-center justify-between border-t-2 afu pt-4"> + <dt class="flex items-center text-sm text-gray-600"> + <span> + <i18n.Translate>Fee</i18n.Translate> + </span> + </dt> + <dd class="text-sm text-gray-900"> + <RenderAmount + value={sellFee} + spec={fiat_currency_specification} + /> + </dd> + </div> + {cashoutAccountName && cashoutLegalName ? ( + <Fragment> + <div class="flex items-center justify-between border-t-2 afu pt-4"> + <dt class="flex items-center text-sm text-gray-600"> + <span> + <i18n.Translate>To account</i18n.Translate> + </span> + </dt> + <dd class="text-sm text-gray-900">{cashoutAccountName}</dd> + </div> + <div class="flex items-center justify-between border-t-2 afu pt-4"> + <dt class="flex items-center text-sm text-gray-600"> + <span> + <i18n.Translate>Legal name</i18n.Translate> + </span> + </dt> + <dd class="text-sm text-gray-900">{cashoutLegalName}</dd> + </div> + <p class="mt-2 text-sm text-gray-500"> + <i18n.Translate> + If this name doesn't match the account holder's name your + transaction may fail. + </i18n.Translate> + </p> + </Fragment> + ) : ( + <div class="flex items-center justify-between border-t-2 afu pt-4"> + <Attention type="warning" title={i18n.str`No cashout account`}> + <i18n.Translate> + Before doing a cashout you need to complete your profile + </i18n.Translate> + </Attention> + </div> + )} + </dl> + </section> + <form + class="bg-white shadow-sm ring-1 ring-gray-900/5 sm:rounded-xl md:col-span-2" + autoCapitalize="none" + autoCorrect="off" + onSubmit={(e) => { + e.preventDefault(); + }} + > + <div class="px-4 py-6 sm:p-8"> + <div class="grid max-w-2xl grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6"> + {/* subject */} + + <div class="sm:col-span-5"> + <label + class="block text-sm font-medium leading-6 text-gray-900" + for="subject" + > + {i18n.str`Transfer subject`} + <b style={{ color: "red" }}> *</b> + </label> + <div class="mt-2"> + <input + ref={focus ? doAutoFocus : undefined} + type="text" + class="block w-full rounded-md disabled:bg-gray-200 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="subject" + id="subject" + disabled={cashoutDisabled} + data-error={!!errors?.subject && form.subject !== undefined} + value={form.subject ?? ""} + onChange={(e) => { + form.subject = e.currentTarget.value; + updateForm(structuredClone(form)); + }} + autocomplete="off" + /> + <ShowInputErrorLabel + message={errors?.subject} + isDirty={form.subject !== undefined} + /> + </div> + </div> + + <div class="sm:col-span-5"> + <label + class="block text-sm font-medium leading-6 text-gray-900" + for="subject" + > + {i18n.str`Currency`} + </label> + + <div class="mt-2"> + <button + type="button" + name="set 50" + class=" inline-flex p-4 text-sm items-center rounded-l-md bg-white text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:z-10" + onClick={(e) => { + e.preventDefault(); + form.isDebit = true; + updateForm(structuredClone(form)); + }} + > + {form.isDebit ? ( + <svg + class="self-center flex-none h-5 w-5 text-indigo-600" + viewBox="0 0 20 20" + fill="currentColor" + aria-hidden="true" + > + <path + fill-rule="evenodd" + d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z" + clip-rule="evenodd" + /> + </svg> + ) : ( + <svg + fill="none" + viewBox="0 0 24 24" + stroke-width="1.5" + stroke="currentColor" + class="w-5 h-5" + > + <path d="M15 12H9m12 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" /> + </svg> + )} + + <i18n.Translate>Send {regional_currency}</i18n.Translate> + </button> + <button + type="button" + name="set 25" + class=" -ml-px -mr-px inline-flex p-4 text-sm items-center rounded-r-md bg-white text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:z-10" + onClick={(e) => { + e.preventDefault(); + form.isDebit = false; + updateForm(structuredClone(form)); + }} + > + {!form.isDebit ? ( + <svg + class="self-center flex-none h-5 w-5 text-indigo-600" + viewBox="0 0 20 20" + fill="currentColor" + aria-hidden="true" + > + <path + fill-rule="evenodd" + d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z" + clip-rule="evenodd" + /> + </svg> + ) : ( + <svg + fill="none" + viewBox="0 0 24 24" + stroke-width="1.5" + stroke="currentColor" + class="w-5 h-5" + > + <path d="M15 12H9m12 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" /> + </svg> + )} + + <i18n.Translate>Receive {fiat_currency}</i18n.Translate> + </button> + </div> + </div> + + {/* amount */} + <div class="sm:col-span-5"> + <div class="flex justify-between"> + <label + class="block text-sm font-medium leading-6 text-gray-900" + for="amount" + > + {i18n.str`Amount`} + <b style={{ color: "red" }}> *</b> + </label> + {/* <button + type="button" + data-enabled={form.isDebit} + 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={() => { + form.isDebit = !form.isDebit; + updateForm(structuredClone(form)); + }} + > + <span + aria-hidden="true" + data-enabled={form.isDebit} + class="translate-x-5 data-[enabled=false]: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 class="mt-2"> + <InputAmount + name="amount" + left + currency={form.isDebit ? regional_currency : fiat_currency} + value={trimmedAmountStr} + onChange={ + cashoutDisabled + ? undefined + : (value) => { + form.amount = value; + updateForm(structuredClone(form)); + } + } + /> + <ShowInputErrorLabel + message={errors?.amount} + isDirty={form.amount !== undefined} + /> + </div> + </div> + + {Amounts.isZero(calc.credit) ? undefined : ( + <div class="sm:col-span-5"> + <dl class="mt-4 space-y-4"> + <div class="justify-between items-center flex "> + <dt class="text-sm text-gray-600"> + <i18n.Translate>Total cost</i18n.Translate> + </dt> + <dd class="text-sm text-gray-900"> + <RenderAmount + value={calc.debit} + negative + withColor + spec={regional_currency_specification} + /> + </dd> + </div> + + <div class="flex items-center justify-between border-t-2 afu pt-4"> + <dt class="flex items-center text-sm text-gray-600"> + <span> + <i18n.Translate>Balance left</i18n.Translate> + </span> + </dt> + <dd class="text-sm text-gray-900"> + <RenderAmount + value={balanceAfter} + spec={regional_currency_specification} + /> + </dd> + </div> + {Amounts.isZero(sellFee) || + Amounts.isZero(calc.beforeFee) ? undefined : ( + <div class="flex items-center justify-between border-t-2 afu pt-4"> + <dt class="flex items-center text-sm text-gray-600"> + <span> + <i18n.Translate>Before fee</i18n.Translate> + </span> + </dt> + <dd class="text-sm text-gray-900"> + <RenderAmount + value={calc.beforeFee} + spec={fiat_currency_specification} + /> + </dd> + </div> + )} + <div class="flex justify-between items-center border-t-2 afu pt-4"> + <dt class="text-lg text-gray-900 font-medium"> + <i18n.Translate>Total cashout transfer</i18n.Translate> + </dt> + <dd class="text-lg text-gray-900 font-medium"> + <RenderAmount + value={calc.credit} + withColor + spec={fiat_currency_specification} + /> + </dd> + </div> + </dl> + </div> + )} + </div> + </div> + + <div class="flex items-center justify-between gap-x-6 border-t border-gray-900/10 px-4 py-4 sm:px-8"> + <a + href={routeClose.url({})} + name="cancel" + type="button" + class="text-sm font-semibold leading-6 text-gray-900" + > + <i18n.Translate>Cancel</i18n.Translate> + </a> + <button + type="submit" + name="cashout" + class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" + disabled={!!errors} + onClick={(e) => { + e.preventDefault(); + createCashout(); + }} + > + <i18n.Translate>Cashout</i18n.Translate> + </button> + </div> + </form> + </div> + </div> + ); +} diff --git a/packages/bank-ui/src/pages/regional/ShowCashoutDetails.tsx b/packages/bank-ui/src/pages/regional/ShowCashoutDetails.tsx new file mode 100644 index 000000000..aba00ad7a --- /dev/null +++ b/packages/bank-ui/src/pages/regional/ShowCashoutDetails.tsx @@ -0,0 +1,194 @@ +/* + This file is part of GNU Taler + (C) 2022-2024 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ +import { + AbsoluteTime, + Amounts, + HttpStatusCode, + TalerError, + assertUnreachable, +} from "@gnu-taler/taler-util"; +import { + Attention, + Loading, + useTranslationContext, +} from "@gnu-taler/web-util/browser"; +import { VNode, h } from "preact"; +import { ErrorLoadingWithDebug } from "../../components/ErrorLoadingWithDebug.js"; +import { Time } from "../../components/Time.js"; +import { useCashoutDetails, useConversionInfo } from "../../hooks/regional.js"; +import { RouteDefinition } from "@gnu-taler/web-util/browser"; +import { RenderAmount } from "../PaytoWireTransferForm.js"; + +interface Props { + id: string; + routeClose: RouteDefinition; +} +export function ShowCashoutDetails({ id, routeClose }: Props): VNode { + const { i18n } = useTranslationContext(); + const cid = Number.parseInt(id, 10); + + const result = useCashoutDetails(Number.isNaN(cid) ? undefined : cid); + const info = useConversionInfo(); + + if (Number.isNaN(cid)) { + return ( + <Attention + type="danger" + title={i18n.str`Cashout id should be a number`} + /> + ); + } + if (!result) { + return <Loading />; + } + if (result instanceof TalerError) { + return <ErrorLoadingWithDebug error={result} />; + } + if (result.type === "fail") { + switch (result.case) { + case HttpStatusCode.NotFound: + return ( + <Attention + type="warning" + title={i18n.str`This cashout not found. Maybe already aborted.`} + ></Attention> + ); + case HttpStatusCode.NotImplemented: + return ( + <Attention type="warning" title={i18n.str`Cashout are disabled`}> + <i18n.Translate> + Cashout should be enable by configuration and the conversion rate + should be initialized with fee, ratio and rounding mode. + </i18n.Translate> + </Attention> + ); + default: + assertUnreachable(result); + } + } + if (!info) { + return <Loading />; + } + + if (info instanceof TalerError) { + return <ErrorLoadingWithDebug error={info} />; + } + if (info.type === "fail") { + switch (info.case) { + case HttpStatusCode.NotImplemented: { + return ( + <Attention type="danger" title={i18n.str`Cashout are disabled`}> + <i18n.Translate> + Cashout should be enable by configuration and the conversion rate + should be initialized with fee, ratio and rounding mode. + </i18n.Translate> + </Attention> + ); + } + default: + assertUnreachable(info.case); + } + } + + const { fiat_currency_specification, regional_currency_specification } = + info.body; + + return ( + <div> + <div class="grid grid-cols-1 gap-x-8 gap-y-8 pt-6 md:grid-cols-3 bg-gray-100 my-4 px-4 pb-4 rounded-lg"> + <section class="rounded-sm px-4"> + <h2 id="summary-heading" class="font-medium text-lg"> + <i18n.Translate>Cashout detail</i18n.Translate> + </h2> + <dl class="mt-8 space-y-4"> + <div class="justify-between items-center flex"> + <dt class="text-sm text-gray-600"> + <i18n.Translate>Subject</i18n.Translate> + </dt> + <dd class="text-sm ">{result.body.subject}</dd> + </div> + </dl> + </section> + <div class="bg-white shadow-sm ring-1 ring-gray-900/5 sm:rounded-xl md:col-span-2"> + <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"> + <dl class="space-y-4"> + {result.body.creation_time.t_s !== "never" ? ( + <div class="justify-between items-center flex "> + <dt class=" text-gray-600"> + <i18n.Translate>Created</i18n.Translate> + </dt> + <dd class="text-sm "> + <Time + format="dd/MM/yyyy HH:mm:ss" + timestamp={AbsoluteTime.fromProtocolTimestamp( + result.body.creation_time, + )} + // relative={Duration.fromSpec({ days: 1 })} + /> + </dd> + </div> + ) : undefined} + + <div class="flex justify-between items-center border-t-2 afu pt-4"> + <dt class="text-gray-600"> + <i18n.Translate>Debited</i18n.Translate> + </dt> + <dd class=" font-medium"> + <RenderAmount + value={Amounts.parseOrThrow(result.body.amount_debit)} + negative + withColor + spec={regional_currency_specification} + /> + </dd> + </div> + + <div class="flex items-center justify-between border-t-2 afu pt-4"> + <dt class="flex items-center text-gray-600"> + <span> + <i18n.Translate>Credited</i18n.Translate> + </span> + </dt> + <dd class="text-sm "> + <RenderAmount + value={Amounts.parseOrThrow(result.body.amount_credit)} + withColor + spec={fiat_currency_specification} + /> + </dd> + </div> + </dl> + </div> + </div> + </div> + </div> + </div> + + <br /> + <div style={{ display: "flex", justifyContent: "space-between" }}> + <a + href={routeClose.url({})} + name="close" + class="text-sm font-semibold leading-6 text-gray-900" + > + <i18n.Translate>Close</i18n.Translate> + </a> + </div> + </div> + ); +} diff --git a/packages/bank-ui/src/pages/rnd.ts b/packages/bank-ui/src/pages/rnd.ts new file mode 100644 index 000000000..d66fb005b --- /dev/null +++ b/packages/bank-ui/src/pages/rnd.ts @@ -0,0 +1,2907 @@ +/* + This file is part of GNU Taler + (C) 2022-2024 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ +import { encodeCrock, getRandomBytes } from "@gnu-taler/taler-util"; + +const noun = [ + "people", + "history", + "way", + "art", + "world", + "information", + "map", + "two", + "family", + "government", + "health", + "system", + "computer", + "meat", + "year", + "thanks", + "music", + "person", + "reading", + "method", + "data", + "food", + "understanding", + "theory", + "law", + "bird", + "literature", + "problem", + "software", + "control", + "knowledge", + "power", + "ability", + "economics", + "love", + "internet", + "television", + "science", + "library", + "nature", + "fact", + "product", + "idea", + "temperature", + "investment", + "area", + "society", + "activity", + "story", + "industry", + "media", + "thing", + "oven", + "community", + "definition", + "safety", + "quality", + "development", + "language", + "management", + "player", + "variety", + "video", + "week", + "security", + "country", + "exam", + "movie", + "organization", + "equipment", + "physics", + "analysis", + "policy", + "series", + "thought", + "basis", + "boyfriend", + "direction", + "strategy", + "technology", + "army", + "camera", + "freedom", + "paper", + "environment", + "child", + "instance", + "month", + "truth", + "marketing", + "university", + "writing", + "article", + "department", + "difference", + "goal", + "news", + "audience", + "fishing", + "growth", + "income", + "marriage", + "user", + "combination", + "failure", + "meaning", + "medicine", + "philosophy", + "teacher", + "communication", + "night", + "chemistry", + "disease", + "disk", + "energy", + "nation", + "road", + "role", + "soup", + "advertising", + "location", + "success", + "addition", + "apartment", + "education", + "math", + "moment", + "painting", + "politics", + "attention", + "decision", + "event", + "property", + "shopping", + "student", + "wood", + "competition", + "distribution", + "entertainment", + "office", + "population", + "president", + "unit", + "category", + "cigarette", + "context", + "introduction", + "opportunity", + "performance", + "driver", + "flight", + "length", + "magazine", + "newspaper", + "relationship", + "teaching", + "cell", + "dealer", + "finding", + "lake", + "member", + "message", + "phone", + "scene", + "appearance", + "association", + "concept", + "customer", + "death", + "discussion", + "housing", + "inflation", + "insurance", + "mood", + "woman", + "advice", + "blood", + "effort", + "expression", + "importance", + "opinion", + "payment", + "reality", + "responsibility", + "situation", + "skill", + "statement", + "wealth", + "application", + "city", + "county", + "depth", + "estate", + "foundation", + "grandmother", + "heart", + "perspective", + "photo", + "recipe", + "studio", + "topic", + "collection", + "depression", + "imagination", + "passion", + "percentage", + "resource", + "setting", + "ad", + "agency", + "college", + "connection", + "criticism", + "debt", + "description", + "memory", + "patience", + "secretary", + "solution", + "administration", + "aspect", + "attitude", + "director", + "personality", + "psychology", + "recommendation", + "response", + "selection", + "storage", + "version", + "alcohol", + "argument", + "complaint", + "contract", + "emphasis", + "highway", + "loss", + "membership", + "possession", + "preparation", + "steak", + "union", + "agreement", + "cancer", + "currency", + "employment", + "engineering", + "entry", + "interaction", + "mixture", + "preference", + "region", + "republic", + "tradition", + "virus", + "actor", + "classroom", + "delivery", + "device", + "difficulty", + "drama", + "election", + "engine", + "football", + "guidance", + "hotel", + "owner", + "priority", + "protection", + "suggestion", + "tension", + "variation", + "anxiety", + "atmosphere", + "awareness", + "bath", + "bread", + "candidate", + "climate", + "comparison", + "confusion", + "construction", + "elevator", + "emotion", + "employee", + "employer", + "guest", + "height", + "leadership", + "mall", + "manager", + "operation", + "recording", + "sample", + "transportation", + "charity", + "cousin", + "disaster", + "editor", + "efficiency", + "excitement", + "extent", + "feedback", + "guitar", + "homework", + "leader", + "mom", + "outcome", + "permission", + "presentation", + "promotion", + "reflection", + "refrigerator", + "resolution", + "revenue", + "session", + "singer", + "tennis", + "basket", + "bonus", + "cabinet", + "childhood", + "church", + "clothes", + "coffee", + "dinner", + "drawing", + "hair", + "hearing", + "initiative", + "judgment", + "lab", + "measurement", + "mode", + "mud", + "orange", + "poetry", + "police", + "possibility", + "procedure", + "queen", + "ratio", + "relation", + "restaurant", + "satisfaction", + "sector", + "signature", + "significance", + "song", + "tooth", + "town", + "vehicle", + "volume", + "wife", + "accident", + "airport", + "appointment", + "arrival", + "assumption", + "baseball", + "chapter", + "committee", + "conversation", + "database", + "enthusiasm", + "error", + "explanation", + "farmer", + "gate", + "girl", + "hall", + "historian", + "hospital", + "injury", + "instruction", + "maintenance", + "manufacturer", + "meal", + "perception", + "pie", + "poem", + "presence", + "proposal", + "reception", + "replacement", + "revolution", + "river", + "son", + "speech", + "tea", + "village", + "warning", + "winner", + "worker", + "writer", + "assistance", + "breath", + "buyer", + "chest", + "chocolate", + "conclusion", + "contribution", + "cookie", + "courage", + "dad", + "desk", + "drawer", + "establishment", + "examination", + "garbage", + "grocery", + "honey", + "impression", + "improvement", + "independence", + "insect", + "inspection", + "inspector", + "king", + "ladder", + "menu", + "penalty", + "piano", + "potato", + "profession", + "professor", + "quantity", + "reaction", + "requirement", + "salad", + "sister", + "supermarket", + "tongue", + "weakness", + "wedding", + "affair", + "ambition", + "analyst", + "apple", + "assignment", + "assistant", + "bathroom", + "bedroom", + "beer", + "birthday", + "celebration", + "championship", + "cheek", + "client", + "consequence", + "departure", + "diamond", + "dirt", + "ear", + "fortune", + "friendship", + "funeral", + "gene", + "girlfriend", + "hat", + "indication", + "intention", + "lady", + "midnight", + "negotiation", + "obligation", + "passenger", + "pizza", + "platform", + "poet", + "pollution", + "recognition", + "reputation", + "shirt", + "sir", + "speaker", + "stranger", + "surgery", + "sympathy", + "tale", + "throat", + "trainer", + "uncle", + "youth", + "time", + "work", + "film", + "water", + "money", + "example", + "while", + "business", + "study", + "game", + "life", + "form", + "air", + "day", + "place", + "number", + "part", + "field", + "fish", + "back", + "process", + "heat", + "hand", + "experience", + "job", + "book", + "end", + "point", + "type", + "home", + "economy", + "value", + "body", + "market", + "guide", + "interest", + "state", + "radio", + "course", + "company", + "price", + "size", + "card", + "list", + "mind", + "trade", + "line", + "care", + "group", + "risk", + "word", + "fat", + "force", + "key", + "light", + "training", + "name", + "school", + "top", + "amount", + "level", + "order", + "practice", + "research", + "sense", + "service", + "piece", + "web", + "boss", + "sport", + "fun", + "house", + "page", + "term", + "test", + "answer", + "sound", + "focus", + "matter", + "kind", + "soil", + "board", + "oil", + "picture", + "access", + "garden", + "range", + "rate", + "reason", + "future", + "site", + "demand", + "exercise", + "image", + "case", + "cause", + "coast", + "action", + "age", + "bad", + "boat", + "record", + "result", + "section", + "building", + "mouse", + "cash", + "class", + "nothing", + "period", + "plan", + "store", + "tax", + "side", + "subject", + "space", + "rule", + "stock", + "weather", + "chance", + "figure", + "man", + "model", + "source", + "beginning", + "earth", + "program", + "chicken", + "design", + "feature", + "head", + "material", + "purpose", + "question", + "rock", + "salt", + "act", + "birth", + "car", + "dog", + "object", + "scale", + "sun", + "note", + "profit", + "rent", + "speed", + "style", + "war", + "bank", + "craft", + "half", + "inside", + "outside", + "standard", + "bus", + "exchange", + "eye", + "fire", + "position", + "pressure", + "stress", + "advantage", + "benefit", + "box", + "frame", + "issue", + "step", + "cycle", + "face", + "item", + "metal", + "paint", + "review", + "room", + "screen", + "structure", + "view", + "account", + "ball", + "discipline", + "medium", + "share", + "balance", + "bit", + "black", + "bottom", + "choice", + "gift", + "impact", + "machine", + "shape", + "tool", + "wind", + "address", + "average", + "career", + "culture", + "morning", + "pot", + "sign", + "table", + "task", + "condition", + "contact", + "credit", + "egg", + "hope", + "ice", + "network", + "north", + "square", + "attempt", + "date", + "effect", + "link", + "post", + "star", + "voice", + "capital", + "challenge", + "friend", + "self", + "shot", + "brush", + "couple", + "debate", + "exit", + "front", + "function", + "lack", + "living", + "plant", + "plastic", + "spot", + "summer", + "taste", + "theme", + "track", + "wing", + "brain", + "button", + "click", + "desire", + "foot", + "gas", + "influence", + "notice", + "rain", + "wall", + "base", + "damage", + "distance", + "feeling", + "pair", + "savings", + "staff", + "sugar", + "target", + "text", + "animal", + "author", + "budget", + "discount", + "file", + "ground", + "lesson", + "minute", + "officer", + "phase", + "reference", + "register", + "sky", + "stage", + "stick", + "title", + "trouble", + "bowl", + "bridge", + "campaign", + "character", + "club", + "edge", + "evidence", + "fan", + "letter", + "lock", + "maximum", + "novel", + "option", + "pack", + "park", + "plenty", + "quarter", + "skin", + "sort", + "weight", + "baby", + "background", + "carry", + "dish", + "factor", + "fruit", + "glass", + "joint", + "master", + "muscle", + "red", + "strength", + "traffic", + "trip", + "vegetable", + "appeal", + "chart", + "gear", + "ideal", + "kitchen", + "land", + "log", + "mother", + "net", + "party", + "principle", + "relative", + "sale", + "season", + "signal", + "spirit", + "street", + "tree", + "wave", + "belt", + "bench", + "commission", + "copy", + "drop", + "minimum", + "path", + "progress", + "project", + "sea", + "south", + "status", + "stuff", + "ticket", + "tour", + "angle", + "blue", + "breakfast", + "confidence", + "daughter", + "degree", + "doctor", + "dot", + "dream", + "duty", + "essay", + "father", + "fee", + "finance", + "hour", + "juice", + "limit", + "luck", + "milk", + "mouth", + "peace", + "pipe", + "seat", + "stable", + "storm", + "substance", + "team", + "trick", + "afternoon", + "bat", + "beach", + "blank", + "catch", + "chain", + "consideration", + "cream", + "crew", + "detail", + "gold", + "interview", + "kid", + "mark", + "match", + "mission", + "pain", + "pleasure", + "score", + "screw", + "sex", + "shop", + "shower", + "suit", + "tone", + "window", + "agent", + "band", + "block", + "bone", + "calendar", + "cap", + "coat", + "contest", + "corner", + "court", + "cup", + "district", + "door", + "east", + "finger", + "garage", + "guarantee", + "hole", + "hook", + "implement", + "layer", + "lecture", + "lie", + "manner", + "meeting", + "nose", + "parking", + "partner", + "profile", + "respect", + "rice", + "routine", + "schedule", + "swimming", + "telephone", + "tip", + "winter", + "airline", + "bag", + "battle", + "bed", + "bill", + "bother", + "cake", + "code", + "curve", + "designer", + "dimension", + "dress", + "ease", + "emergency", + "evening", + "extension", + "farm", + "fight", + "gap", + "grade", + "holiday", + "horror", + "horse", + "host", + "husband", + "loan", + "mistake", + "mountain", + "nail", + "noise", + "occasion", + "package", + "patient", + "pause", + "phrase", + "proof", + "race", + "relief", + "sand", + "sentence", + "shoulder", + "smoke", + "stomach", + "string", + "tourist", + "towel", + "vacation", + "west", + "wheel", + "wine", + "arm", + "aside", + "associate", + "bet", + "blow", + "border", + "branch", + "breast", + "brother", + "buddy", + "bunch", + "chip", + "coach", + "cross", + "document", + "draft", + "dust", + "expert", + "floor", + "god", + "golf", + "habit", + "iron", + "judge", + "knife", + "landscape", + "league", + "mail", + "mess", + "native", + "opening", + "parent", + "pattern", + "pin", + "pool", + "pound", + "request", + "salary", + "shame", + "shelter", + "shoe", + "silver", + "tackle", + "tank", + "trust", + "assist", + "bake", + "bar", + "bell", + "bike", + "blame", + "boy", + "brick", + "chair", + "closet", + "clue", + "collar", + "comment", + "conference", + "devil", + "diet", + "fear", + "fuel", + "glove", + "jacket", + "lunch", + "monitor", + "mortgage", + "nurse", + "pace", + "panic", + "peak", + "plane", + "reward", + "row", + "sandwich", + "shock", + "spite", + "spray", + "surprise", + "till", + "transition", + "weekend", + "welcome", + "yard", + "alarm", + "bend", + "bicycle", + "bite", + "blind", + "bottle", + "cable", + "candle", + "clerk", + "cloud", + "concert", + "counter", + "flower", + "grandfather", + "harm", + "knee", + "lawyer", + "leather", + "load", + "mirror", + "neck", + "pension", + "plate", + "purple", + "ruin", + "ship", + "skirt", + "slice", + "snow", + "specialist", + "stroke", + "switch", + "trash", + "tune", + "zone", + "anger", + "award", + "bid", + "bitter", + "boot", + "bug", + "camp", + "candy", + "carpet", + "cat", + "champion", + "channel", + "clock", + "comfort", + "cow", + "crack", + "engineer", + "entrance", + "fault", + "grass", + "guy", + "hell", + "highlight", + "incident", + "island", + "joke", + "jury", + "leg", + "lip", + "mate", + "motor", + "nerve", + "passage", + "pen", + "pride", + "priest", + "prize", + "promise", + "resident", + "resort", + "ring", + "roof", + "rope", + "sail", + "scheme", + "script", + "sock", + "station", + "toe", + "tower", + "truck", + "witness", + "a", + "you", + "it", + "can", + "will", + "if", + "one", + "many", + "most", + "other", + "use", + "make", + "good", + "look", + "help", + "go", + "great", + "being", + "few", + "might", + "still", + "public", + "read", + "keep", + "start", + "give", + "human", + "local", + "general", + "she", + "specific", + "long", + "play", + "feel", + "high", + "tonight", + "put", + "common", + "set", + "change", + "simple", + "past", + "big", + "possible", + "particular", + "today", + "major", + "personal", + "current", + "national", + "cut", + "natural", + "physical", + "show", + "try", + "check", + "second", + "call", + "move", + "pay", + "let", + "increase", + "single", + "individual", + "turn", + "ask", + "buy", + "guard", + "hold", + "main", + "offer", + "potential", + "professional", + "international", + "travel", + "cook", + "alternative", + "following", + "special", + "working", + "whole", + "dance", + "excuse", + "cold", + "commercial", + "low", + "purchase", + "deal", + "primary", + "worth", + "fall", + "necessary", + "positive", + "produce", + "search", + "present", + "spend", + "talk", + "creative", + "tell", + "cost", + "drive", + "green", + "support", + "glad", + "remove", + "return", + "run", + "complex", + "due", + "effective", + "middle", + "regular", + "reserve", + "independent", + "leave", + "original", + "reach", + "rest", + "serve", + "watch", + "beautiful", + "charge", + "active", + "break", + "negative", + "safe", + "stay", + "visit", + "visual", + "affect", + "cover", + "report", + "rise", + "walk", + "white", + "beyond", + "junior", + "pick", + "unique", + "anything", + "classic", + "final", + "lift", + "mix", + "private", + "stop", + "teach", + "western", + "concern", + "familiar", + "fly", + "official", + "broad", + "comfortable", + "gain", + "maybe", + "rich", + "save", + "stand", + "young", + "fail", + "heavy", + "hello", + "lead", + "listen", + "valuable", + "worry", + "handle", + "leading", + "meet", + "release", + "sell", + "finish", + "normal", + "press", + "ride", + "secret", + "spread", + "spring", + "tough", + "wait", + "brown", + "deep", + "display", + "flow", + "hit", + "objective", + "shoot", + "touch", + "cancel", + "chemical", + "cry", + "dump", + "extreme", + "push", + "conflict", + "eat", + "fill", + "formal", + "jump", + "kick", + "opposite", + "pass", + "pitch", + "remote", + "total", + "treat", + "vast", + "abuse", + "beat", + "burn", + "deposit", + "print", + "raise", + "sleep", + "somewhere", + "advance", + "anywhere", + "consist", + "dark", + "double", + "draw", + "equal", + "fix", + "hire", + "internal", + "join", + "kill", + "sensitive", + "tap", + "win", + "attack", + "claim", + "constant", + "drag", + "drink", + "guess", + "minor", + "pull", + "raw", + "soft", + "solid", + "wear", + "weird", + "wonder", + "annual", + "count", + "dead", + "doubt", + "feed", + "forever", + "impress", + "nobody", + "repeat", + "round", + "sing", + "slide", + "strip", + "whereas", + "wish", + "combine", + "command", + "dig", + "divide", + "equivalent", + "hang", + "hunt", + "initial", + "march", + "mention", + "smell", + "spiritual", + "survey", + "tie", + "adult", + "brief", + "crazy", + "escape", + "gather", + "hate", + "prior", + "repair", + "rough", + "sad", + "scratch", + "sick", + "strike", + "employ", + "external", + "hurt", + "illegal", + "laugh", + "lay", + "mobile", + "nasty", + "ordinary", + "respond", + "royal", + "senior", + "split", + "strain", + "struggle", + "swim", + "train", + "upper", + "wash", + "yellow", + "convert", + "crash", + "dependent", + "fold", + "funny", + "grab", + "hide", + "miss", + "permit", + "quote", + "recover", + "resolve", + "roll", + "sink", + "slip", + "spare", + "suspect", + "sweet", + "swing", + "twist", + "upstairs", + "usual", + "abroad", + "brave", + "calm", + "concentrate", + "estimate", + "grand", + "male", + "mine", + "prompt", + "quiet", + "refuse", + "regret", + "reveal", + "rush", + "shake", + "shift", + "shine", + "steal", + "suck", + "surround", + "anybody", + "bear", + "brilliant", + "dare", + "dear", + "delay", + "drunk", + "female", + "hurry", + "inevitable", + "invite", + "kiss", + "neat", + "pop", + "punch", + "quit", + "reply", + "representative", + "resist", + "rip", + "rub", + "silly", + "smile", + "spell", + "stretch", + "stupid", + "tear", + "temporary", + "tomorrow", + "wake", + "wrap", + "yesterday", +]; + +const adj = [ + "abandoned", + "able", + "absolute", + "adorable", + "adventurous", + "academic", + "acceptable", + "acclaimed", + "accomplished", + "accurate", + "aching", + "acidic", + "acrobatic", + "active", + "actual", + "adept", + "admirable", + "admired", + "adolescent", + "adorable", + "adored", + "advanced", + "afraid", + "affectionate", + "aged", + "aggravating", + "aggressive", + "agile", + "agitated", + "agonizing", + "agreeable", + "ajar", + "alarmed", + "alarming", + "alert", + "alienated", + "alive", + "all", + "altruistic", + "amazing", + "ambitious", + "ample", + "amused", + "amusing", + "anchored", + "ancient", + "angelic", + "angry", + "anguished", + "animated", + "annual", + "another", + "antique", + "anxious", + "any", + "apprehensive", + "appropriate", + "apt", + "arctic", + "arid", + "aromatic", + "artistic", + "ashamed", + "assured", + "astonishing", + "athletic", + "attached", + "attentive", + "attractive", + "austere", + "authentic", + "authorized", + "automatic", + "avaricious", + "average", + "aware", + "awesome", + "awful", + "awkward", + "babyish", + "bad", + "back", + "baggy", + "bare", + "barren", + "basic", + "beautiful", + "belated", + "beloved", + "beneficial", + "better", + "best", + "bewitched", + "big", + "big-hearted", + "biodegradable", + "bite-sized", + "bitter", + "black", + "black-and-white", + "bland", + "blank", + "blaring", + "bleak", + "blind", + "blissful", + "blond", + "blue", + "blushing", + "bogus", + "boiling", + "bold", + "bony", + "boring", + "bossy", + "both", + "bouncy", + "bountiful", + "bowed", + "brave", + "breakable", + "brief", + "bright", + "brilliant", + "brisk", + "broken", + "bronze", + "brown", + "bruised", + "bubbly", + "bulky", + "bumpy", + "buoyant", + "burdensome", + "burly", + "bustling", + "busy", + "buttery", + "buzzing", + "calculating", + "calm", + "candid", + "canine", + "capital", + "carefree", + "careful", + "careless", + "caring", + "cautious", + "cavernous", + "celebrated", + "charming", + "cheap", + "cheerful", + "cheery", + "chief", + "chilly", + "chubby", + "circular", + "classic", + "clean", + "clear", + "clear-cut", + "clever", + "close", + "closed", + "cloudy", + "clueless", + "clumsy", + "cluttered", + "coarse", + "cold", + "colorful", + "colorless", + "colossal", + "comfortable", + "common", + "compassionate", + "competent", + "complete", + "complex", + "complicated", + "composed", + "concerned", + "concrete", + "confused", + "conscious", + "considerate", + "constant", + "content", + "conventional", + "cooked", + "cool", + "cooperative", + "coordinated", + "corny", + "corrupt", + "costly", + "courageous", + "courteous", + "crafty", + "crazy", + "creamy", + "creative", + "creepy", + "criminal", + "crisp", + "critical", + "crooked", + "crowded", + "cruel", + "crushing", + "cuddly", + "cultivated", + "cultured", + "cumbersome", + "curly", + "curvy", + "cute", + "cylindrical", + "damaged", + "damp", + "dangerous", + "dapper", + "daring", + "darling", + "dark", + "dazzling", + "dead", + "deadly", + "deafening", + "dear", + "dearest", + "decent", + "decimal", + "decisive", + "deep", + "defenseless", + "defensive", + "defiant", + "deficient", + "definite", + "definitive", + "delayed", + "delectable", + "delicious", + "delightful", + "delirious", + "demanding", + "dense", + "dental", + "dependable", + "dependent", + "descriptive", + "deserted", + "detailed", + "determined", + "devoted", + "different", + "difficult", + "digital", + "diligent", + "dim", + "dimpled", + "dimwitted", + "direct", + "disastrous", + "discrete", + "disfigured", + "disgusting", + "disloyal", + "dismal", + "distant", + "downright", + "dreary", + "dirty", + "disguised", + "dishonest", + "dismal", + "distant", + "distinct", + "distorted", + "dizzy", + "dopey", + "doting", + "double", + "downright", + "drab", + "drafty", + "dramatic", + "dreary", + "droopy", + "dry", + "dual", + "dull", + "dutiful", + "each", + "eager", + "earnest", + "early", + "easy", + "easy-going", + "ecstatic", + "edible", + "educated", + "elaborate", + "elastic", + "elated", + "elderly", + "electric", + "elegant", + "elementary", + "elliptical", + "embarrassed", + "embellished", + "eminent", + "emotional", + "empty", + "enchanted", + "enchanting", + "energetic", + "enlightened", + "enormous", + "enraged", + "entire", + "envious", + "equal", + "equatorial", + "essential", + "esteemed", + "ethical", + "euphoric", + "even", + "evergreen", + "everlasting", + "every", + "evil", + "exalted", + "excellent", + "exemplary", + "exhausted", + "excitable", + "excited", + "exciting", + "exotic", + "expensive", + "experienced", + "expert", + "extraneous", + "extroverted", + "extra-large", + "extra-small", + "fabulous", + "failing", + "faint", + "fair", + "faithful", + "fake", + "false", + "familiar", + "famous", + "fancy", + "fantastic", + "far", + "faraway", + "far-flung", + "far-off", + "fast", + "fat", + "fatal", + "fatherly", + "favorable", + "favorite", + "fearful", + "fearless", + "feisty", + "feline", + "female", + "feminine", + "few", + "fickle", + "filthy", + "fine", + "finished", + "firm", + "first", + "firsthand", + "fitting", + "fixed", + "flaky", + "flamboyant", + "flashy", + "flat", + "flawed", + "flawless", + "flickering", + "flimsy", + "flippant", + "flowery", + "fluffy", + "fluid", + "flustered", + "focused", + "fond", + "foolhardy", + "foolish", + "forceful", + "forked", + "formal", + "forsaken", + "forthright", + "fortunate", + "fragrant", + "frail", + "frank", + "frayed", + "free", + "French", + "fresh", + "frequent", + "friendly", + "frightened", + "frightening", + "frigid", + "frilly", + "frizzy", + "frivolous", + "front", + "frosty", + "frozen", + "frugal", + "fruitful", + "full", + "fumbling", + "functional", + "funny", + "fussy", + "fuzzy", + "gargantuan", + "gaseous", + "general", + "generous", + "gentle", + "genuine", + "giant", + "giddy", + "gigantic", + "gifted", + "giving", + "glamorous", + "glaring", + "glass", + "gleaming", + "gleeful", + "glistening", + "glittering", + "gloomy", + "glorious", + "glossy", + "glum", + "golden", + "good", + "good-natured", + "gorgeous", + "graceful", + "gracious", + "grand", + "grandiose", + "granular", + "grateful", + "grave", + "gray", + "great", + "greedy", + "green", + "gregarious", + "grim", + "grimy", + "gripping", + "grizzled", + "gross", + "grotesque", + "grouchy", + "grounded", + "growing", + "growling", + "grown", + "grubby", + "gruesome", + "grumpy", + "guilty", + "gullible", + "gummy", + "hairy", + "half", + "handmade", + "handsome", + "handy", + "happy", + "happy-go-lucky", + "hard", + "hard-to-find", + "harmful", + "harmless", + "harmonious", + "harsh", + "hasty", + "hateful", + "haunting", + "healthy", + "heartfelt", + "hearty", + "heavenly", + "heavy", + "hefty", + "helpful", + "helpless", + "hidden", + "hideous", + "high", + "high-level", + "hilarious", + "hoarse", + "hollow", + "homely", + "honest", + "honorable", + "honored", + "hopeful", + "horrible", + "hospitable", + "hot", + "huge", + "humble", + "humiliating", + "humming", + "humongous", + "hungry", + "hurtful", + "husky", + "icky", + "icy", + "ideal", + "idealistic", + "identical", + "idle", + "idiotic", + "idolized", + "ignorant", + "ill", + "illegal", + "ill-fated", + "ill-informed", + "illiterate", + "illustrious", + "imaginary", + "imaginative", + "immaculate", + "immaterial", + "immediate", + "immense", + "impassioned", + "impeccable", + "impartial", + "imperfect", + "imperturbable", + "impish", + "impolite", + "important", + "impossible", + "impractical", + "impressionable", + "impressive", + "improbable", + "impure", + "inborn", + "incomparable", + "incompatible", + "incomplete", + "inconsequential", + "incredible", + "indelible", + "inexperienced", + "indolent", + "infamous", + "infantile", + "infatuated", + "inferior", + "infinite", + "informal", + "innocent", + "insecure", + "insidious", + "insignificant", + "insistent", + "instructive", + "insubstantial", + "intelligent", + "intent", + "intentional", + "interesting", + "internal", + "international", + "intrepid", + "ironclad", + "irresponsible", + "irritating", + "itchy", + "jaded", + "jagged", + "jam-packed", + "jaunty", + "jealous", + "jittery", + "joint", + "jolly", + "jovial", + "joyful", + "joyous", + "jubilant", + "judicious", + "juicy", + "jumbo", + "junior", + "jumpy", + "juvenile", + "kaleidoscopic", + "keen", + "key", + "kind", + "kindhearted", + "kindly", + "klutzy", + "knobby", + "knotty", + "knowledgeable", + "knowing", + "known", + "kooky", + "kosher", + "lame", + "lanky", + "large", + "last", + "lasting", + "late", + "lavish", + "lawful", + "lazy", + "leading", + "lean", + "leafy", + "left", + "legal", + "legitimate", + "light", + "lighthearted", + "likable", + "likely", + "limited", + "limp", + "limping", + "linear", + "lined", + "liquid", + "little", + "live", + "lively", + "livid", + "loathsome", + "lone", + "lonely", + "long", + "long-term", + "loose", + "lopsided", + "lost", + "loud", + "lovable", + "lovely", + "loving", + "low", + "loyal", + "lucky", + "lumbering", + "luminous", + "lumpy", + "lustrous", + "luxurious", + "mad", + "made-up", + "magnificent", + "majestic", + "major", + "male", + "mammoth", + "married", + "marvelous", + "masculine", + "massive", + "mature", + "meager", + "mealy", + "mean", + "measly", + "meaty", + "medical", + "mediocre", + "medium", + "meek", + "mellow", + "melodic", + "memorable", + "menacing", + "merry", + "messy", + "metallic", + "mild", + "milky", + "mindless", + "miniature", + "minor", + "minty", + "miserable", + "miserly", + "misguided", + "misty", + "mixed", + "modern", + "modest", + "moist", + "monstrous", + "monthly", + "monumental", + "moral", + "mortified", + "motherly", + "motionless", + "mountainous", + "muddy", + "muffled", + "multicolored", + "mundane", + "murky", + "mushy", + "musty", + "muted", + "mysterious", + "naive", + "narrow", + "nasty", + "natural", + "naughty", + "nautical", + "near", + "neat", + "necessary", + "needy", + "negative", + "neglected", + "negligible", + "neighboring", + "nervous", + "new", + "next", + "nice", + "nifty", + "nimble", + "nippy", + "nocturnal", + "noisy", + "nonstop", + "normal", + "notable", + "noted", + "noteworthy", + "novel", + "noxious", + "numb", + "nutritious", + "nutty", + "obedient", + "obese", + "oblong", + "oily", + "oblong", + "obvious", + "occasional", + "odd", + "oddball", + "offbeat", + "offensive", + "official", + "old", + "old-fashioned", + "only", + "open", + "optimal", + "optimistic", + "opulent", + "orange", + "orderly", + "organic", + "ornate", + "ornery", + "ordinary", + "original", + "other", + "our", + "outlying", + "outgoing", + "outlandish", + "outrageous", + "outstanding", + "oval", + "overcooked", + "overdue", + "overjoyed", + "overlooked", + "palatable", + "pale", + "paltry", + "parallel", + "parched", + "partial", + "passionate", + "past", + "pastel", + "peaceful", + "peppery", + "perfect", + "perfumed", + "periodic", + "perky", + "personal", + "pertinent", + "pesky", + "pessimistic", + "petty", + "phony", + "physical", + "piercing", + "pink", + "pitiful", + "plain", + "plaintive", + "plastic", + "playful", + "pleasant", + "pleased", + "pleasing", + "plump", + "plush", + "polished", + "polite", + "political", + "pointed", + "pointless", + "poised", + "poor", + "popular", + "portly", + "posh", + "positive", + "possible", + "potable", + "powerful", + "powerless", + "practical", + "precious", + "present", + "prestigious", + "pretty", + "precious", + "previous", + "pricey", + "prickly", + "primary", + "prime", + "pristine", + "private", + "prize", + "probable", + "productive", + "profitable", + "profuse", + "proper", + "proud", + "prudent", + "punctual", + "pungent", + "puny", + "pure", + "purple", + "pushy", + "putrid", + "puzzled", + "puzzling", + "quaint", + "qualified", + "quarrelsome", + "quarterly", + "queasy", + "querulous", + "questionable", + "quick", + "quick-witted", + "quiet", + "quintessential", + "quirky", + "quixotic", + "quizzical", + "radiant", + "ragged", + "rapid", + "rare", + "rash", + "raw", + "recent", + "reckless", + "rectangular", + "ready", + "real", + "realistic", + "reasonable", + "red", + "reflecting", + "regal", + "regular", + "reliable", + "relieved", + "remarkable", + "remorseful", + "remote", + "repentant", + "required", + "respectful", + "responsible", + "repulsive", + "revolving", + "rewarding", + "rich", + "rigid", + "right", + "ringed", + "ripe", + "roasted", + "robust", + "rosy", + "rotating", + "rotten", + "rough", + "round", + "rowdy", + "royal", + "rubbery", + "rundown", + "ruddy", + "rude", + "runny", + "rural", + "rusty", + "sad", + "safe", + "salty", + "same", + "sandy", + "sane", + "sarcastic", + "sardonic", + "satisfied", + "scaly", + "scarce", + "scared", + "scary", + "scented", + "scholarly", + "scientific", + "scornful", + "scratchy", + "scrawny", + "second", + "secondary", + "second-hand", + "secret", + "self-assured", + "self-reliant", + "selfish", + "sentimental", + "separate", + "serene", + "serious", + "serpentine", + "several", + "severe", + "shabby", + "shadowy", + "shady", + "shallow", + "shameful", + "shameless", + "sharp", + "shimmering", + "shiny", + "shocked", + "shocking", + "shoddy", + "short", + "short-term", + "showy", + "shrill", + "shy", + "sick", + "silent", + "silky", + "silly", + "silver", + "similar", + "simple", + "simplistic", + "sinful", + "single", + "sizzling", + "skeletal", + "skinny", + "sleepy", + "slight", + "slim", + "slimy", + "slippery", + "slow", + "slushy", + "small", + "smart", + "smoggy", + "smooth", + "smug", + "snappy", + "snarling", + "sneaky", + "sniveling", + "snoopy", + "sociable", + "soft", + "soggy", + "solid", + "somber", + "some", + "spherical", + "sophisticated", + "sore", + "sorrowful", + "soulful", + "soupy", + "sour", + "Spanish", + "sparkling", + "sparse", + "specific", + "spectacular", + "speedy", + "spicy", + "spiffy", + "spirited", + "spiteful", + "splendid", + "spotless", + "spotted", + "spry", + "square", + "squeaky", + "squiggly", + "stable", + "staid", + "stained", + "stale", + "standard", + "starchy", + "stark", + "starry", + "steep", + "sticky", + "stiff", + "stimulating", + "stingy", + "stormy", + "straight", + "strange", + "steel", + "strict", + "strident", + "striking", + "striped", + "strong", + "studious", + "stunning", + "stupendous", + "stupid", + "sturdy", + "stylish", + "subdued", + "submissive", + "substantial", + "subtle", + "suburban", + "sudden", + "sugary", + "sunny", + "super", + "superb", + "superficial", + "superior", + "supportive", + "sure-footed", + "surprised", + "suspicious", + "svelte", + "sweaty", + "sweet", + "sweltering", + "swift", + "sympathetic", + "tall", + "talkative", + "tame", + "tan", + "tangible", + "tart", + "tasty", + "tattered", + "taut", + "tedious", + "teeming", + "tempting", + "tender", + "tense", + "tepid", + "terrible", + "terrific", + "testy", + "thankful", + "that", + "these", + "thick", + "thin", + "third", + "thirsty", + "this", + "thorough", + "thorny", + "those", + "thoughtful", + "threadbare", + "thrifty", + "thunderous", + "tidy", + "tight", + "timely", + "tinted", + "tiny", + "tired", + "torn", + "total", + "tough", + "traumatic", + "treasured", + "tremendous", + "tragic", + "trained", + "tremendous", + "triangular", + "tricky", + "trifling", + "trim", + "trivial", + "troubled", + "true", + "trusting", + "trustworthy", + "trusty", + "truthful", + "tubby", + "turbulent", + "twin", + "ugly", + "ultimate", + "unacceptable", + "unaware", + "uncomfortable", + "uncommon", + "unconscious", + "understated", + "unequaled", + "uneven", + "unfinished", + "unfit", + "unfolded", + "unfortunate", + "unhappy", + "unhealthy", + "uniform", + "unimportant", + "unique", + "united", + "unkempt", + "unknown", + "unlawful", + "unlined", + "unlucky", + "unnatural", + "unpleasant", + "unrealistic", + "unripe", + "unruly", + "unselfish", + "unsightly", + "unsteady", + "unsung", + "untidy", + "untimely", + "untried", + "untrue", + "unused", + "unusual", + "unwelcome", + "unwieldy", + "unwilling", + "unwitting", + "unwritten", + "upbeat", + "upright", + "upset", + "urban", + "usable", + "used", + "useful", + "useless", + "utilized", + "utter", + "vacant", + "vague", + "vain", + "valid", + "valuable", + "vapid", + "variable", + "vast", + "velvety", + "venerated", + "vengeful", + "verifiable", + "vibrant", + "vicious", + "victorious", + "vigilant", + "vigorous", + "villainous", + "violet", + "violent", + "virtual", + "virtuous", + "visible", + "vital", + "vivacious", + "vivid", + "voluminous", + "wan", + "warlike", + "warm", + "warmhearted", + "warped", + "wary", + "wasteful", + "watchful", + "waterlogged", + "watery", + "wavy", + "wealthy", + "weak", + "weary", + "webbed", + "weed", + "weekly", + "weepy", + "weighty", + "weird", + "welcome", + "well-documented", + "well-groomed", + "well-informed", + "well-lit", + "well-made", + "well-off", + "well-to-do", + "well-worn", + "wet", + "which", + "whimsical", + "whirlwind", + "whispered", + "white", + "whole", + "whopping", + "wicked", + "wide", + "wide-eyed", + "wiggly", + "wild", + "willing", + "wilted", + "winding", + "windy", + "winged", + "wiry", + "wise", + "witty", + "wobbly", + "woeful", + "wonderful", + "wooden", + "woozy", + "wordy", + "worldly", + "worn", + "worried", + "worrisome", + "worse", + "worst", + "worthless", + "worthwhile", + "worthy", + "wrathful", + "wretched", + "writhing", + "wrong", + "wry", + "yawning", + "yearly", + "yellow", + "yellowish", + "young", + "youthful", + "yummy", + "zany", + "zealous", + "zesty", + "zigzag", +]; + +export function getRandomUsername(): { first: string; second: string } { + const n = Math.floor(Math.random() * noun.length); + const a = Math.floor(Math.random() * adj.length); + return { + first: adj[a], + second: noun[n], + }; +} + +export function getRandomPassword(): string { + return encodeCrock(getRandomBytes(16)); +} |