diff options
author | Sebastian <sebasjm@gmail.com> | 2024-03-08 14:09:31 -0300 |
---|---|---|
committer | Sebastian <sebasjm@gmail.com> | 2024-03-08 14:09:31 -0300 |
commit | ddd32a690bd13b1eb1aef1356a1d59fd64e254bf (patch) | |
tree | 44126872f6e8195a3617e2002c696c0afa13fb0d /packages/demobank-ui/src/pages | |
parent | e0e82cdf07930d766081e42203c5a4e66d43191f (diff) | |
download | wallet-core-ddd32a690bd13b1eb1aef1356a1d59fd64e254bf.tar.xz |
demobank => bank
Diffstat (limited to 'packages/demobank-ui/src/pages')
42 files changed, 0 insertions, 14028 deletions
diff --git a/packages/demobank-ui/src/pages/AccountPage/index.ts b/packages/demobank-ui/src/pages/AccountPage/index.ts deleted file mode 100644 index 0223b12db..000000000 --- a/packages/demobank-ui/src/pages/AccountPage/index.ts +++ /dev/null @@ -1,134 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2022-2024 Taler Systems S.A. - - GNU Taler is free software; you can redistribute it and/or modify it under the - terms of the GNU General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along with - GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> - */ - -import { - 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 "../../route.js"; - -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; - 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/demobank-ui/src/pages/AccountPage/state.ts b/packages/demobank-ui/src/pages/AccountPage/state.ts deleted file mode 100644 index e84fef025..000000000 --- a/packages/demobank-ui/src/pages/AccountPage/state.ts +++ /dev/null @@ -1,117 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2022-2024 Taler Systems S.A. - - GNU Taler is free software; you can redistribute it and/or modify it under the - terms of the GNU General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along with - GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> - */ - -import { - Amounts, - HttpStatusCode, - TalerError, - 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; - - return { - status: "ready", - onOperationCreated, - error: undefined, - tab, - routeCashout, - routeOperationDetails, - routeCreateWireTransfer, - routePublicAccounts, - routeSolveSecondFactor, - onAuthorizationRequired, - onClose, - routeClose, - routeChargeWallet, - routeWireTransfer, - account, - limit, - }; -} diff --git a/packages/demobank-ui/src/pages/AccountPage/stories.tsx b/packages/demobank-ui/src/pages/AccountPage/stories.tsx deleted file mode 100644 index fe09a4f89..000000000 --- a/packages/demobank-ui/src/pages/AccountPage/stories.tsx +++ /dev/null @@ -1,29 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2022-2024 Taler Systems S.A. - - GNU Taler is free software; you can redistribute it and/or modify it under the - terms of the GNU General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along with - GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> - */ - -/** - * - * @author Sebastian Javier Marchano (sebasjm) - */ - -import * as tests from "@gnu-taler/web-util/testing"; -import { ReadyView } from "./views.js"; - -export default { - title: "account page", -}; - -export const Ready = tests.createExample(ReadyView, {}); diff --git a/packages/demobank-ui/src/pages/AccountPage/test.ts b/packages/demobank-ui/src/pages/AccountPage/test.ts deleted file mode 100644 index 14c8be948..000000000 --- a/packages/demobank-ui/src/pages/AccountPage/test.ts +++ /dev/null @@ -1,31 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2022-2024 Taler Systems S.A. - - GNU Taler is free software; you can redistribute it and/or modify it under the - terms of the GNU General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along with - GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> - */ - -/** - * - * @author Sebastian Javier Marchano (sebasjm) - */ - -// import * as tests from "@gnu-taler/web-util/testing"; -// import { 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/demobank-ui/src/pages/AccountPage/views.tsx b/packages/demobank-ui/src/pages/AccountPage/views.tsx deleted file mode 100644 index 7165c28b6..000000000 --- a/packages/demobank-ui/src/pages/AccountPage/views.tsx +++ /dev/null @@ -1,144 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2022-2024 Taler Systems S.A. - - GNU Taler is free software; you can redistribute it and/or modify it under the - terms of the GNU General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along with - GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> - */ - -import { 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 "../../route.js"; - -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, - 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} - routeClose={routeClose} - onClose={onClose} - onOperationCreated={onOperationCreated} - onAuthorizationRequired={onAuthorizationRequired} - /> - <Transactions account={account} routeCreateWireTransfer={routeCreateWireTransfer} /> - </Fragment> - ); -} diff --git a/packages/demobank-ui/src/pages/BankFrame.stories.tsx b/packages/demobank-ui/src/pages/BankFrame.stories.tsx deleted file mode 100644 index c874ac4ca..000000000 --- a/packages/demobank-ui/src/pages/BankFrame.stories.tsx +++ /dev/null @@ -1,29 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2022-2024 Taler Systems S.A. - - GNU Taler is free software; you can redistribute it and/or modify it under the - terms of the GNU General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along with - GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> - */ - -/** - * - * @author Sebastian Javier Marchano (sebasjm) - */ - -import * as tests from "@gnu-taler/web-util/testing"; -import { BankFrame } from "./BankFrame.js"; - -export default { - title: "bank frame", -}; - -export const Ready = tests.createExample(BankFrame, {}); diff --git a/packages/demobank-ui/src/pages/BankFrame.tsx b/packages/demobank-ui/src/pages/BankFrame.tsx deleted file mode 100644 index 427e9a156..000000000 --- a/packages/demobank-ui/src/pages/BankFrame.tsx +++ /dev/null @@ -1,237 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2022-2024 Taler Systems S.A. - - GNU Taler is free software; you can redistribute it and/or modify it under the - terms of the GNU General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along with - GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> - */ - -import { Amounts, TalerError, TranslatedString } from "@gnu-taler/taler-util"; -import { - Footer, - Header, - Loading, - ToastBanner, - notifyError, - notifyException, - useTranslationContext -} from "@gnu-taler/web-util/browser"; -import { ComponentChildren, VNode, h } from "preact"; -import { useEffect, useErrorBoundary } from "preact/hooks"; -import { useBankCoreApiContext } from "../context/config.js"; -import { useSettingsContext } from "../context/settings.js"; -import { useAccountDetails } from "../hooks/account.js"; -import { useSessionState } from "../hooks/session.js"; -import { useBankState } from "../hooks/bank-state.js"; -import { - getAllBooleanPreferences, - getLabelForPreferences, - usePreferences, -} from "../hooks/preferences.js"; -import { RouteDefinition } from "../route.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({})} - 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-1"> - {getAllBooleanPreferences().map((set) => { - const isOn: boolean = !!preferences[set]; - return ( - <li key={set} class="mt-2 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 w-full"> - <div class="mx-auto w-4/5"> - <ToastBanner /> - </div> - </div> - - <main class="-mt-32 flex-1"> - {account && routeAccountDetails && ( - <header class="py-5 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-12 sm:px-6 lg:px-8"> - <div class="rounded-lg bg-white px-5 py-6 shadow sm:px-6"> - {children} - </div> - </div> - </main> - - <Footer - testingUrlKey="corebank-api-base-url" - GIT_HASH={GIT_HASH} - VERSION={VERSION} - /> - </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/demobank-ui/src/pages/LoginForm.tsx b/packages/demobank-ui/src/pages/LoginForm.tsx deleted file mode 100644 index bd20e79c8..000000000 --- a/packages/demobank-ui/src/pages/LoginForm.tsx +++ /dev/null @@ -1,226 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2022-2024 Taler Systems S.A. - - GNU Taler is free software; you can redistribute it and/or modify it under the - terms of the GNU General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along with - GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> - */ - -import { - HttpStatusCode -} 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 "../context/config.js"; -import { useSessionState } from "../hooks/session.js"; -import { RouteDefinition } from "../route.js"; -import { undefinedIfEmpty } from "../utils.js"; -import { doAutoFocus } from "./PaytoWireTransferForm.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 { 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 and numbers only, and start with a lowercase letter` - undefined, - password: !password ? i18n.str`Missing password` : undefined, - }); - - async function doLogout() { - session.logOut(); - } - - const loginHandler = !username || !password ? undefined : withErrorHandler( - async () => authenticator(username) - .createAccessToken(password, { - // scope: "readwrite" as "write", // FIX: different than merchant - scope: "readwrite", - duration: { d_us: "forever" }, - refreshable: true, - }), - (result) => { - session.logIn({ username, token: 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/demobank-ui/src/pages/OperationState/index.ts b/packages/demobank-ui/src/pages/OperationState/index.ts deleted file mode 100644 index e4d9d45e3..000000000 --- a/packages/demobank-ui/src/pages/OperationState/index.ts +++ /dev/null @@ -1,157 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2022-2024 Taler Systems S.A. - - GNU Taler is free software; you can redistribute it and/or modify it under the - terms of the GNU General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along with - GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> - */ - -import { - 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 "../../route.js"; - -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/demobank-ui/src/pages/OperationState/state.ts b/packages/demobank-ui/src/pages/OperationState/state.ts deleted file mode 100644 index 5baf2d51c..000000000 --- a/packages/demobank-ui/src/pages/OperationState/state.ts +++ /dev/null @@ -1,232 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2022-2024 Taler Systems S.A. - - GNU Taler is free software; you can redistribute it and/or modify it under the - terms of the GNU General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along with - GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> - */ - -import { - Amounts, - HttpStatusCode, - 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 "../../context/config.js"; -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 { 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(), - 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/demobank-ui/src/pages/OperationState/stories.tsx b/packages/demobank-ui/src/pages/OperationState/stories.tsx deleted file mode 100644 index 82253b82c..000000000 --- a/packages/demobank-ui/src/pages/OperationState/stories.tsx +++ /dev/null @@ -1,29 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2022-2024 Taler Systems S.A. - - GNU Taler is free software; you can redistribute it and/or modify it under the - terms of the GNU General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along with - GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> - */ - -/** - * - * @author Sebastian Javier Marchano (sebasjm) - */ - -import * as tests from "@gnu-taler/web-util/testing"; -import { ReadyView } from "./views.js"; - -export default { - title: "operation status page", -}; - -export const Ready = tests.createExample(ReadyView, {}); diff --git a/packages/demobank-ui/src/pages/OperationState/test.ts b/packages/demobank-ui/src/pages/OperationState/test.ts deleted file mode 100644 index d47cb64a2..000000000 --- a/packages/demobank-ui/src/pages/OperationState/test.ts +++ /dev/null @@ -1,31 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2022-2024 Taler Systems S.A. - - GNU Taler is free software; you can redistribute it and/or modify it under the - terms of the GNU General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along with - GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> - */ - -/** - * - * @author Sebastian Javier Marchano (sebasjm) - */ - -// import * as tests from "@gnu-taler/web-util/testing"; -// import { 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/demobank-ui/src/pages/OperationState/views.tsx b/packages/demobank-ui/src/pages/OperationState/views.tsx deleted file mode 100644 index 6eee6daa9..000000000 --- a/packages/demobank-ui/src/pages/OperationState/views.tsx +++ /dev/null @@ -1,440 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2022-2024 Taler Systems S.A. - - GNU Taler is free software; you can redistribute it and/or modify it under the - terms of the GNU General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along with - GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> - */ - -import { - AbsoluteTime, - HttpStatusCode, - TalerErrorCode, - TranslatedString, - assertUnreachable, - stringifyWithdrawUri, -} from "@gnu-taler/taler-util"; -import { - Attention, - LocalNotificationBanner, - notifyInfo, - useLocalNotification, - 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"; -import { useTalerWalletIntegrationAPI } from "../../context/wallet-integration.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, - }); - case HttpStatusCode.BadRequest: - return notify({ - type: "error", - title: i18n.str`The operation id is invalid.`, - description: resp.detail.hint as TranslatedString, - debug: resp.detail, - }); - case HttpStatusCode.NotFound: - return notify({ - type: "error", - title: i18n.str`The operation was not found.`, - description: resp.detail.hint as TranslatedString, - debug: resp.detail, - }); - 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, - }); - 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, - }); - case HttpStatusCode.BadRequest: - return notify({ - type: "error", - title: i18n.str`The operation id is invalid.`, - description: resp.detail.hint as TranslatedString, - debug: resp.detail, - }); - case HttpStatusCode.NotFound: - return notify({ - type: "error", - title: i18n.str`The operation was not found.`, - description: resp.detail.hint as TranslatedString, - debug: resp.detail, - }); - 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, - }); - 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, - }); - case HttpStatusCode.BadRequest: - return notify({ - type: "error", - title: i18n.str`The operation id is invalid.`, - description: hasError.detail.hint as TranslatedString, - debug: hasError.detail, - }); - case HttpStatusCode.NotFound: - return notify({ - type: "error", - title: i18n.str`The operation was not found.`, - description: hasError.detail.hint as TranslatedString, - debug: hasError.detail, - }); - 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/demobank-ui/src/pages/PaymentOptions.stories.tsx b/packages/demobank-ui/src/pages/PaymentOptions.stories.tsx deleted file mode 100644 index 78af886a8..000000000 --- a/packages/demobank-ui/src/pages/PaymentOptions.stories.tsx +++ /dev/null @@ -1,35 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2022-2024 Taler Systems S.A. - - GNU Taler is free software; you can redistribute it and/or modify it under the - terms of the GNU General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along with - GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> - */ - -/** - * - * @author Sebastian Javier Marchano (sebasjm) - */ - -import * as tests from "@gnu-taler/web-util/testing"; -import { 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/demobank-ui/src/pages/PaymentOptions.tsx b/packages/demobank-ui/src/pages/PaymentOptions.tsx deleted file mode 100644 index 48ecc7525..000000000 --- a/packages/demobank-ui/src/pages/PaymentOptions.tsx +++ /dev/null @@ -1,237 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2022-2024 Taler Systems S.A. - - GNU Taler is free software; you can redistribute it and/or modify it under the - terms of the GNU General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along with - GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> - */ - -import { AmountJson, TalerError } from "@gnu-taler/taler-util"; -import { Fragment, VNode, h } from "preact"; -import { useBankState } from "../hooks/bank-state.js"; -import { PaytoWireTransferForm } from "./PaytoWireTransferForm.js"; -import { WalletWithdrawForm } from "./WalletWithdrawForm.js"; -import { EmptyObject, RouteDefinition } from "../route.js"; -import { useTranslationContext } from "@gnu-taler/web-util/browser"; -import { useWithdrawalDetails } from "../hooks/account.js"; -import { useEffect } from "preact/hooks"; -import { useSessionState } from "../hooks/session.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, - onOperationCreated, - onClose, - routeOperationDetails, - onAuthorizationRequired, -}: { - limit: 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} - onAuthorizationRequired={onAuthorizationRequired} - onOperationCreated={onOperationCreated} - onOperationAborted={onClose} - routeCancel={routeClose} - /> - )} - {tab === "wire-transfer" && ( - <PaytoWireTransferForm - focus - title={i18n.str`Transfer details`} - routeHere={routeWireTransfer} - limit={limit} - onAuthorizationRequired={onAuthorizationRequired} - onSuccess={onClose} - routeCashout={routeCashout} - routeCancel={routeClose} - /> - )} - </fieldset> - </div> - ); -} diff --git a/packages/demobank-ui/src/pages/PaytoWireTransferForm.stories.tsx b/packages/demobank-ui/src/pages/PaytoWireTransferForm.stories.tsx deleted file mode 100644 index 61cfb5629..000000000 --- a/packages/demobank-ui/src/pages/PaytoWireTransferForm.stories.tsx +++ /dev/null @@ -1,35 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2022-2024 Taler Systems S.A. - - GNU Taler is free software; you can redistribute it and/or modify it under the - terms of the GNU General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along with - GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> - */ - -/** - * - * @author Sebastian Javier Marchano (sebasjm) - */ - -import * as tests from "@gnu-taler/web-util/testing"; -import { 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/demobank-ui/src/pages/PaytoWireTransferForm.tsx b/packages/demobank-ui/src/pages/PaytoWireTransferForm.tsx deleted file mode 100644 index 791a3b440..000000000 --- a/packages/demobank-ui/src/pages/PaytoWireTransferForm.tsx +++ /dev/null @@ -1,792 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2022-2024 Taler Systems S.A. - - GNU Taler is free software; you can redistribute it and/or modify it under the - terms of the GNU General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along with - GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> - */ - -import { - AbsoluteTime, - AmountJson, - AmountString, - Amounts, - CurrencySpecification, - FRAC_SEPARATOR, - HttpStatusCode, - PaytoString, - PaytoUri, - TalerErrorCode, - TranslatedString, - assertUnreachable, - buildPayto, - parsePaytoUri, - stringifyPaytoUri -} from "@gnu-taler/taler-util"; -import { - InternationalizationAPI, - LocalNotificationBanner, - ShowInputErrorLabel, - notifyInfo, - 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 { useBankCoreApiContext } from "../context/config.js"; -import { useSessionState } from "../hooks/session.js"; -import { useBankState } from "../hooks/bank-state.js"; -import { EmptyObject, RouteDefinition } from "../route.js"; -import { undefinedIfEmpty, validateIBAN, validateTalerBank } from "../utils.js"; - -interface Props { - title: TranslatedString; - 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; -} - -export function PaytoWireTransferForm({ - focus, - title, - withAccount, - withSubject, - withAmount, - onSuccess, - routeCancel, - routeCashout, - routeHere, - onAuthorizationRequired, - limit, -}: Props): VNode { - const [isRawPayto, setIsRawPayto] = useState(false); - const { state: credentials } = useSessionState(); - const { 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; - 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); - } else { - if (!account || !subject) return; - let payto; - 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 () => { - const request = { - payto_uri: puri, - amount: sAmount, - }; - const resp = await api.createTransaction(credentials, request); - 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, - }); - 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, - }); - case TalerErrorCode.BANK_UNKNOWN_CREDITOR: - return notify({ - type: "error", - title: i18n.str`The destination account "${puri}" was not found.`, - description: resp.detail.hint as TranslatedString, - debug: resp.detail, - }); - 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, - }); - 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, - }); - 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, - }); - 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-10 md:grid-cols-3 bg-gray-100 my-4 px-4 pb-4 rounded-lg"> - {/** - * FIXME: Scan a qr code - */} - <div class=""> - <h2 class="text-base font-semibold leading-7 text-gray-900">{title}</h2> - <div class="px-2 mt-2 grid grid-cols-1 gap-y-4 sm:gap-x-4"> - <label - class={ - "relative flex cursor-pointer rounded-lg border bg-white p-4 shadow-sm focus:outline-none" + - (!isRawPayto - ? "border-indigo-600 ring-2 ring-indigo-600" - : "border-gray-300") - } - > - <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={() => { - 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); - } - } - setIsRawPayto(false); - }} - /> - <span class="flex flex-1"> - <span class="flex flex-col"> - <span class="block text-sm font-medium text-gray-900"> - <i18n.Translate>Using a form</i18n.Translate> - </span> - </span> - </span> - </label> - - {sendingToFixedAccount ? undefined : ( - <label - class={ - "relative flex cursor-pointer rounded-lg border bg-white p-4 shadow-sm focus:outline-none" + - (isRawPayto - ? "border-indigo-600 ring-2 ring-indigo-600" - : "border-gray-300") - } - > - <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={() => { - 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)); - } - setIsRawPayto(true); - }} - /> - <span class="flex flex-1"> - <span class="flex flex-col"> - <span class="block text-sm font-medium text-gray-900"> - <i18n.Translate>Import payto:// URI</i18n.Translate> - </span> - </span> - </span> - </label> - )} - {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 - )} - </div> - </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" - 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" - 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`}</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`}</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:`}</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; - 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, - 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}</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/demobank-ui/src/pages/ProfileNavigation.tsx b/packages/demobank-ui/src/pages/ProfileNavigation.tsx deleted file mode 100644 index 10497f015..000000000 --- a/packages/demobank-ui/src/pages/ProfileNavigation.tsx +++ /dev/null @@ -1,202 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2022-2024 Taler Systems S.A. - - GNU Taler is free software; you can redistribute it and/or modify it under the - terms of the GNU General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along with - GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> - */ -import { assertUnreachable } from "@gnu-taler/taler-util"; -import { useTranslationContext } from "@gnu-taler/web-util/browser"; -import { Fragment, VNode, h } from "preact"; -import { useBankCoreApiContext } from "../context/config.js"; -import { useNavigationContext } from "../context/navigation.js"; -import { useSessionState } from "../hooks/session.js"; -import { RouteDefinition } from "../route.js"; - -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/demobank-ui/src/pages/PublicHistoriesPage.tsx b/packages/demobank-ui/src/pages/PublicHistoriesPage.tsx deleted file mode 100644 index 84d703cbe..000000000 --- a/packages/demobank-ui/src/pages/PublicHistoriesPage.tsx +++ /dev/null @@ -1,95 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2022-2024 Taler Systems S.A. - - GNU Taler is free software; you can redistribute it and/or modify it under the - terms of the GNU General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along with - GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> - */ - -import { 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.data.public_accounts.length > 0 - ? result.data.public_accounts[0].username - : undefined; - - const [showAccount, setShowAccount] = useState(firstAccount); - - if (!result) { - return <Loading />; - } - if (result instanceof TalerError) { - return <Loading />; - } - - const { data } = result; - - const txs: Record<string, h.JSX.Element> = {}; - const accountsBar = []; - - // Ask story of all the public accounts. - for (const account of data.public_accounts) { - 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/demobank-ui/src/pages/QrCodeSection.stories.tsx b/packages/demobank-ui/src/pages/QrCodeSection.stories.tsx deleted file mode 100644 index d53d2e7b4..000000000 --- a/packages/demobank-ui/src/pages/QrCodeSection.stories.tsx +++ /dev/null @@ -1,32 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2022-2024 Taler Systems S.A. - - GNU Taler is free software; you can redistribute it and/or modify it under the - terms of the GNU General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along with - GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> - */ - -/** - * - * @author Sebastian Javier Marchano (sebasjm) - */ - -import * as tests from "@gnu-taler/web-util/testing"; -import { QrCodeSection } from "./QrCodeSection.js"; -import { 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/demobank-ui/src/pages/QrCodeSection.tsx b/packages/demobank-ui/src/pages/QrCodeSection.tsx deleted file mode 100644 index da11e631d..000000000 --- a/packages/demobank-ui/src/pages/QrCodeSection.tsx +++ /dev/null @@ -1,150 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2022-2024 Taler Systems S.A. - - GNU Taler is free software; you can redistribute it and/or modify it under the - terms of the GNU General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along with - GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> - */ - -import { - HttpStatusCode, - stringifyWithdrawUri, - WithdrawUriResult -} from "@gnu-taler/taler-util"; -import { - Button, - LocalNotificationBanner, - useLocalNotificationHandler, - 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 "../context/config.js"; -import { useTalerWalletIntegrationAPI } from "../context/wallet-integration.js"; -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 { 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/demobank-ui/src/pages/RegistrationPage.tsx b/packages/demobank-ui/src/pages/RegistrationPage.tsx deleted file mode 100644 index e9f7e602f..000000000 --- a/packages/demobank-ui/src/pages/RegistrationPage.tsx +++ /dev/null @@ -1,415 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2022-2024 Taler Systems S.A. - - GNU Taler is free software; you can redistribute it and/or modify it under the - terms of the GNU General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along with - GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> - */ -import { - AccessToken, - HttpStatusCode, - OperationFail, - TalerErrorCode, - TranslatedString, - assertUnreachable, -} from "@gnu-taler/taler-util"; -import { - LocalNotificationBanner, - ShowInputErrorLabel, - useLocalNotification, - useTranslationContext, -} from "@gnu-taler/web-util/browser"; -import { Fragment, VNode, h } from "preact"; -import { useState } from "preact/hooks"; -import { useBankCoreApiContext } from "../context/config.js"; -import { useSettingsContext } from "../context/settings.js"; -import { RouteDefinition } from "../route.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} - /> - ); -} - -export const USERNAME_REGEX = /^[a-z][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 { 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 and numbers only, and start with a lowercase letter` - : 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("" as AccessToken, { - 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_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/demobank-ui/src/pages/SolveChallengePage.tsx b/packages/demobank-ui/src/pages/SolveChallengePage.tsx deleted file mode 100644 index b2e053b3c..000000000 --- a/packages/demobank-ui/src/pages/SolveChallengePage.tsx +++ /dev/null @@ -1,716 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2022-2024 Taler Systems S.A. - - GNU Taler is free software; you can redistribute it and/or modify it under the - terms of the GNU General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along with - GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> - */ - -import { - AbsoluteTime, - Amounts, - Duration, - HttpStatusCode, - TalerCorebankApi, - TalerError, - TalerErrorCode, - TranslatedString, - assertUnreachable, - parsePaytoUri, -} from "@gnu-taler/taler-util"; -import { - Attention, - Loading, - LocalNotificationBanner, - ShowInputErrorLabel, - useLocalNotification, - useTranslationContext, -} from "@gnu-taler/web-util/browser"; -import { format } from "date-fns"; -import { Fragment, VNode, h } from "preact"; -import { useEffect, useState } from "preact/hooks"; -import { ErrorLoadingWithDebug } from "../components/ErrorLoadingWithDebug.js"; -import { useBankCoreApiContext } from "../context/config.js"; -import { useWithdrawalDetails } from "../hooks/account.js"; -import { useSessionState } from "../hooks/session.js"; -import { ChallengeInProgess, useBankState } from "../hooks/bank-state.js"; -import { useConversionInfo } from "../hooks/regional.js"; -import { RouteDefinition } from "../route.js"; -import { undefinedIfEmpty } from "../utils.js"; -import { RenderAmount } from "./PaytoWireTransferForm.js"; -import { OperationNotFound } from "./WithdrawalQRCode.js"; -import { useNavigationContext } from "../context/navigation.js"; -import { Time } from "../components/Time.js"; - -export function SolveChallengePage({ - onChallengeCompleted, - routeClose, -}: { - onChallengeCompleted: () => void; - routeClose: RouteDefinition; -}): VNode { - const { 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` : 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, - }); - 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, - }); - 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, - }); - default: - assertUnreachable(resp); - } - } - }); - } - - async function completeChallenge() { - if (!creds || !code) return; - await handleError(async () => { - { - const resp = await api.confirmChallenge(creds, ch.id, { - tan: code, - }); - 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, - }); - 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, - }); - 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, - }); - 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, - }); - 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, - }); - 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, 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, - }); - } - // 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.`, - }); - } - updateBankState("currentChallenge", undefined); - return onChallengeCompleted(); - } - }); - } - - const subtitle = ((op): TranslatedString => { - switch (op) { - case "delete-account": - return i18n.str`Account delete`; - case "update-account": - return i18n.str`Account update`; - case "update-password": - return i18n.str`Password update`; - case "create-transaction": - return i18n.str`Wire transfer`; - case "confirm-withdrawal": - return i18n.str`Withdrawal`; - case "create-cashout": - return i18n.str`Cashout`; - } - })(ch.operation); - - return ( - <Fragment> - <LocalNotificationBanner notification={notification} /> - <div class="grid grid-cols-1 gap-x-8 gap-y-8 pt-10 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> - <span>{subtitle}</span> - </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 - name="answer" - id="answer" - autocomplete="off" - onChange={(e): void => { - setCode(e.currentTarget.value); - }} - /> - </div> - <ShowInputErrorLabel - message={errors?.code} - isDirty={code !== undefined} - /> - </div> - </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, dateLocale } = useTranslationContext(); - const { config } = useBankCoreApiContext(); - - const firstTime = AbsoluteTime.isNever(challenge.sent) - useEffect(() => { - if (firstTime) { - onStart() - } - }, []) - return ( - <div class="px-4 mt-4 "> - <div class="w-full"> - <div class="border-gray-100"> - <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>Operation details</i18n.Translate> - </span> - </h2> - <dl class="divide-y divide-gray-100"> - {((): VNode => { - switch (challenge.operation) { - case "delete-account": - 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>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> - ); - 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); - } - })()} - - {challenge.info && ( - <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>Challenge details</i18n.Translate> - </span> - </h2> - )} - {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/demobank-ui/src/pages/WalletWithdrawForm.tsx b/packages/demobank-ui/src/pages/WalletWithdrawForm.tsx deleted file mode 100644 index 001d90fa1..000000000 --- a/packages/demobank-ui/src/pages/WalletWithdrawForm.tsx +++ /dev/null @@ -1,366 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2022-2024 Taler Systems S.A. - - GNU Taler is free software; you can redistribute it and/or modify it under the - terms of the GNU General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along with - GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> - */ - -import { - AmountJson, - Amounts, - HttpStatusCode, - 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 "../context/config.js"; -import { useSessionState } from "../hooks/session.js"; -import { useBankState } from "../hooks/bank-state.js"; -import { usePreferences } from "../hooks/preferences.js"; -import { RouteDefinition } from "../route.js"; -import { undefinedIfEmpty } from "../utils.js"; -import { OperationState } from "./OperationState/index.js"; -import { InputAmount, doAutoFocus } from "./PaytoWireTransferForm.js"; - -const RefAmount = forwardRef(InputAmount); - -function OldWithdrawalForm({ - onOperationCreated, - limit, - routeCancel, - focus, - routeOperationDetails, -}: { - limit: 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 { bank: api } = 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, - }); - 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, - }); - break; - } - case HttpStatusCode.NotFound: { - notify({ - type: "error", - title: i18n.str`Account not found`, - description: resp.detail.hint as TranslatedString, - debug: resp.detail, - }); - 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> - <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, - routeCancel, - onAuthorizationRequired, - onOperationCreated, - onOperationAborted, - routeOperationDetails, -}: { - limit: 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-10 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} - routeCancel={routeCancel} - onOperationCreated={onOperationCreated} - /> - ) : ( - <OperationState - currency={limit.currency} - onAuthorizationRequired={onAuthorizationRequired} - routeClose={routeCancel} - routeHere={routeOperationDetails} - onAbort={onOperationAborted} - // route={routeCancel} - /> - )} - </div> - </div> - ); -} diff --git a/packages/demobank-ui/src/pages/WireTransfer.tsx b/packages/demobank-ui/src/pages/WireTransfer.tsx deleted file mode 100644 index 33f067e63..000000000 --- a/packages/demobank-ui/src/pages/WireTransfer.tsx +++ /dev/null @@ -1,103 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2022-2024 Taler Systems S.A. - - GNU Taler is free software; you can redistribute it and/or modify it under the - terms of the GNU General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along with - GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> - */ -import { - Amounts, - HttpStatusCode, - TalerError, - 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 "../route.js"; - -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); - const limit = balanceIsDebit - ? Amounts.sub(debitThreshold, balance).amount - : Amounts.add(balance, debitThreshold).amount; - if (!balance) return <Fragment />; - return ( - <PaytoWireTransferForm - title={i18n.str`Make a wire transfer`} - withAccount={toAccount} - withAmount={withAmount} - withSubject={withSubject} - routeHere={routeHere} - limit={limit} - onAuthorizationRequired={onAuthorizationRequired} - onSuccess={() => { - notifyInfo(i18n.str`Wire transfer created!`); - if (onSuccess) onSuccess(); - }} - routeCancel={routeCancel} - /> - ); -} diff --git a/packages/demobank-ui/src/pages/WithdrawalConfirmationQuestion.tsx b/packages/demobank-ui/src/pages/WithdrawalConfirmationQuestion.tsx deleted file mode 100644 index 5925719c3..000000000 --- a/packages/demobank-ui/src/pages/WithdrawalConfirmationQuestion.tsx +++ /dev/null @@ -1,355 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2022-2024 Taler Systems S.A. - - GNU Taler is free software; you can redistribute it and/or modify it under the - terms of the GNU General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along with - GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> - */ - -import { - 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 "../context/config.js"; -import { useBankState } from "../hooks/bank-state.js"; -import { usePreferences } from "../hooks/preferences.js"; -import { useSessionState } from "../hooks/session.js"; -import { RouteDefinition } from "../route.js"; -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, 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, - }); - 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, - }); - case HttpStatusCode.BadRequest: - return notify({ - type: "error", - title: i18n.str`The operation id is invalid.`, - description: resp.detail.hint as TranslatedString, - debug: resp.detail, - }); - case HttpStatusCode.NotFound: - return notify({ - type: "error", - title: i18n.str`The operation was not found.`, - description: resp.detail.hint as TranslatedString, - debug: resp.detail, - }); - 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, - }); - 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`, - }); - case HttpStatusCode.BadRequest: - return notify({ - type: "error", - title: i18n.str`The operation id is invalid.`, - description: resp.detail.hint as TranslatedString, - debug: resp.detail, - }); - case HttpStatusCode.NotFound: - return notify({ - type: "error", - title: i18n.str`The operation was not found.`, - description: resp.detail.hint as TranslatedString, - debug: resp.detail, - }); - 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 => { - switch (details.account.targetType) { - case "iban": { - const p = details.account as PaytoUriIBAN; - const name = p.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"> - {p.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 p = details.account as PaytoUriTalerBank; - const name = p.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 id</i18n.Translate> - </dt> - <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0"> - {p.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> - ); - } - default: - 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> - ); - } - })()} - <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/demobank-ui/src/pages/WithdrawalOperationPage.tsx b/packages/demobank-ui/src/pages/WithdrawalOperationPage.tsx deleted file mode 100644 index 973a23011..000000000 --- a/packages/demobank-ui/src/pages/WithdrawalOperationPage.tsx +++ /dev/null @@ -1,71 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2022-2024 Taler Systems S.A. - - GNU Taler is free software; you can redistribute it and/or modify it under the - terms of the GNU General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along with - GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> - */ - -import { parseWithdrawUri, stringifyWithdrawUri } from "@gnu-taler/taler-util"; -import { Attention, useTranslationContext } from "@gnu-taler/web-util/browser"; -import { VNode, h } from "preact"; -import { useBankCoreApiContext } from "../context/config.js"; -import { useBankState } from "../hooks/bank-state.js"; -import { RouteDefinition } from "../route.js"; -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 { bank: api } = useBankCoreApiContext(); - const uri = stringifyWithdrawUri({ - bankIntegrationApiBaseUrl: api.getIntegrationAPI(), - 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/demobank-ui/src/pages/WithdrawalQRCode.tsx b/packages/demobank-ui/src/pages/WithdrawalQRCode.tsx deleted file mode 100644 index 9765147d1..000000000 --- a/packages/demobank-ui/src/pages/WithdrawalQRCode.tsx +++ /dev/null @@ -1,310 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2022-2024 Taler Systems S.A. - - GNU Taler is free software; you can redistribute it and/or modify it under the - terms of the GNU General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along with - GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> - */ - -import { - Amounts, - HttpStatusCode, - TalerError, - 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 "../route.js"; -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/demobank-ui/src/pages/account/CashoutListForAccount.tsx b/packages/demobank-ui/src/pages/account/CashoutListForAccount.tsx deleted file mode 100644 index 2216b96fc..000000000 --- a/packages/demobank-ui/src/pages/account/CashoutListForAccount.tsx +++ /dev/null @@ -1,85 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2022-2024 Taler Systems S.A. - - GNU Taler is free software; you can redistribute it and/or modify it under the - terms of the GNU General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along with - GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> - */ -import { 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 "../../route.js"; - -interface Props { - account: string; - routeClose: RouteDefinition; - onAuthorizationRequired: () => void; - routeCashoutDetails: RouteDefinition<{ cid: string }>; - routeMyAccountDetails: RouteDefinition; - routeMyAccountDelete: RouteDefinition; - routeMyAccountPassword: RouteDefinition; - routeMyAccountCashout: RouteDefinition; - routeCreateCashout: RouteDefinition; - routeConversionConfig:RouteDefinition; -} - -export function CashoutListForAccount({ - account, - onAuthorizationRequired, - 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} - onAuthorizationRequired={onAuthorizationRequired} - account={account} - /> - - <Cashouts account={account} routeCashoutDetails={routeCashoutDetails} /> - </Fragment> - ); -} diff --git a/packages/demobank-ui/src/pages/account/ShowAccountDetails.tsx b/packages/demobank-ui/src/pages/account/ShowAccountDetails.tsx deleted file mode 100644 index 62c8df7f8..000000000 --- a/packages/demobank-ui/src/pages/account/ShowAccountDetails.tsx +++ /dev/null @@ -1,244 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2022-2024 Taler Systems S.A. - - GNU Taler is free software; you can redistribute it and/or modify it under the - terms of the GNU General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along with - GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> - */ -import { - AbsoluteTime, - HttpStatusCode, - TalerCorebankApi, - TalerError, - TalerErrorCode, - TranslatedString, - assertUnreachable, -} from "@gnu-taler/taler-util"; -import { - Loading, - LocalNotificationBanner, - 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 "../../context/config.js"; -import { useAccountDetails } from "../../hooks/account.js"; -import { useSessionState } from "../../hooks/session.js"; -import { useBankState } from "../../hooks/bank-state.js"; -import { RouteDefinition } from "../../route.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 { bank: api } = 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 api.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, - }); - case HttpStatusCode.NotFound: - return notify({ - type: "error", - title: i18n.str`The username was not found`, - description: resp.detail.hint as TranslatedString, - debug: resp.detail, - }); - 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, - }); - 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, - }); - 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, - }); - 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, - }); - 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, - }); - } - default: - assertUnreachable(resp); - } - } - }); - } - - 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-10 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> - </Fragment> - ); -} diff --git a/packages/demobank-ui/src/pages/account/UpdateAccountPassword.tsx b/packages/demobank-ui/src/pages/account/UpdateAccountPassword.tsx deleted file mode 100644 index e21ac2464..000000000 --- a/packages/demobank-ui/src/pages/account/UpdateAccountPassword.tsx +++ /dev/null @@ -1,301 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2022-2024 Taler Systems S.A. - - GNU Taler is free software; you can redistribute it and/or modify it under the - terms of the GNU General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along with - GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> - */ -import { - AbsoluteTime, - HttpStatusCode, - TalerErrorCode, - 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 "../../context/config.js"; -import { useSessionState } from "../../hooks/session.js"; -import { useBankState } from "../../hooks/bank-state.js"; -import { RouteDefinition } from "../../route.js"; -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 { 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.`, - }); - case HttpStatusCode.NotFound: - return notify({ - type: "error", - title: i18n.str`Account not found`, - }); - 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.`, - }); - 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.`, - }); - 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-10 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`} - </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`} - </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`} - </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/demobank-ui/src/pages/admin/AccountForm.tsx b/packages/demobank-ui/src/pages/admin/AccountForm.tsx deleted file mode 100644 index bce7afe11..000000000 --- a/packages/demobank-ui/src/pages/admin/AccountForm.tsx +++ /dev/null @@ -1,901 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2022-2024 Taler Systems S.A. - - GNU Taler is free software; you can redistribute it and/or modify it under the - terms of the GNU General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along with - GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> - */ -import { - AmountString, - Amounts, - PaytoString, - TalerCorebankApi, - TranslatedString, - assertUnreachable, - buildPayto, - parsePaytoUri, - stringifyPaytoUri, -} from "@gnu-taler/taler-util"; -import { - Attention, - CopyButton, - ShowInputErrorLabel, - useTranslationContext, -} from "@gnu-taler/web-util/browser"; -import { ComponentChildren, VNode, h } from "preact"; -import { useState } from "preact/hooks"; -import { VersionHint, useBankCoreApiContext } from "../../context/config.js"; -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; - 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, hints, 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, - ), - 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 OLD_CASHOUT_API = hints.indexOf(VersionHint.CASHOUT_BEFORE_2FA) !== -1; - - 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 editableAccount = purpose === "create" && userIsAdmin; - - const hasPhone = !!defaultValue.phone || !!form.phone; - const hasEmail = !!defaultValue.email || !!form.email; - - function updateForm(newForm: typeof defaultValue): void { - - const trimmedAmountStr = newForm.debit_threshold?.trim(); - const parsedAmount = Amounts.parse( - `${config.currency}:${trimmedAmountStr}`, - ); - - 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 - : !trimmedAmountStr - ? undefined - : !parsedAmount - ? i18n.str`Not valid` - : undefined, - name: !editableName - ? undefined // disabled - : !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 = !parsedAmount - ? undefined - : Amounts.stringify(parsedAmount); - - 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, - 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, - 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} - /> - )} - - {/* channel, not shown if old cashout api */} - {OLD_CASHOUT_API || - config.supported_tan_channels.length === 0 ? undefined : ( - <div class="sm:col-span-5"> - <label - class="block text-sm font-medium leading-6 text-gray-900" - for="channel" - > - {i18n.str`Enable second factor authentication`} - </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"> - {config.supported_tan_channels.indexOf(TanChannel.EMAIL) === - -1 ? undefined : ( - <label - onClick={(e) => { - if (!hasEmail) return; - if (form.tan_channel === TanChannel.EMAIL) { - form.tan_channel = "remove"; - } else { - form.tan_channel = TanChannel.EMAIL; - } - updateForm(structuredClone(form)); - e.preventDefault(); - }} - data-disabled={purpose === "show" || !hasEmail} - data-selected={ - (form.tan_channel ?? defaultValue.tan_channel) === - TanChannel.EMAIL - } - 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 - id="project-type-0-label" - class="block text-sm font-medium text-gray-900 " - > - <i18n.Translate>Using email</i18n.Translate> - </span> - {purpose !== "show" && - !hasEmail && - i18n.str`Add an email in your profile to enable this option`} - </span> - </span> - <svg - data-selected={ - (form.tan_channel ?? defaultValue.tan_channel) === - TanChannel.EMAIL - } - 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> - )} - - {config.supported_tan_channels.indexOf(TanChannel.SMS) === - -1 ? undefined : ( - <label - onClick={(e) => { - if (!hasPhone) return; - if (form.tan_channel === TanChannel.SMS) { - form.tan_channel = "remove"; - } else { - form.tan_channel = TanChannel.SMS; - } - updateForm(structuredClone(form)); - e.preventDefault(); - }} - data-disabled={purpose === "show" || !hasPhone} - data-selected={ - (form.tan_channel ?? defaultValue.tan_channel) === - TanChannel.SMS - } - 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 - id="project-type-1-label" - class="block text-sm font-medium text-gray-900" - > - <i18n.Translate>Using SMS</i18n.Translate> - </span> - {purpose !== "show" && - !hasPhone && - i18n.str`Add a phone number in your profile to enable this option`} - </span> - </span> - <svg - data-selected={ - (form.tan_channel ?? defaultValue.tan_channel) === - TanChannel.SMS - } - 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 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"> - <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/demobank-ui/src/pages/admin/AccountList.tsx b/packages/demobank-ui/src/pages/admin/AccountList.tsx deleted file mode 100644 index 4e465d4b5..000000000 --- a/packages/demobank-ui/src/pages/admin/AccountList.tsx +++ /dev/null @@ -1,244 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2022-2024 Taler Systems S.A. - - GNU Taler is free software; you can redistribute it and/or modify it under the - terms of the GNU General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along with - GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> - */ -import { - Amounts, - HttpStatusCode, - TalerError, - 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 "../../context/config.js"; -import { useBusinessAccounts } from "../../hooks/regional.js"; -import { RenderAmount } from "../PaytoWireTransferForm.js"; -import { RouteDefinition } from "../../route.js"; - -interface Props { - routeCreate: RouteDefinition; - - routeShowAccount: RouteDefinition<{ account: string }>; - routeRemoveAccount: RouteDefinition<{ account: string }>; - routeUpdatePasswordAccount: RouteDefinition<{ account: string }>; - routeShowCashoutsAccount: RouteDefinition<{ account: string }>; -} - -export function AccountList({ - routeCreate, - routeRemoveAccount, - routeShowAccount, - routeShowCashoutsAccount, - 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.data.type === "fail") { - switch (result.data.case) { - case HttpStatusCode.Unauthorized: - return <Fragment />; - default: - assertUnreachable(result.data.case); - } - } - - const onGoStart = result.isFirstPage ? undefined : result.loadFirst - const onGoNext = result.isLastPage ? undefined : result.loadNext - - const accounts = result.result; - return ( - <Fragment> - <div class="px-4 sm:px-6 lg:px-8 mt-4"> - <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> - <p class="mt-2 text-sm text-gray-700"> - <i18n.Translate> - A list of all bank accounts. - </i18n.Translate> - </p> - </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-8 flow-root"> - <div class="-mx-4 -my-2 overflow-x-auto sm:-mx-6 lg:-mx-8"> - <div class="inline-block min-w-full py-2 align-middle sm:px-6 lg:px-8"> - {!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/demobank-ui/src/pages/admin/AdminHome.tsx b/packages/demobank-ui/src/pages/admin/AdminHome.tsx deleted file mode 100644 index 752d86aa6..000000000 --- a/packages/demobank-ui/src/pages/admin/AdminHome.tsx +++ /dev/null @@ -1,541 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2022-2024 Taler Systems S.A. - - GNU Taler is free software; you can redistribute it and/or modify it under the - terms of the GNU General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along with - GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> - */ -import { - AmountString, - Amounts, - CurrencySpecification, - HttpStatusCode, - TalerCorebankApi, - TalerError, - assertUnreachable, -} from "@gnu-taler/taler-util"; -import { Attention, useTranslationContext } from "@gnu-taler/web-util/browser"; -import { - format, - getDate, - getHours, - getMonth, - getYear, - setDate, - setHours, - setMonth, - setYear, - 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 { useBankCoreApiContext } from "../../context/config.js"; -import { useConversionInfo, useLastMonitorInfo } from "../../hooks/regional.js"; -import { RouteDefinition } from "../../route.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, - routeShowCashoutsAccount, - 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} - routeShowCashoutsAccount={routeShowCashoutsAccount} - routeUpdatePasswordAccount={routeUpdatePasswordAccount} - /> - </Fragment> - ); -} - -function getDateForTimeframe( - which: number, - timeframe: TalerCorebankApi.MonitorTimeframeParam, - locale: Locale, -): string { - const time = Date.now(); - switch (timeframe) { - case TalerCorebankApi.MonitorTimeframeParam.hour: - return `${format(setHours(time, which), "HH", { locale })}hs`; - case TalerCorebankApi.MonitorTimeframeParam.day: - return format(setDate(time, which), "EEEE", { locale }); - case TalerCorebankApi.MonitorTimeframeParam.month: - return format(setMonth(time, which), "MMMM", { locale }); - case TalerCorebankApi.MonitorTimeframeParam.year: - return format(setYear(time, which), "yyyy", { locale }); - case TalerCorebankApi.MonitorTimeframeParam.decade: - return format(setYear(time, which), "yyyy", { locale }); - } - assertUnreachable(timeframe); -} - -export function getTimeframesForDate( - time: Date, - timeframe: TalerCorebankApi.MonitorTimeframeParam, -): { current: number; previous: number } { - switch (timeframe) { - case TalerCorebankApi.MonitorTimeframeParam.hour: - return { - current: getHours(sub(time, { hours: 1 })), - previous: getHours(sub(time, { hours: 2 })), - }; - case TalerCorebankApi.MonitorTimeframeParam.day: - return { - current: getDate(sub(time, { days: 1 })), - previous: getDate(sub(time, { days: 2 })), - }; - case TalerCorebankApi.MonitorTimeframeParam.month: - return { - current: getMonth(sub(time, { months: 1 })), - previous: getMonth(sub(time, { months: 2 })), - }; - case TalerCorebankApi.MonitorTimeframeParam.year: - return { - current: getYear(sub(time, { years: 1 })), - previous: getYear(sub(time, { years: 2 })), - }; - case TalerCorebankApi.MonitorTimeframeParam.decade: - return { - current: getYear(sub(time, { years: 10 })), - previous: getYear(sub(time, { years: 20 })), - }; - 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" || resp.previous.type !== "ok") { - return <Fragment />; - } - return ( - <Fragment> - <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 metricType - setMetricType( - e.currentTarget - .value as unknown 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 font-semibold leading-7 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-2"> - <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> - </Fragment> - ); -} - -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/demobank-ui/src/pages/admin/CreateNewAccount.tsx b/packages/demobank-ui/src/pages/admin/CreateNewAccount.tsx deleted file mode 100644 index 38119735e..000000000 --- a/packages/demobank-ui/src/pages/admin/CreateNewAccount.tsx +++ /dev/null @@ -1,204 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2022-2024 Taler Systems S.A. - - GNU Taler is free software; you can redistribute it and/or modify it under the - terms of the GNU General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along with - GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> - */ -import { - 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 "../../context/config.js"; -import { useSessionState } from "../../hooks/session.js"; -import { RouteDefinition } from "../../route.js"; -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 { 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, - }); - 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, - }); - 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, - }); - 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, - }); - 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, - }); - 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, - }); - 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, - }); - 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, - }); - 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, - }); - 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, - }); - 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-10 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/demobank-ui/src/pages/admin/DownloadStats.tsx b/packages/demobank-ui/src/pages/admin/DownloadStats.tsx deleted file mode 100644 index fba366676..000000000 --- a/packages/demobank-ui/src/pages/admin/DownloadStats.tsx +++ /dev/null @@ -1,585 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2022-2024 Taler Systems S.A. - - GNU Taler is free software; you can redistribute it and/or modify it under the - terms of the GNU General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along with - GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> - */ - -import { - 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 "../../context/config.js"; -import { useSessionState } from "../../hooks/session.js"; -import { EmptyObject, RouteDefinition } from "../../route.js"; -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 { 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-10 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, - which: 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, - which: 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/demobank-ui/src/pages/admin/RemoveAccount.tsx b/packages/demobank-ui/src/pages/admin/RemoveAccount.tsx deleted file mode 100644 index 61def9a95..000000000 --- a/packages/demobank-ui/src/pages/admin/RemoveAccount.tsx +++ /dev/null @@ -1,267 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2022-2024 Taler Systems S.A. - - GNU Taler is free software; you can redistribute it and/or modify it under the - terms of the GNU General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along with - GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> - */ -import { - 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 "../../context/config.js"; -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 "../../route.js"; - -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 { 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, - }); - case HttpStatusCode.NotFound: - return notify({ - type: "error", - title: i18n.str`The username was not found.`, - description: resp.detail.hint as TranslatedString, - debug: resp.detail, - }); - 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, - }); - 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, - }); - 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-10 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/demobank-ui/src/pages/index.stories.tsx b/packages/demobank-ui/src/pages/index.stories.tsx deleted file mode 100644 index 823def5d7..000000000 --- a/packages/demobank-ui/src/pages/index.stories.tsx +++ /dev/null @@ -1,20 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2022-2024 Taler Systems S.A. - - GNU Taler is free software; you can redistribute it and/or modify it under the - terms of the GNU General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along with - GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> - */ - -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/demobank-ui/src/pages/regional/ConversionConfig.tsx b/packages/demobank-ui/src/pages/regional/ConversionConfig.tsx deleted file mode 100644 index 8845ec9a0..000000000 --- a/packages/demobank-ui/src/pages/regional/ConversionConfig.tsx +++ /dev/null @@ -1,978 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2022-2024 Taler Systems S.A. - - GNU Taler is free software; you can redistribute it and/or modify it under the - terms of the GNU General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along with - GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> - */ - -import { - AmountJson, - Amounts, - HttpStatusCode, - 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 "../../context/config.js"; -import { useSessionState } from "../../hooks/session.js"; -import { TransferCalculation, useCashinEstimator, useCashoutEstimator, useConversionInfo } from "../../hooks/regional.js"; -import { RouteDefinition } from "../../route.js"; -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({ - onUpdateSuccess, - 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 () => { - const { i18n } = useTranslationContext(); - - const { bank, conversion, config } = 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, - }); - } - case HttpStatusCode.NotImplemented: { - return notify({ - type: "error", - title: i18n.str`Conversion is disabled`, - description: resp.detail.hint as TranslatedString, - debug: resp.detail, - }); - } - 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-10 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="cashin_min_amount" - class="block text-sm font-medium leading-6 text-gray-900" - >{i18n.str`Minimum amount`}</label> - <InputAmount - name="cashin_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="password" - > - {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="cashin_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="cashin_tiny_amount" - class="block text-sm font-medium leading-6 text-gray-900" - >{i18n.str`Rounding value`}</label> - <InputAmount - name="cashin_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="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 - id="project-type-0-label" - 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 - id="project-type-0-label" - 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 - id="project-type-0-label" - 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="cashin_fee" - class="block text-sm font-medium leading-6 text-gray-900" - >{i18n.str`Fee`}</label> - <InputAmount - name="cashin_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/demobank-ui/src/pages/regional/CreateCashout.tsx b/packages/demobank-ui/src/pages/regional/CreateCashout.tsx deleted file mode 100644 index 2f15d16b4..000000000 --- a/packages/demobank-ui/src/pages/regional/CreateCashout.tsx +++ /dev/null @@ -1,809 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2022-2024 Taler Systems S.A. - - GNU Taler is free software; you can redistribute it and/or modify it under the - terms of the GNU General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along with - GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> - */ -import { - 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 { VersionHint, useBankCoreApiContext } from "../../context/config.js"; -import { useAccountDetails } from "../../hooks/account.js"; -import { useSessionState } from "../../hooks/session.js"; -import { useBankState } from "../../hooks/bank-state.js"; -import { TransferCalculation, useCashoutEstimator, useConversionInfo, useEstimator } from "../../hooks/regional.js"; -import { RouteDefinition } from "../../route.js"; -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; - 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, - 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 { bank: api, config, hints } = 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> - ); - } - - const OLD_CASHOUT_API = hints.indexOf(VersionHint.CASHOUT_BEFORE_2FA) !== -1; - - 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 account = { - balance: Amounts.parseOrThrow(resultAccount.body.balance.amount), - balanceIsDebit: - resultAccount.body.balance.credit_debit_indicator == "debit", - debitThreshold: Amounts.parseOrThrow(resultAccount.body.debit_threshold), - }; - - 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 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` - : form.isDebit && Amounts.cmp(inputAmount, conversionInfo.cashout_min_amount) < 1 - ? i18n.str`Needs to be higher than ${Amounts.stringifyValueWithSpec(Amounts.parseOrThrow(conversionInfo.cashout_min_amount), regional_currency_specification).normal}` - : calculationResult === "amount-is-too-small" - ? i18n.str`Amount needs to be higher` - : Amounts.isZero(calc.credit) - ? i18n.str`The total transfer at destination will be zero` - : undefined, - channel: OLD_CASHOUT_API && !form.channel ? i18n.str`Required` : undefined, - }); - const trimmedAmountStr = form.amount?.trim(); - - async function createCashout() { - const request_uid = encodeCrock(getRandomBytes(32)); - await handleError(async () => { - // new cashout api doesn't require channel - const validChannel = - !OLD_CASHOUT_API || - config.supported_tan_channels.length === 0 || - form.channel; - - if (!creds || !form.subject || !validChannel) return; - const request = { - request_uid, - amount_credit: Amounts.stringify(calc.credit), - amount_debit: Amounts.stringify(calc.debit), - subject: form.subject, - tan_channel: form.channel, - }; - const resp = await api.createCashout(creds, request); - if (resp.type === "ok") { - notifyInfo(i18n.str`Cashout created`); - } 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, - }); - 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, - }); - 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, - }); - 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, - }); - case HttpStatusCode.NotImplemented: - return notify({ - type: "error", - title: i18n.str`Cashout are disabled`, - description: resp.detail.hint as TranslatedString, - debug: resp.detail, - }); - 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, - }); - 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, - }); - } - 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-10 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> - )} - - {/* channel, not shown if new cashout api */} - {!OLD_CASHOUT_API ? undefined : config.supported_tan_channels - .length === 0 ? ( - <div class="sm:col-span-5"> - <Attention - type="warning" - title={i18n.str`No cashout channel available`} - > - <i18n.Translate> - Before doing a cashout the server need to provide an - second channel to confirm the operation - </i18n.Translate> - </Attention> - </div> - ) : ( - <div class="sm:col-span-5"> - <label - class="block text-sm font-medium leading-6 text-gray-900" - for="channel" - > - {i18n.str`Second factor authentication`} - </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"> - {config.supported_tan_channels.indexOf( - TanChannel.EMAIL, - ) === -1 ? undefined : ( - <label - onClick={() => { - if (!resultAccount.body.contact_data?.email) return; - form.channel = TanChannel.EMAIL; - updateForm(structuredClone(form)); - }} - data-disabled={ - !resultAccount.body.contact_data?.email - } - data-selected={form.channel === TanChannel.EMAIL} - 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 - id="project-type-0-label" - class="block text-sm font-medium text-gray-900 " - > - <i18n.Translate>Email</i18n.Translate> - </span> - {!resultAccount.body.contact_data?.email && - i18n.str`Add a email in your profile to enable this option`} - </span> - </span> - <svg - data-selected={form.channel === TanChannel.EMAIL} - 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> - )} - - {config.supported_tan_channels.indexOf(TanChannel.SMS) === - -1 ? undefined : ( - <label - onClick={() => { - if (!resultAccount.body.contact_data?.phone) return; - form.channel = TanChannel.SMS; - updateForm(structuredClone(form)); - }} - data-disabled={ - !resultAccount.body.contact_data?.phone - } - data-selected={form.channel === TanChannel.SMS} - 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 - id="project-type-1-label" - class="block text-sm font-medium text-gray-900" - > - <i18n.Translate>SMS</i18n.Translate> - </span> - {!resultAccount.body.contact_data?.phone && - i18n.str`Add a phone number in your profile to enable this option`} - </span> - </span> - <svg - data-selected={form.channel === TanChannel.SMS} - 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="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/demobank-ui/src/pages/regional/ShowCashoutDetails.tsx b/packages/demobank-ui/src/pages/regional/ShowCashoutDetails.tsx deleted file mode 100644 index 415f88868..000000000 --- a/packages/demobank-ui/src/pages/regional/ShowCashoutDetails.tsx +++ /dev/null @@ -1,192 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2022-2024 Taler Systems S.A. - - GNU Taler is free software; you can redistribute it and/or modify it under the - terms of the GNU General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along with - GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> - */ -import { - AbsoluteTime, - Amounts, - Duration, - HttpStatusCode, - TalerError, - assertUnreachable, -} from "@gnu-taler/taler-util"; -import { - Attention, - Loading, - useTranslationContext, -} from "@gnu-taler/web-util/browser"; -import { format } from "date-fns"; -import { VNode, h } from "preact"; -import { ErrorLoadingWithDebug } from "../../components/ErrorLoadingWithDebug.js"; -import { useCashoutDetails, useConversionInfo } from "../../hooks/regional.js"; -import { RouteDefinition } from "../../route.js"; -import { RenderAmount } from "../PaytoWireTransferForm.js"; -import { Time } from "../../components/Time.js"; - -interface Props { - id: string; - routeClose: RouteDefinition; -} -export function ShowCashoutDetails({ id, routeClose }: Props): VNode { - const { i18n, dateLocale } = 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-10 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/demobank-ui/src/pages/rnd.ts b/packages/demobank-ui/src/pages/rnd.ts deleted file mode 100644 index d66fb005b..000000000 --- a/packages/demobank-ui/src/pages/rnd.ts +++ /dev/null @@ -1,2907 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2022-2024 Taler Systems S.A. - - GNU Taler is free software; you can redistribute it and/or modify it under the - terms of the GNU General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along with - GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> - */ -import { 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)); -} |