diff options
Diffstat (limited to 'packages/demobank-ui/src/pages')
34 files changed, 5306 insertions, 3341 deletions
diff --git a/packages/demobank-ui/src/pages/AccountPage/index.ts b/packages/demobank-ui/src/pages/AccountPage/index.ts index 115da807d..31a8a9e34 100644 --- a/packages/demobank-ui/src/pages/AccountPage/index.ts +++ b/packages/demobank-ui/src/pages/AccountPage/index.ts @@ -14,21 +14,36 @@ 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 { + 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; - goToConfirmOperation: (id: string) => void; + onOperationCreated: (wopid: string) => void; + onClose: () => void; + tab: "charge-wallet" | "wire-transfer" | undefined; + routeClose: RouteDefinition<Record<string, never>>; + routeChargeWallet: RouteDefinition<Record<string, never>>; + routeWireTransfer: RouteDefinition<Record<string, never>>; } -export type State = State.Loading | - State.LoadingError | State.Ready | State.InvalidIban | State.UserNotFound; +export type State = + | State.Loading + | State.LoadingError + | State.Ready + | State.InvalidIban + | State.UserNotFound; export namespace State { export interface Loading { @@ -48,21 +63,26 @@ export namespace State { export interface Ready extends BaseInfo { status: "ready"; error: undefined; - account: string, - limit: AmountJson, + account: string; + tab: "charge-wallet" | "wire-transfer" | undefined; + limit: AmountJson; onAuthorizationRequired: () => void; - goToConfirmOperation: (id: string) => void; + onOperationCreated: (wopid: string) => void; + onClose: () => void; + routeClose: RouteDefinition<Record<string, never>>; + routeChargeWallet: RouteDefinition<Record<string, never>>; + routeWireTransfer: RouteDefinition<Record<string, never>>; } export interface InvalidIban { - status: "invalid-iban", + status: "invalid-iban"; error: TalerCorebankApi.AccountData; } export interface UserNotFound { - status: "login", + status: "login"; reason: "not-found" | "forbidden"; - onRegister?: () => void; + routeRegister?: RouteDefinition<Record<string, never>>; } } @@ -76,7 +96,7 @@ export interface Transaction { const viewMapping: utils.StateViewMap<State> = { loading: Loading, - "login": LoginForm, + login: LoginForm, "invalid-iban": InvalidIbanView, "loading-error": ErrorLoadingWithDebug, ready: ReadyView, diff --git a/packages/demobank-ui/src/pages/AccountPage/state.ts b/packages/demobank-ui/src/pages/AccountPage/state.ts index 56c041a4a..a07ea37d3 100644 --- a/packages/demobank-ui/src/pages/AccountPage/state.ts +++ b/packages/demobank-ui/src/pages/AccountPage/state.ts @@ -14,15 +14,27 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ -import { Amounts, HttpStatusCode, TalerError, parsePaytoUri } from "@gnu-taler/taler-util"; -import { useTranslationContext } from "@gnu-taler/web-util/browser"; +import { + Amounts, + HttpStatusCode, + TalerError, + assertUnreachable, + parsePaytoUri, +} from "@gnu-taler/taler-util"; import { useAccountDetails } from "../../hooks/access.js"; -import { assertUnreachable } from "../WithdrawalOperationPage.js"; import { Props, State } from "./index.js"; -export function useComponentState({ account, goToConfirmOperation, onAuthorizationRequired }: Props): State { +export function useComponentState({ + account, + tab, + routeChargeWallet, + routeWireTransfer, + onOperationCreated, + onClose, + routeClose, + onAuthorizationRequired, +}: Props): State { const result = useAccountDetails(account); - const { i18n } = useTranslationContext(); if (!result) { return { @@ -40,16 +52,18 @@ export function useComponentState({ account, goToConfirmOperation, onAuthorizati if (result.type === "fail") { switch (result.case) { - case HttpStatusCode.Unauthorized: return { - status: "login", - reason: "forbidden" - } - case HttpStatusCode.NotFound: return { - status: "login", - reason: "not-found", - } + case HttpStatusCode.Unauthorized: + return { + status: "login", + reason: "forbidden", + }; + case HttpStatusCode.NotFound: + return { + status: "login", + reason: "not-found", + }; default: { - assertUnreachable(result) + assertUnreachable(result); } } } @@ -61,10 +75,14 @@ export function useComponentState({ account, goToConfirmOperation, onAuthorizati 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")) { + if ( + !payto || + !payto.isKnown || + (payto.targetType !== "iban" && payto.targetType !== "x-taler-bank") + ) { return { status: "invalid-iban", - error: data + error: data, }; } @@ -73,12 +91,16 @@ export function useComponentState({ account, goToConfirmOperation, onAuthorizati ? Amounts.sub(debitThreshold, balance).amount : Amounts.add(balance, debitThreshold).amount; - return { status: "ready", - goToConfirmOperation, + onOperationCreated, error: undefined, + tab, onAuthorizationRequired, + onClose, + routeClose, + routeChargeWallet, + routeWireTransfer, account, limit, }; diff --git a/packages/demobank-ui/src/pages/AccountPage/test.ts b/packages/demobank-ui/src/pages/AccountPage/test.ts index 538decb29..14c8be948 100644 --- a/packages/demobank-ui/src/pages/AccountPage/test.ts +++ b/packages/demobank-ui/src/pages/AccountPage/test.ts @@ -19,14 +19,13 @@ * @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"; +// 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 () => { - }); + 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 index c9a7a6c13..9baefe96c 100644 --- a/packages/demobank-ui/src/pages/AccountPage/views.tsx +++ b/packages/demobank-ui/src/pages/AccountPage/views.tsx @@ -22,6 +22,7 @@ import { useBankState } from "../../hooks/bank-state.js"; import { usePreferences } from "../../hooks/preferences.js"; import { PaymentOptions } from "../PaymentOptions.js"; import { State } from "./index.js"; +import { privatePages } from "../../Routing.js"; export function InvalidIbanView({ error }: State.InvalidIban) { return ( @@ -29,29 +30,34 @@ export function InvalidIbanView({ error }: State.InvalidIban) { ); } -const IS_PUBLIC_ACCOUNT_ENABLED = false +const IS_PUBLIC_ACCOUNT_ENABLED = false; function ShowDemoInfo(): 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 href="/public-accounts">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> + 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 href={privatePages.publicAccountList.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(): VNode { @@ -60,30 +66,67 @@ function ShowPedingOperation(): VNode { 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` + 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" href={`#/2fa`}> - <i18n.Translate>this page</i18n.Translate> - </a> - </Attention> + })(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" + href={`#/2fa`} + > + <i18n.Translate>this page</i18n.Translate> + </a> + </Attention> + ); } -export function ReadyView({ account, limit, goToConfirmOperation, onAuthorizationRequired }: State.Ready): VNode<{}> { - - return <Fragment> - <ShowPedingOperation /> - <ShowDemoInfo /> - <PaymentOptions limit={limit} goToConfirmOperation={goToConfirmOperation} onAuthorizationRequired={onAuthorizationRequired} /> - <Transactions account={account} /> - </Fragment>; +export function ReadyView({ + tab, + account, + routeChargeWallet, + routeWireTransfer, + limit, + onClose, + routeClose, + onOperationCreated, + onAuthorizationRequired, +}: State.Ready): VNode { + return ( + <Fragment> + <ShowPedingOperation /> + <ShowDemoInfo /> + <PaymentOptions + tab={tab} + routeChargeWallet={routeChargeWallet} + routeWireTransfer={routeWireTransfer} + limit={limit} + routeClose={routeClose} + onClose={onClose} + onOperationCreated={onOperationCreated} + onAuthorizationRequired={onAuthorizationRequired} + /> + <Transactions account={account} /> + </Fragment> + ); } - - diff --git a/packages/demobank-ui/src/pages/BankFrame.tsx b/packages/demobank-ui/src/pages/BankFrame.tsx index 73e87d9d2..a106f370d 100644 --- a/packages/demobank-ui/src/pages/BankFrame.tsx +++ b/packages/demobank-ui/src/pages/BankFrame.tsx @@ -15,143 +15,195 @@ */ import { Amounts, TalerError, TranslatedString } from "@gnu-taler/taler-util"; -import { Footer, GlobalNotificationsBanner, Header, Loading, notifyError, notifyException, useTranslationContext } from "@gnu-taler/web-util/browser"; -import { ComponentChildren, Fragment, VNode, h } from "preact"; -import { useEffect, useErrorBoundary, useState } from "preact/hooks"; +import { + Footer, + GlobalNotificationsBanner, + Header, + Loading, + notifyError, + notifyException, + useTranslationContext, +} from "@gnu-taler/web-util/browser"; +import { ComponentChildren, VNode, h } from "preact"; +import { useEffect, useErrorBoundary } from "preact/hooks"; +import { privatePages } from "../Routing.js"; +import { useBankCoreApiContext } from "../context/config.js"; +import { useSettingsContext } from "../context/settings.js"; import { useAccountDetails } from "../hooks/access.js"; import { useBackendState } from "../hooks/backend.js"; -import { getAllBooleanPreferences, getLabelForPreferences, usePreferences } from "../hooks/preferences.js"; -import { RenderAmount } from "./PaytoWireTransferForm.js"; -import { useSettingsContext } from "../context/settings.js"; -import { useBankCoreApiContext } from "../context/config.js"; import { useBankState } from "../hooks/bank-state.js"; +import { + getAllBooleanPreferences, + getLabelForPreferences, + usePreferences, +} from "../hooks/preferences.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, }: { - account?: string, + account?: string; children: ComponentChildren; }): VNode { const { i18n } = useTranslationContext(); const backend = useBackendState(); const settings = useSettingsContext(); const [preferences, updatePreferences] = usePreferences(); - const [, , resetBankState] = useBankState() + const [, , resetBankState] = useBankState(); const [error, resetError] = useErrorBoundary(); useEffect(() => { if (error) { - const desc = (error instanceof Error ? error.stack : String(error)) as TranslatedString if (error instanceof Error) { - console.log("Internal error, please report", error) - notifyException(i18n.str`Internal error, please report.`, 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) + console.log("Internal error, please report", error); + notifyError( + i18n.str`Internal error, please report.`, + String(error) as TranslatedString, + ); } - resetError() + 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 ?? "#"} - onLogout={backend.state.status !== "loggedIn" ? undefined : () => { - backend.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 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" 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 > - - <GlobalNotificationsBanner /> - - <main class="-mt-32 flex-1"> - {account && - <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} /></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> + }, [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 ?? "#"} + onLogout={ + backend.state.status !== "loggedIn" + ? undefined + : () => { + backend.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" + 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> - </main> - - <Footer - testingUrlKey="corebank-api-base-url" - GIT_HASH={GIT_HASH} - VERSION={VERSION} - /> - - </div > + <GlobalNotificationsBanner /> + + <main class="-mt-32 flex-1"> + {account && ( + <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} /> + </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: accountName }: { account: string }): VNode { const { i18n } = useTranslationContext(); - return <a href="#/my-profile" class="underline underline-offset-2"> - <i18n.Translate>Welcome, <span class="whitespace-nowrap">{accountName}</span></i18n.Translate> - </a> + return ( + <a + href={privatePages.myAccountDetails.url({})} + class="underline underline-offset-2" + > + <i18n.Translate> + Welcome, <span class="whitespace-nowrap">{accountName}</span> + </i18n.Translate> + </a> + ); } function AccountBalance({ account }: { account: string }): VNode { const result = useAccountDetails(account); const { config } = useBankCoreApiContext(); if (!result) { - return <Loading /> + return <Loading />; } if (result instanceof TalerError) { - return <div /> + return <div />; } - if (result.type === "fail") 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} - /> + 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/DownloadStats.tsx b/packages/demobank-ui/src/pages/DownloadStats.tsx index 48daacaea..a98c573ae 100644 --- a/packages/demobank-ui/src/pages/DownloadStats.tsx +++ b/packages/demobank-ui/src/pages/DownloadStats.tsx @@ -14,18 +14,28 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ -import { AccessToken, AmountString, Logger, TalerCoreBankHttpClient, TalerCorebankApi, TalerError } from "@gnu-taler/taler-util"; -import { Attention, LocalNotificationBanner, useLocalNotification, useTranslationContext } from "@gnu-taler/web-util/browser"; -import { Fragment, VNode, h } from "preact"; +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 { useBackendState } from "../hooks/backend.js"; +import { RouteDefinition } from "../route.js"; import { getTimeframesForDate } from "./admin/AdminHome.js"; -const logger = new Logger("PublicHistoriesPage"); - interface Props { - onCancel: () => void; + routeCancel: RouteDefinition<Record<string, never>>; } type Options = { @@ -36,16 +46,19 @@ type Options = { compareWithPrevious: boolean; endOnFirstFail: boolean; includeHeader: boolean; -} +}; -/** +/** * Show histories of public accounts. */ -export function DownloadStats({ onCancel }: Props): VNode { +export function DownloadStats({ routeCancel }: Props): VNode { const { i18n } = useTranslationContext(); const { state: credentials } = useBackendState(); - const creds = credentials.status !== "loggedIn" || !credentials.isUserAdministrator ? undefined : credentials + const creds = + credentials.status !== "loggedIn" || !credentials.isUserAdministrator + ? undefined + : credentials; const { api } = useBankCoreApiContext(); const [options, setOptions] = useState<Options>({ @@ -56,19 +69,18 @@ export function DownloadStats({ onCancel }: Props): VNode { 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, notify, handleError] = useLocalNotification() + }); + 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>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} /> @@ -78,13 +90,12 @@ export function DownloadStats({ onCancel }: Props): VNode { </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() + onSubmit={(e) => { + e.preventDefault(); }} > <div class="px-4 py-6 sm:p-8"> @@ -92,223 +103,393 @@ export function DownloadStats({ onCancel }: Props): VNode { <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"> + <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" 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 + type="button" + 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"> + <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" 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 + type="button" + 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"> + <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" 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 + type="button" + 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"> + <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" 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 + type="button" + 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"> + <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" 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 + type="button" + 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 + 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" 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 + type="button" + 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"> + <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" 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 + type="button" + 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"> - <button type="button" class="text-sm font-semibold leading-6 text-gray-900" - onClick={onCancel} + <a + href={routeCancel.url({})} + class="text-sm font-semibold leading-6 text-gray-900" > <i18n.Translate>Cancel</i18n.Translate> - </button> - <button type="submit" + </a> + <button + type="submit" 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) + setDownloaded(undefined); await handleError(async () => { - const csv = await fetchAllStatus(api, creds.token, options, referenceDates, (step, total) => { - setLastStep({ step, total }) - }) - setDownloaded(csv) - }) - setLastStep(undefined) + 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> + {!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> - </div>} - {!downloaded ? <div class="h-5 mb-5" /> : - <a href={"data:text/plain;charset=utf-8," + encodeURIComponent(downloaded)} download={"bank-stats.csv"}> + )} + {!downloaded ? ( + <div class="h-5 mb-5" /> + ) : ( + <a + href={ + "data:text/plain;charset=utf-8," + encodeURIComponent(downloaded) + } + download={"bank-stats.csv"} + > <Attention title={i18n.str`Download completed`}> - <i18n.Translate>click here to save the file in your computer</i18n.Translate> + <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[], progres: (current: number, total: number) => void): Promise<string> { +async function fetchAllStatus( + api: TalerCoreBankHttpClient, + token: AccessToken, + options: Options, + references: Date[], + progres: (current: number, total: number) => void, +): Promise<string> { const allMetrics: TalerCorebankApi.MonitorTimeframeParam[] = []; if (options.hourMetric) { - allMetrics.push(TalerCorebankApi.MonitorTimeframeParam.hour) + allMetrics.push(TalerCorebankApi.MonitorTimeframeParam.hour); } if (options.dayMetric) { - allMetrics.push(TalerCorebankApi.MonitorTimeframeParam.day) + allMetrics.push(TalerCorebankApi.MonitorTimeframeParam.day); } if (options.monthMetric) { - allMetrics.push(TalerCorebankApi.MonitorTimeframeParam.month) + allMetrics.push(TalerCorebankApi.MonitorTimeframeParam.month); } if (options.yearMetric) { - allMetrics.push(TalerCorebankApi.MonitorTimeframeParam.year) + allMetrics.push(TalerCorebankApi.MonitorTimeframeParam.year); } /** * conver request into frames */ - const allFrames = allMetrics.flatMap(timeframe => references.map(reference => ({ - reference, - timeframe, - moment: getTimeframesForDate(reference, timeframe) - })) - ) - const total = allFrames.length + 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 - progres(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 allInfo = await allFrames.reduce( + async (prev, frame, index) => { + const accumulatedMap = await prev; + progres(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 - }) + const current = await api.getMonitor(token, { + timeframe: frame.timeframe, + which: frame.moment.current, + }); - if (current.type === "fail" && options.endOnFirstFail) { - throw TalerError.fromUncheckedDetail(current.detail) - } + 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>)) - progres(total, total) + 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>), + ); + progres(total, total); /** * conver into table format - * + * */ const table: Array<string[]> = []; if (options.includeHeader) { - table.push(["date", + table.push([ + "date", "metric", "reference", "talerInCount", @@ -320,7 +501,8 @@ async function fetchAllStatus(api: TalerCoreBankHttpClient, token: AccessToken, "cashinRegionalVolume", "cashoutCount", "cashoutFiatVolume", - "cashoutRegionalVolume",]) + "cashoutRegionalVolume", + ]); } Object.entries(allInfo).forEach(([name, data]) => { if (data.current) { @@ -328,9 +510,9 @@ async function fetchAllStatus(api: TalerCoreBankHttpClient, token: AccessToken, date: data.reference.getTime(), metric: name, reference: "current", - ...dataToRow(data.current) - } - table.push((Object.values(row) as string[])) + ...dataToRow(data.current), + }; + table.push(Object.values(row) as string[]); } if (data.previous) { @@ -338,20 +520,20 @@ async function fetchAllStatus(api: TalerCoreBankHttpClient, token: AccessToken, date: data.reference.getTime(), metric: name, reference: "previous", - ...dataToRow(data.previous) - } - table.push((Object.values(row) as string[])) + ...dataToRow(data.previous), + }; + table.push(Object.values(row) as string[]); } - }) + }); const csv = table.reduce((acc, row) => { - return acc + row.join(",") + "\n" - }, "") + return acc + row.join(",") + "\n"; + }, ""); - return csv + return csv; } -type JustData = Omit<Omit<Omit<TableRow, "metric">, "date">, "reference"> +type JustData = Omit<Omit<Omit<TableRow, "metric">, "date">, "reference">; function dataToRow(info: TalerCorebankApi.MonitorResponse): JustData { return { talerInCount: info.talerInCount, @@ -359,23 +541,28 @@ function dataToRow(info: TalerCorebankApi.MonitorResponse): JustData { 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, - } + 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, + reference: Date; previous: TalerCorebankApi.MonitorResponse | undefined; current: TalerCorebankApi.MonitorResponse | undefined; -} +}; type TableRow = { - date: number, - metric: string, - reference: "current" | "previous", + date: number; + metric: string; + reference: "current" | "previous"; cashinCount?: number; cashinRegionalVolume?: AmountString; cashinFiatVolume?: AmountString; @@ -386,11 +573,4 @@ type TableRow = { talerInVolume: AmountString; talerOutCount: number; talerOutVolume: AmountString; -} -async function delay() { - return new Promise(res => { - setTimeout(() => { - res(null) - }, 500) - }) -}
\ No newline at end of file +}; diff --git a/packages/demobank-ui/src/pages/LoginForm.tsx b/packages/demobank-ui/src/pages/LoginForm.tsx index 04bf0b7fa..7e5631cfb 100644 --- a/packages/demobank-ui/src/pages/LoginForm.tsx +++ b/packages/demobank-ui/src/pages/LoginForm.tsx @@ -14,29 +14,48 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ -import { HttpStatusCode, TranslatedString } from "@gnu-taler/taler-util"; -import { LocalNotificationBanner, ShowInputErrorLabel, useLocalNotification, useTranslationContext } from "@gnu-taler/web-util/browser"; -import { Fragment, VNode, h } from "preact"; +import { + HttpStatusCode, + TranslatedString, + assertUnreachable, +} from "@gnu-taler/taler-util"; +import { + LocalNotificationBanner, + ShowInputErrorLabel, + useLocalNotification, + 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 { useBackendState } from "../hooks/backend.js"; import { undefinedIfEmpty } from "../utils.js"; import { doAutoFocus } from "./PaytoWireTransferForm.js"; -import { assertUnreachable } from "./WithdrawalOperationPage.js"; - +import { RouteDefinition } from "../route.js"; /** * Collect and submit login data. */ -export function LoginForm({ currentUser, fixedUser, onRegister }: { fixedUser?: boolean, currentUser?: string, onRegister?: () => void }): VNode { +export function LoginForm({ + currentUser, + fixedUser, + routeRegister, +}: { + fixedUser?: boolean; + currentUser?: string; + routeRegister?: RouteDefinition<Record<string, never>>; +}): VNode { const backend = useBackendState(); - const sessionUser = backend.state.status !== "loggedOut" ? backend.state.username : undefined - const [username, setUsername] = useState<string | undefined>(currentUser ?? sessionUser); + const sessionUser = + backend.state.status !== "loggedOut" ? backend.state.username : undefined; + const [username, setUsername] = useState<string | undefined>( + currentUser ?? sessionUser, + ); const [password, setPassword] = useState<string | undefined>(); const { i18n } = useTranslationContext(); const { api } = useBankCoreApiContext(); - const [notification, notify, handleError] = useLocalNotification() + const [notification, notify, handleError] = useLocalNotification(); const { config } = useBankCoreApiContext(); const ref = useRef<HTMLInputElement>(null); @@ -44,63 +63,71 @@ export function LoginForm({ currentUser, fixedUser, onRegister }: { fixedUser?: ref.current?.focus(); }, []); - const [busy, setBusy] = useState<Record<string, undefined>>() + const [busy, setBusy] = useState<Record<string, undefined>>(); - 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, - }) ?? busy; + 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, + }) ?? busy; async function doLogout() { - backend.logOut() + backend.logOut(); } async function doLogin() { if (!username || !password) return; - setBusy({}) + setBusy({}); await handleError(async () => { - const resp = await api.getAuthenticationAPI(username).createAccessToken(password, { - // scope: "readwrite" as "write", //FIX: different than merchant - scope: "readwrite", - duration: { - d_us: "forever" //FIX: should return shortest - // d_us: 60 * 60 * 24 * 7 * 1000 * 1000 - }, - refreshable: true, - }) + const resp = await api + .getAuthenticationAPI(username) + .createAccessToken(password, { + // scope: "readwrite" as "write", // FIX: different than merchant + scope: "readwrite", + duration: { + d_us: "forever", // FIX: should return shortest + // d_us: 60 * 60 * 24 * 7 * 1000 * 1000 + }, + refreshable: true, + }); if (resp.type === "ok") { backend.logIn({ username, token: resp.body.access_token }); } else { switch (resp.case) { - case HttpStatusCode.Unauthorized: return notify({ - type: "error", - title: i18n.str`Wrong credentials for "${username}"`, - description: resp.detail.hint as TranslatedString, - debug: resp.detail, - }) - case HttpStatusCode.NotFound: return notify({ - type: "error", - title: i18n.str`Account not found`, - description: resp.detail.hint as TranslatedString, - debug: resp.detail, - }) - default: assertUnreachable(resp) + case HttpStatusCode.Unauthorized: + return notify({ + type: "error", + title: i18n.str`Wrong credentials for "${username}"`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + }); + case HttpStatusCode.NotFound: + return notify({ + type: "error", + title: i18n.str`Account not found`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + }); + default: + assertUnreachable(resp); } } - }) + }); setPassword(undefined); - setBusy(undefined) + setBusy(undefined); } 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 + <form + class="space-y-6" + noValidate onSubmit={(e) => { e.preventDefault(); }} @@ -108,7 +135,10 @@ export function LoginForm({ currentUser, fixedUser, onRegister }: { fixedUser?: autoCorrect="off" > <div> - <label for="username" class="block text-sm font-medium leading-6 text-gray-900"> + <label + for="username" + class="block text-sm font-medium leading-6 text-gray-900" + > <i18n.Translate>Username</i18n.Translate> </label> <div class="mt-2"> @@ -138,7 +168,10 @@ export function LoginForm({ currentUser, fixedUser, onRegister }: { fixedUser?: <div> <div class="flex items-center justify-between"> - <label for="password" class="block text-sm font-medium leading-6 text-gray-900"> + <label + for="password" + class="block text-sm font-medium leading-6 text-gray-900" + > <i18n.Translate>Password</i18n.Translate> </label> </div> @@ -165,54 +198,58 @@ export function LoginForm({ currentUser, fixedUser, onRegister }: { fixedUser?: </div> </div> - {backend.state.status !== "loggedOut" ? <div class="flex justify-between"> - <button type="submit" - 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" - 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() - doLogin() - }} - > - <i18n.Translate>Check</i18n.Translate> - </button> - </div> : <div> - <button type="submit" - 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} - onClick={(e) => { - e.preventDefault() - doLogin() - }} - > - <i18n.Translate>Log in</i18n.Translate> - </button> - </div>} + {backend.state.status !== "loggedOut" ? ( + <div class="flex justify-between"> + <button + type="submit" + 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" + 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(); + doLogin(); + }} + > + <i18n.Translate>Check</i18n.Translate> + </button> + </div> + ) : ( + <div> + <button + type="submit" + 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} + onClick={(e) => { + e.preventDefault(); + doLogin(); + }} + > + <i18n.Translate>Log in</i18n.Translate> + </button> + </div> + )} </form> - {config.allow_registrations && onRegister && + {config.allow_registrations && routeRegister && ( <p class="mt-10 text-center text-sm text-gray-500 border-t"> - <button type="submit" + <a + href={routeRegister.url({})} class="flex 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" - onClick={(e) => { - e.preventDefault() - onRegister() - }} > <i18n.Translate>Register</i18n.Translate> - </button> + </a> </p> - } + )} </div> </div> ); diff --git a/packages/demobank-ui/src/pages/OperationState/index.ts b/packages/demobank-ui/src/pages/OperationState/index.ts index 18ffe0ec3..20cb1760f 100644 --- a/packages/demobank-ui/src/pages/OperationState/index.ts +++ b/packages/demobank-ui/src/pages/OperationState/index.ts @@ -14,28 +14,46 @@ 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 { + 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 { + AbortedView, + ConfirmedView, + FailedView, + InvalidPaytoView, + InvalidReserveView, + InvalidWithdrawalView, + NeedConfirmationView, + ReadyView, +} from "./views.js"; +import { RouteDefinition } from "../../route.js"; export interface Props { currency: string; - onAuthorizationRequired: () => void, - onClose: () => void; + onAuthorizationRequired: () => void; + routeClose: RouteDefinition<Record<string, never>>; + onAbort: () => void; } -export type State = State.Loading | - State.LoadingError | - State.Ready | - State.Failed | - State.Aborted | - State.Confirmed | - State.InvalidPayto | - State.InvalidWithdrawal | - State.InvalidReserve | - State.NeedConfirmation; +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 { @@ -59,48 +77,55 @@ export namespace State { export interface Ready { status: "ready"; error: undefined; - uri: WithdrawUriResult, - onClose: () => Promise<TalerCoreBankErrorsByMethod<"abortWithdrawalById"> | undefined>; + uri: WithdrawUriResult; + onAbort: () => Promise< + TalerCoreBankErrorsByMethod<"abortWithdrawalById"> | undefined + >; + routeClose: RouteDefinition<Record<string, never>>; } export interface InvalidPayto { - status: "invalid-payto", + status: "invalid-payto"; error: undefined; payto: string | undefined; - onClose: () => void; } export interface InvalidWithdrawal { - status: "invalid-withdrawal", + status: "invalid-withdrawal"; error: undefined; - onClose: () => void; - uri: string, + uri: string; } export interface InvalidReserve { - status: "invalid-reserve", + status: "invalid-reserve"; error: undefined; - onClose: () => void; reserve: string | undefined; } export interface NeedConfirmation { - status: "need-confirmation", - onAuthorizationRequired: () => void, - account: string, - onAbort: undefined | (() => Promise<TalerCoreBankErrorsByMethod<"abortWithdrawalById"> | undefined>); - onConfirm: undefined | (() => Promise<TalerCoreBankErrorsByMethod<"confirmWithdrawalById"> | undefined>); + status: "need-confirmation"; + onAuthorizationRequired: () => void; + account: string; + onAbort: + | undefined + | (() => Promise< + TalerCoreBankErrorsByMethod<"abortWithdrawalById"> | undefined + >); + onConfirm: + | undefined + | (() => Promise< + TalerCoreBankErrorsByMethod<"confirmWithdrawalById"> | undefined + >); error: undefined; - id: string, + id: string; } export interface Aborted { - status: "aborted", + status: "aborted"; error: undefined; - onClose: () => void; + routeClose: RouteDefinition<Record<string, never>>; } export interface Confirmed { - status: "confirmed", + status: "confirmed"; error: undefined; - onClose: () => void; + routeClose: RouteDefinition<Record<string, never>>; } - } export interface Transaction { @@ -113,13 +138,13 @@ export interface Transaction { const viewMapping: utils.StateViewMap<State> = { loading: Loading, - "failed": FailedView, + failed: FailedView, "invalid-payto": InvalidPaytoView, "invalid-withdrawal": InvalidWithdrawalView, "invalid-reserve": InvalidReserveView, "need-confirmation": NeedConfirmationView, - "aborted": AbortedView, - "confirmed": ConfirmedView, + aborted: AbortedView, + confirmed: ConfirmedView, "loading-error": ErrorLoadingWithDebug, ready: ReadyView, }; diff --git a/packages/demobank-ui/src/pages/OperationState/state.ts b/packages/demobank-ui/src/pages/OperationState/state.ts index 32a13d047..20d66bbb1 100644 --- a/packages/demobank-ui/src/pages/OperationState/state.ts +++ b/packages/demobank-ui/src/pages/OperationState/state.ts @@ -14,7 +14,16 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ -import { Amounts, HttpStatusCode, TalerCoreBankErrorsByMethod, TalerError, parsePaytoUri, parseWithdrawUri, stringifyWithdrawUri } from "@gnu-taler/taler-util"; +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"; @@ -23,73 +32,80 @@ import { useWithdrawalDetails } from "../../hooks/access.js"; import { useBackendState } from "../../hooks/backend.js"; import { useBankState } from "../../hooks/bank-state.js"; import { usePreferences } from "../../hooks/preferences.js"; -import { assertUnreachable } from "../WithdrawalOperationPage.js"; import { Props, State } from "./index.js"; -export function useComponentState({ currency, onClose, onAuthorizationRequired, }: Props): utils.RecursiveState<State> { - const [settings] = usePreferences() +export function useComponentState({ + currency, + routeClose, + onAbort, + onAuthorizationRequired, +}: Props): utils.RecursiveState<State> { + const [settings] = usePreferences(); const [bankState, updateBankState] = useBankState(); - const { state: credentials } = useBackendState() - const creds = credentials.status !== "loggedIn" ? undefined : credentials - const { api } = useBankCoreApiContext() + const { state: credentials } = useBackendState(); + const creds = credentials.status !== "loggedIn" ? undefined : credentials; + const { api } = useBankCoreApiContext(); - const [failure, setFailure] = useState<TalerCoreBankErrorsByMethod<"createWithdrawal"> | undefined>() - const amount = settings.maxWithdrawalAmount + 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}`) + // FIXME: if amount is not enough use balance + const parsedAmount = Amounts.parseOrThrow(`${currency}:${amount}`); if (!creds) return; const resp = await api.createWithdrawal(creds, { amount: Amounts.stringify(parsedAmount), }); if (resp.type === "fail") { - setFailure(resp) + setFailure(resp); return; } - updateBankState("currentWithdrawalOperationId", resp.body.withdrawal_id) - + updateBankState("currentWithdrawalOperationId", resp.body.withdrawal_id); } - const withdrawalOperationId = bankState.currentWithdrawalOperationId + const withdrawalOperationId = bankState.currentWithdrawalOperationId; useEffect(() => { if (withdrawalOperationId === undefined) { - doSilentStart() + doSilentStart(); } - }, [settings.fastWithdrawal, amount]) + }, [settings.fastWithdrawal, amount]); if (failure) { return { status: "failed", - error: failure - } + error: failure, + }; } if (!withdrawalOperationId) { return { status: "loading", - error: undefined - } + error: undefined, + }; } - const wid = withdrawalOperationId + const wid = withdrawalOperationId; async function doAbort() { if (!creds) return; const resp = await api.abortWithdrawalById(creds, wid); if (resp.type === "ok") { - updateBankState("currentWithdrawalOperationId", undefined) - onClose(); + // updateBankState("currentWithdrawalOperationId", undefined) + onAbort(); } else { return resp; } } - async function doConfirm(): Promise<TalerCoreBankErrorsByMethod<"confirmWithdrawalById"> | undefined> { + async function doConfirm(): Promise< + TalerCoreBankErrorsByMethod<"confirmWithdrawalById"> | undefined + > { if (!creds) return; const resp = await api.confirmWithdrawalById(creds, wid); if (resp.type === "ok") { - mutate(() => true)//clean withdrawal state + mutate(() => true); //clean withdrawal state } else { return resp; } @@ -105,30 +121,29 @@ export function useComponentState({ currency, onClose, onAuthorizationRequired, status: "invalid-withdrawal", error: undefined, uri, - onClose, - } + }; } return (): utils.RecursiveState<State> => { const result = useWithdrawalDetails(withdrawalOperationId); - const shouldCreateNewOperation = result && !(result instanceof TalerError) + const shouldCreateNewOperation = result && !(result instanceof TalerError); useEffect(() => { if (shouldCreateNewOperation) { - doSilentStart() + doSilentStart(); } - }, []) + }, []); if (!result) { return { status: "loading", - error: undefined - } + error: undefined, + }; } if (result instanceof TalerError) { return { status: "loading-error", - error: result - } + error: result, + }; } if (result.type === "fail") { @@ -138,13 +153,11 @@ export function useComponentState({ currency, onClose, onAuthorizationRequired, return { status: "aborted", error: undefined, - onClose: async () => { - updateBankState("currentWithdrawalOperationId", undefined) - onClose() - }, - } + routeClose, + }; } - default: assertUnreachable(result) + default: + assertUnreachable(result); } } @@ -153,26 +166,20 @@ export function useComponentState({ currency, onClose, onAuthorizationRequired, return { status: "aborted", error: undefined, - onClose: async () => { - updateBankState("currentWithdrawalOperationId", undefined) - onClose() - }, - } + routeClose, + }; } if (data.status === "confirmed") { if (!settings.showWithdrawalSuccess) { - updateBankState("currentWithdrawalOperationId", undefined) - onClose() + updateBankState("currentWithdrawalOperationId", undefined); + // onClose() } return { status: "confirmed", error: undefined, - onClose: async () => { - updateBankState("currentWithdrawalOperationId", undefined) - onClose() - }, - } + routeClose, + }; } if (data.status === "pending") { @@ -180,11 +187,14 @@ export function useComponentState({ currency, onClose, onAuthorizationRequired, status: "ready", error: undefined, uri: parsedUri, - onClose: !creds ? (async () => { - onClose(); - return undefined - }) : doAbort, - } + routeClose, + onAbort: !creds + ? async () => { + onAbort(); + return undefined; + } + : doAbort, + }; } if (!data.selected_reserve_pub) { @@ -192,19 +202,19 @@ export function useComponentState({ currency, onClose, onAuthorizationRequired, status: "invalid-reserve", error: undefined, reserve: data.selected_reserve_pub, - onClose, - } + }; } - const account = !data.selected_exchange_account ? undefined : parsePaytoUri(data.selected_exchange_account) + 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, - onClose, - } + }; } return { @@ -214,8 +224,7 @@ export function useComponentState({ currency, onClose, onAuthorizationRequired, account: data.username, id: withdrawalOperationId, onAbort: !creds ? undefined : doAbort, - onConfirm: !creds ? undefined : doConfirm - } - } - + onConfirm: !creds ? undefined : doConfirm, + }; + }; } diff --git a/packages/demobank-ui/src/pages/OperationState/test.ts b/packages/demobank-ui/src/pages/OperationState/test.ts index 3ba351cd3..d47cb64a2 100644 --- a/packages/demobank-ui/src/pages/OperationState/test.ts +++ b/packages/demobank-ui/src/pages/OperationState/test.ts @@ -19,14 +19,13 @@ * @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"; +// 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 () => { - }); + 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 index c86b8bd4b..ac3724eb8 100644 --- a/packages/demobank-ui/src/pages/OperationState/views.tsx +++ b/packages/demobank-ui/src/pages/OperationState/views.tsx @@ -14,121 +14,143 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ -import { AbsoluteTime, HttpStatusCode, TalerErrorCode, TranslatedString, stringifyWithdrawUri } from "@gnu-taler/taler-util"; -import { Attention, LocalNotificationBanner, notifyInfo, useLocalNotification, useTranslationContext } from "@gnu-taler/web-util/browser"; +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 { assertUnreachable } from "../WithdrawalOperationPage.js"; import { State } from "./index.js"; -export function InvalidPaytoView({ payto, onClose }: State.InvalidPayto) { - return ( - <div>Payto from server is not valid "{payto}"</div> - ); +export function InvalidPaytoView({ payto }: State.InvalidPayto) { + return <div>Payto from server is not valid "{payto}"</div>; } -export function InvalidWithdrawalView({ uri, onClose }: State.InvalidWithdrawal) { - return ( - <div>Withdrawal uri from server is not valid "{uri}"</div> - ); +export function InvalidWithdrawalView({ uri }: State.InvalidWithdrawal) { + return <div>Withdrawal uri from server is not valid "{uri}"</div>; } -export function InvalidReserveView({ reserve, onClose }: State.InvalidReserve) { - return ( - <div>Reserve from server is not valid "{reserve}"</div> - ); +export function InvalidReserveView({ reserve }: State.InvalidReserve) { + return <div>Reserve from server is not valid "{reserve}"</div>; } -export function NeedConfirmationView({ error, onAbort: doAbort, onConfirm: doConfirm, account, id, onAuthorizationRequired, }: State.NeedConfirmation) { - const { i18n } = useTranslationContext() - const [settings] = usePreferences() - const [notification, notify, errorHandler] = useLocalNotification() - const [, updateBankState] = useBankState() +export function NeedConfirmationView({ + onAbort: doAbort, + onConfirm: doConfirm, + 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() + 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) + 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() + const resp = await doConfirm(); if (!resp) { if (!settings.showWithdrawalSuccess) { - notifyInfo(i18n.str`Wire transfer completed!`) + notifyInfo(i18n.str`Wire transfer completed!`); } - return + 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 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(), request: id, - }) - return onAuthorizationRequired() + }); + return onAuthorizationRequired(); } - default: assertUnreachable(resp) + default: + assertUnreachable(resp); } - }) + }); } return ( @@ -144,23 +166,27 @@ export function NeedConfirmationView({ error, onAbort: doAbort, onConfirm: doCon 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() + 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" class="text-sm font-semibold leading-6 text-gray-900" + <button + type="button" + class="text-sm font-semibold leading-6 text-gray-900" onClick={(e) => { - e.preventDefault() - onCancel() + e.preventDefault(); + onCancel(); }} > - <i18n.Translate>Cancel</i18n.Translate></button> - <button type="submit" + <i18n.Translate>Cancel</i18n.Translate> + </button> + <button + type="submit" 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() + e.preventDefault(); + onConfirm(); }} > <i18n.Translate>Transfer</i18n.Translate> @@ -171,61 +197,81 @@ export function NeedConfirmationView({ error, onAbort: doAbort, onConfirm: doCon </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) + 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({ error, onClose }: State.Aborted) { - return ( - <div>aborted</div> - ); +export function AbortedView() { + return <div>aborted</div>; } -export function ConfirmedView({ error, onClose }: State.Confirmed) { +export function ConfirmedView({ routeClose }: State.Confirmed) { const { i18n } = useTranslationContext(); - const [settings, updateSettings] = usePreferences() + 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 + 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"> + <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. + 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> @@ -234,132 +280,165 @@ export function ConfirmedView({ error, onClose }: State.Confirmed) { <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"> + <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" 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" + <button + type="button" + 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> + 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"> - <button type="button" + <a + href={routeClose.url({})} + type="button" class="inline-flex w-full justify-center rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" - onClick={async (e) => { - e.preventDefault(); - onClose() - }}> + > <i18n.Translate>Close</i18n.Translate> - </button> + </a> </div> </Fragment> - ); } -export function ReadyView({ uri, onClose: doClose }: State.Ready): VNode<{}> { +export function ReadyView({ + uri, + onAbort: doAbort, +}: State.Ready): VNode<Record<string, never>> { const { i18n } = useTranslationContext(); - const [notification, notify, errorHandler] = useLocalNotification() + const [notification, notify, errorHandler] = useLocalNotification(); const talerWithdrawUri = stringifyWithdrawUri(uri); useEffect(() => { - //Taler Wallet WebExtension is listening to headers response and tab updates. - //In the SPA there is no header response with the Taler URI so - //this hack manually triggers the tab update after the QR is in the DOM. + // Taler Wallet WebExtension is listening to headers response and tab updates. + // In the SPA there is no header response with the Taler URI so + // this hack manually triggers the tab update after the QR is in the DOM. // WebExtension will be using // https://developer.chrome.com/docs/extensions/reference/tabs/#event-onUpdated document.title = `${document.title} ${uri.withdrawalOperationId}`; - const meta = document.createElement("meta") - meta.setAttribute("name", "taler-uri") - meta.setAttribute("content", talerWithdrawUri) - document.head.insertBefore(meta, document.head.children.length ? document.head.children[0] : null) + const meta = document.createElement("meta"); + meta.setAttribute("name", "taler-uri"); + meta.setAttribute("content", talerWithdrawUri); + document.head.insertBefore( + meta, + document.head.children.length ? document.head.children[0] : null, + ); }, []); - async function onClose() { + async function onAbort() { errorHandler(async () => { - const hasError = await doClose() + 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) + 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} /> + return ( + <Fragment> + <LocalNotificationBanner notification={notification} /> - <div class="flex justify-end mt-4"> - <button type="button" - 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={() => { - onClose() - }} - > - Cancel - </button> - </div> + <div class="flex justify-end mt-4"> + <button + type="button" + 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 should access your wallet with the GNU Taler WebExtension now or click the link if your WebExtension have the "Inject Taler support" option enabled.</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} - 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 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 should access + your wallet with the GNU Taler WebExtension now or click the + link if your WebExtension have the "Inject Taler support" + option enabled. + </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} + 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> - <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 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 class="mt-2 max-w-md ml-auto mr-auto"> - <QR text={talerWithdrawUri} /> </div> </div> - </div> - - </Fragment> - + </Fragment> + ); } diff --git a/packages/demobank-ui/src/pages/PaymentOptions.tsx b/packages/demobank-ui/src/pages/PaymentOptions.tsx index 55611c172..53086d4cc 100644 --- a/packages/demobank-ui/src/pages/PaymentOptions.tsx +++ b/packages/demobank-ui/src/pages/PaymentOptions.tsx @@ -15,30 +15,41 @@ */ import { AmountJson } from "@gnu-taler/taler-util"; -import { notifyInfo, useTranslationContext } from "@gnu-taler/web-util/browser"; import { VNode, h } from "preact"; -import { useState } from "preact/hooks"; import { useBankState } from "../hooks/bank-state.js"; import { PaytoWireTransferForm } from "./PaytoWireTransferForm.js"; import { WalletWithdrawForm } from "./WalletWithdrawForm.js"; +import { RouteDefinition } from "../route.js"; +import { useTranslationContext } from "@gnu-taler/web-util/browser"; /** * Let the user choose a payment option, * then specify the details trigger the action. */ -export function PaymentOptions({ limit, goToConfirmOperation, onAuthorizationRequired }: { - limit: AmountJson, - onAuthorizationRequired: () => void, - goToConfirmOperation: (id: string) => void, +export function PaymentOptions({ + routeClose, + routeChargeWallet, + routeWireTransfer, + tab, + limit, + onOperationCreated, + onClose, + onAuthorizationRequired, +}: { + limit: AmountJson; + tab: "charge-wallet" | "wire-transfer" | undefined; + onAuthorizationRequired: () => void; + onOperationCreated: (wopid: string) => void; + onClose: () => void; + routeClose: RouteDefinition<Record<string, never>>; + routeChargeWallet: RouteDefinition<Record<string, never>>; + routeWireTransfer: RouteDefinition<Record<string, never>>; }): VNode { const { i18n } = useTranslationContext(); const [bankState] = useBankState(); - const [tab, setTab] = useState<"charge-wallet" | "wire-transfer" | undefined>(); - 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> @@ -46,65 +57,112 @@ export function PaymentOptions({ limit, goToConfirmOperation, onAuthorizationReq <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" --> */} - <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")}> - <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" onClick={() => { - setTab("charge-wallet") - }} - /> - <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 <b>Taler</b> 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 && - <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" /> + <a 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 <b>Taler</b> 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> - <i18n.Translate>operation ready</i18n.Translate> </span> - } - </div> - </label> - + <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 && ( + <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> + )} + </div> + </label> + </a> - <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")}> - <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" onClick={() => { - setTab("wire-transfer") - }} /> - <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> + <a 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> - <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 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> - </div> - </label> + </label> + </a> </div> {tab === "charge-wallet" && ( <WalletWithdrawForm focus limit={limit} onAuthorizationRequired={onAuthorizationRequired} - goToConfirmOperation={goToConfirmOperation} - onCancel={() => { - setTab(undefined) - }} + onOperationCreated={onOperationCreated} + onOperationAborted={onClose} + routeCancel={routeClose} /> )} {tab === "wire-transfer" && ( @@ -113,17 +171,11 @@ export function PaymentOptions({ limit, goToConfirmOperation, onAuthorizationReq title={i18n.str`Transfer details`} limit={limit} onAuthorizationRequired={onAuthorizationRequired} - onSuccess={() => { - notifyInfo(i18n.str`Wire transfer created!`); - setTab(undefined) - }} - onCancel={() => { - setTab(undefined) - }} + onSuccess={onClose} + routeCancel={routeClose} /> )} - </fieldset> </div> - ) + ); } diff --git a/packages/demobank-ui/src/pages/PaytoWireTransferForm.tsx b/packages/demobank-ui/src/pages/PaytoWireTransferForm.tsx index 321b87253..2259929e7 100644 --- a/packages/demobank-ui/src/pages/PaytoWireTransferForm.tsx +++ b/packages/demobank-ui/src/pages/PaytoWireTransferForm.tsx @@ -17,68 +17,62 @@ import { AbsoluteTime, AmountJson, - AmountLike, AmountString, Amounts, CurrencySpecification, FRAC_SEPARATOR, HttpStatusCode, - Logger, PaytoString, TalerErrorCode, TranslatedString, + assertUnreachable, buildPayto, parsePaytoUri, - stringifyPaytoUri + stringifyPaytoUri, } from "@gnu-taler/taler-util"; import { + LocalNotificationBanner, + ShowInputErrorLabel, + notifyInfo, useLocalNotification, - useTranslationContext + useTranslationContext, } from "@gnu-taler/web-util/browser"; -import { Fragment, Ref, VNode, h } from "preact"; +import { Ref, VNode, h } from "preact"; import { useState } from "preact/hooks"; import { mutate } from "swr"; -import { ShowInputErrorLabel } from "@gnu-taler/web-util/browser"; import { useBankCoreApiContext } from "../context/config.js"; import { useBackendState } from "../hooks/backend.js"; -import { - undefinedIfEmpty, - validateIBAN, - withRuntimeErrorHandling -} from "../utils.js"; -import { assertUnreachable } from "./WithdrawalOperationPage.js"; -import { LocalNotificationBanner } from "@gnu-taler/web-util/browser"; import { useBankState } from "../hooks/bank-state.js"; - -const logger = new Logger("PaytoWireTransferForm"); +import { RouteDefinition } from "../route.js"; +import { undefinedIfEmpty, validateIBAN } from "../utils.js"; export function PaytoWireTransferForm({ focus, title, toAccount, onSuccess, - onCancel, + routeCancel, onAuthorizationRequired, limit, }: { - title: TranslatedString, + title: TranslatedString; focus?: boolean; - toAccount?: string, + toAccount?: string; onSuccess: () => void; onAuthorizationRequired: () => void; - onCancel: (() => void) | undefined; + routeCancel?: RouteDefinition<Record<string, never>>; limit: AmountJson; }): VNode { const [isRawPayto, setIsRawPayto] = useState(false); - const { state: credentials } = useBackendState() + const { state: credentials } = useBackendState(); const { api } = useBankCoreApiContext(); - const sendingToFixedAccount = toAccount !== undefined - //FIXME: support other destination that just IBAN + const sendingToFixedAccount = toAccount !== undefined; + // FIXME: support other destination that just IBAN const [iban, setIban] = useState<string | undefined>(toAccount); const [subject, setSubject] = useState<string | undefined>(); const [amount, setAmount] = useState<string | undefined>(); - const [, updateBankState] = useBankState() + const [, updateBankState] = useBankState(); const [rawPaytoInput, rawPaytoInputSetter] = useState<string | undefined>( undefined, @@ -89,7 +83,7 @@ export function PaytoWireTransferForm({ const trimmedAmountStr = amount?.trim(); const parsedAmount = Amounts.parse(`${limit.currency}:${trimmedAmountStr}`); const IBAN_REGEX = /^[A-Z][A-Z0-9]*$/; - const [notification, notify, handleError] = useLocalNotification() + const [notification, notify, handleError] = useLocalNotification(); const errorsWire = undefinedIfEmpty({ iban: !iban @@ -135,18 +129,18 @@ export function PaytoWireTransferForm({ if (credentials.status !== "loggedIn") return; if (rawPaytoInput) { - const p = parsePaytoUri(rawPaytoInput) + 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) + 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 (!iban || !subject) return; const ibanPayto = buildPayto("iban", iban, undefined); ibanPayto.params.message = encodeURIComponent(subject); payto_uri = stringifyPaytoUri(ibanPayto); - sendingAmount = `${limit.currency}:${trimmedAmountStr}` as AmountString + sendingAmount = `${limit.currency}:${trimmedAmountStr}` as AmountString; } const puri = payto_uri; const sAmount = sendingAmount; @@ -155,288 +149,348 @@ export function PaytoWireTransferForm({ const request = { payto_uri: puri, amount: sAmount, - } + }; const resp = await api.createTransaction(credentials, request); - mutate(() => true) + 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.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), sent: AbsoluteTime.never(), request, - }) - return onAuthorizationRequired() + }); + return onAuthorizationRequired(); } - default: assertUnreachable(resp) + default: + assertUnreachable(resp); } } + notifyInfo(i18n.str`Wire transfer created!`); onSuccess(); setAmount(undefined); setIban(undefined); setSubject(undefined); - rawPaytoInputSetter(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> - <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 && parsed.targetType === "iban") { - setIban(parsed.iban) - const amountStr = parsed.params["amount"] - if (amountStr) { - const amount = Amounts.parse(parsed.params["amount"]) - if (amount) { - setAmount(Amounts.stringifyValue(amount)) - } - } - const subject = parsed.params["message"] - if (subject) { - setSubject(subject) - } + 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> + <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") } - 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 (iban) { - const payto = buildPayto("iban", iban, undefined) - if (parsedAmount) { - payto.params["amount"] = Amounts.stringify(parsedAmount) + > + <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 && + parsed.targetType === "iban" + ) { + setIban(parsed.iban); + const amountStr = parsed.params["amount"]; + if (amountStr) { + const amount = Amounts.parse(parsed.params["amount"]); + if (amount) { + setAmount(Amounts.stringifyValue(amount)); + } + } + const subject = parsed.params["message"]; + if (subject) { + setSubject(subject); + } } - if (subject) { - payto.params["message"] = subject - } - rawPaytoInputSetter(stringifyPaytoUri(payto)) - } - setIsRawPayto(true) - }} /> + setIsRawPayto(false); + }} + /> <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 class="block text-sm font-medium text-gray-900"> + <i18n.Translate>Using a form</i18n.Translate> </span> </span> </span> </label> - } - </div> - </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"> - <div class="sm:col-span-5"> - <label for="iban" class="block text-sm font-medium leading-6 text-gray-900">{i18n.str`Recipient`}</label> - <div class="mt-2"> + {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 - 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="iban" - id="iban" - disabled={sendingToFixedAccount} - value={iban ?? ""} - placeholder="CC0123456789" - autocomplete="off" - required - pattern={ibanRegex} - onInput={(e): void => { - setIban(e.currentTarget.value.toUpperCase()); + 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 (iban) { + const payto = buildPayto("iban", iban, undefined); + if (parsedAmount) { + payto.params["amount"] = + Amounts.stringify(parsedAmount); + } + if (subject) { + payto.params["message"] = subject; + } + rawPaytoInputSetter(stringifyPaytoUri(payto)); + } + setIsRawPayto(true); }} /> - <ShowInputErrorLabel - message={errorsWire?.iban} - isDirty={iban !== undefined} - /> - </div> - <p class="mt-2 text-sm text-gray-500" > - <i18n.Translate>IBAN of the recipient's account</i18n.Translate> - </p> - </div> + <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> + )} + </div> + </div> + </div> - <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} - /> + <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"> + <div class="sm:col-span-5"> + <label + for="iban" + class="block text-sm font-medium leading-6 text-gray-900" + >{i18n.str`Recipient`}</label> + <div class="mt-2"> + <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="iban" + id="iban" + disabled={sendingToFixedAccount} + value={iban ?? ""} + placeholder="CC0123456789" + autocomplete="off" + required + pattern={ibanRegex} + onInput={(e): void => { + setIban(e.currentTarget.value.toUpperCase()); + }} + /> + <ShowInputErrorLabel + message={errorsWire?.iban} + isDirty={iban !== undefined} + /> + </div> + <p class="mt-2 text-sm text-gray-500"> + <i18n.Translate> + IBAN of the recipient's account + </i18n.Translate> + </p> </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 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> : - <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={i18n.str`payto://iban/[receiver-iban]?message=[subject]&amount=[${limit.currency}:X.Y]`} - onInput={(e): void => { - rawPaytoInputSetter(e.currentTarget.value); + <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={errorsPayto?.rawPaytoInput} - isDirty={rawPaytoInput !== undefined} + 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> - } - </div> - <div class="flex items-center justify-between gap-x-6 border-t border-gray-900/10 px-4 py-4 sm:px-8"> - {onCancel ? - <button type="button" class="text-sm font-semibold leading-6 text-gray-900" - onClick={onCancel} + ) : ( + <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={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 + href={routeCancel.url({})} + class="text-sm font-semibold leading-6 text-gray-900" + > + <i18n.Translate>Cancel</i18n.Translate> + </a> + ) : ( + <div /> + )} + <button + type="submit" + 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>Cancel</i18n.Translate> + <i18n.Translate>Send</i18n.Translate> </button> - : <div /> - } - <button type="submit" - 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 > - ) - + </div> + <LocalNotificationBanner notification={notification} /> + </form> + </div> + ); } /** * Show the element when the load ended - * @param element + * @param element */ export function doAutoFocus(element: HTMLElement | null) { if (element) { setTimeout(() => { - element.focus({ preventScroll: true }) + element.focus({ preventScroll: true }); element.scrollIntoView({ behavior: "smooth", block: "center", inline: "center", - }) - }, 100) + }); + }, 100); } } @@ -452,26 +506,25 @@ export function InputAmount( error?: string; currency: string; name: string; - left?: boolean | undefined, + left?: boolean | undefined; value: string | undefined; onChange?: (s: string) => void; }, ref: Ref<HTMLInputElement>, ): VNode { - const { config } = useBankCoreApiContext() + 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" - > + <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" + placeholder="0.00" + aria-describedby="price-currency" ref={ref} name={name} id={name} @@ -480,10 +533,19 @@ export function InputAmount( 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) + 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); }} @@ -494,13 +556,34 @@ export function InputAmount( ); } -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 +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) + 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> -}
\ No newline at end of file + 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> + ); +} diff --git a/packages/demobank-ui/src/pages/ProfileNavigation.tsx b/packages/demobank-ui/src/pages/ProfileNavigation.tsx index bd9883b1b..a6615d578 100644 --- a/packages/demobank-ui/src/pages/ProfileNavigation.tsx +++ b/packages/demobank-ui/src/pages/ProfileNavigation.tsx @@ -1,72 +1,158 @@ +/* + 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 { VNode, h } from "preact"; +import { privatePages } from "../Routing.js"; import { useBankCoreApiContext } from "../context/config.js"; -import { assertUnreachable } from "./WithdrawalOperationPage.js"; import { useBackendState } from "../hooks/backend.js"; +import { assertUnreachable } from "@gnu-taler/taler-util"; -export function ProfileNavigation({ current }: { current: "details" | "delete" | "credentials" | "cashouts" }): VNode { - const { i18n } = useTranslationContext() - const { config } = useBankCoreApiContext() +export function ProfileNavigation({ + current, +}: { + current: "details" | "delete" | "credentials" | "cashouts"; +}): VNode { + const { i18n } = useTranslationContext(); + const { config } = useBankCoreApiContext(); const { state: credentials } = useBackendState(); - const nonAdminUser = credentials.status !== "loggedIn" ? false : !credentials.isUserAdministrator - 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": { - window.location.href = "#/my-profile"; - return; - } - case "delete": { - window.location.href = "#/delete-my-account"; - return; - } - case "credentials": { - window.location.href = "#/my-password"; - return; - } - case "cashouts": { - window.location.href = "#/my-cashouts"; - 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 ? - <option value="cashouts" selected={current == "cashouts"}><i18n.Translate>Cashouts</i18n.Translate></option> - : undefined} - </select> - </div> - <div class="hidden sm:block"> - <nav class="isolate flex divide-x divide-gray-200 rounded-lg shadow" aria-label="Tabs"> - <a href="#/my-profile" 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 href="#/delete-my-account" 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> + const nonAdminUser = + credentials.status !== "loggedIn" + ? false + : !credentials.isUserAdministrator; + 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": { + window.location.href = privatePages.myAccountDetails.url({}); + return; + } + case "delete": { + window.location.href = privatePages.myAccountDelete.url({}); + return; + } + case "credentials": { + window.location.href = privatePages.myAccountPassword.url({}); + return; + } + case "cashouts": { + window.location.href = privatePages.myAccountCashouts.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 ? ( + <option value="cashouts" selected={current == "cashouts"}> + <i18n.Translate>Cashouts</i18n.Translate> + </option> + ) : undefined} + </select> + </div> + <div class="hidden sm:block"> + <nav + class="isolate flex divide-x divide-gray-200 rounded-lg shadow" + aria-label="Tabs" + > + <a + href={privatePages.myAccountDetails.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> - } - <a href="#/my-password" 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 href="#/my-cashouts" 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> + {!config.allow_deletions ? undefined : ( + <a + href={privatePages.myAccountDelete.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 + href={privatePages.myAccountPassword.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> - : undefined} - </nav> + {config.allow_conversion && nonAdminUser ? ( + <a + href={privatePages.myAccountCashouts.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} + </nav> + </div> </div> - </div> -}
\ No newline at end of file + ); +} diff --git a/packages/demobank-ui/src/pages/PublicHistoriesPage.tsx b/packages/demobank-ui/src/pages/PublicHistoriesPage.tsx index b3e18a62e..0f951b1a8 100644 --- a/packages/demobank-ui/src/pages/PublicHistoriesPage.tsx +++ b/packages/demobank-ui/src/pages/PublicHistoriesPage.tsx @@ -14,37 +14,35 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ -import { Logger, TalerError } from "@gnu-taler/taler-util"; -import { useTranslationContext } from "@gnu-taler/web-util/browser"; +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 { Loading } from "@gnu-taler/web-util/browser"; import { Transactions } from "../components/Transactions/index.js"; import { usePublicAccounts } from "../hooks/access.js"; -const logger = new Logger("PublicHistoriesPage"); - -interface Props { } - -/** +/** * Show histories of public accounts. */ -export function PublicHistoriesPage({ }: Props): VNode { +export function PublicHistoriesPage(): VNode { const { i18n } = useTranslationContext(); - //TODO: implemented filter by account name + // 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 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 /> + return <Loading />; } if (result instanceof TalerError) { - return <Loading /> + return <Loading />; } const { data } = result; @@ -54,7 +52,6 @@ export function PublicHistoriesPage({ }: Props): VNode { // Ask story of all the public accounts. for (const account of data.public_accounts) { - logger.trace("Asking transactions for", account.username); const isSelected = account.username == showAccount; accountsBar.push( <li @@ -89,9 +86,6 @@ export function PublicHistoriesPage({ }: Props): VNode { <p>No public transactions found.</p> )} <br /> - <a href="/account" class="pure-button"> - Go back - </a> </div> </article> </section> diff --git a/packages/demobank-ui/src/pages/QrCodeSection.tsx b/packages/demobank-ui/src/pages/QrCodeSection.tsx index 14a1c410d..f21134aa1 100644 --- a/packages/demobank-ui/src/pages/QrCodeSection.tsx +++ b/packages/demobank-ui/src/pages/QrCodeSection.tsx @@ -15,22 +15,21 @@ */ import { + assertUnreachable, HttpStatusCode, stringifyWithdrawUri, TranslatedString, - WithdrawUriResult + WithdrawUriResult, } from "@gnu-taler/taler-util"; import { + LocalNotificationBanner, useLocalNotification, - useTranslationContext + 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 { withRuntimeErrorHandling } from "../utils.js"; -import { assertUnreachable } from "./WithdrawalOperationPage.js"; -import { LocalNotificationBanner } from "@gnu-taler/web-util/browser"; import { useBackendState } from "../hooks/backend.js"; export function QrCodeSection({ @@ -43,54 +42,63 @@ export function QrCodeSection({ const { i18n } = useTranslationContext(); const talerWithdrawUri = stringifyWithdrawUri(withdrawUri); const { state: credentials } = useBackendState(); - const creds = credentials.status !== "loggedIn" ? undefined : credentials + const creds = credentials.status !== "loggedIn" ? undefined : credentials; useEffect(() => { - //Taler Wallet WebExtension is listening to headers response and tab updates. - //In the SPA there is no header response with the Taler URI so - //this hack manually triggers the tab update after the QR is in the DOM. + // Taler Wallet WebExtension is listening to headers response and tab updates. + // In the SPA there is no header response with the Taler URI so + // this hack manually triggers the tab update after the QR is in the DOM. // WebExtension will be using // https://developer.chrome.com/docs/extensions/reference/tabs/#event-onUpdated document.title = `${document.title} ${withdrawUri.withdrawalOperationId}`; - const meta = document.createElement("meta") - meta.setAttribute("name", "taler-uri") - meta.setAttribute("content", talerWithdrawUri) - document.head.insertBefore(meta, document.head.children.length ? document.head.children[0] : null) + const meta = document.createElement("meta"); + meta.setAttribute("name", "taler-uri"); + meta.setAttribute("content", talerWithdrawUri); + document.head.insertBefore( + meta, + document.head.children.length ? document.head.children[0] : null, + ); }, []); - const [notification, notify, handleError] = useLocalNotification() + const [notification, notify, handleError] = useLocalNotification(); - const { api } = useBankCoreApiContext() + const { api } = useBankCoreApiContext(); async function doAbort() { await handleError(async () => { if (!creds) return; - const resp = await api.abortWithdrawalById(creds, withdrawUri.withdrawalOperationId); + 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, - }) + 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) + assertUnreachable(resp); } } } - }) + }); } return ( @@ -99,22 +107,37 @@ export function QrCodeSection({ <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> + <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" href="https://taler.net/en/wallet.html"><i18n.Translate>this page</i18n.Translate></a>.</p> + <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" + 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" + <button + type="button" // class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md px-3 py-2 text-sm font-semibold text-black shadow-sm hover:bg-red-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-600" class="text-sm font-semibold leading-6 text-gray-900" onClick={doAbort} > - Cancel + <i18n.Translate>Cancel</i18n.Translate> </button> - <a href={talerWithdrawUri} + <a + href={talerWithdrawUri} 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> @@ -126,28 +149,30 @@ export function QrCodeSection({ <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 wallet in another device</i18n.Translate> + <i18n.Translate> + Or if you have the 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> + <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" + <button + type="button" // class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md px-3 py-2 text-sm font-semibold text-black shadow-sm hover:bg-red-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-600" class="text-sm font-semibold leading-6 text-gray-900" onClick={doAbort} > - Cancel + <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 index b3a49a178..931a9b700 100644 --- a/packages/demobank-ui/src/pages/RegistrationPage.tsx +++ b/packages/demobank-ui/src/pages/RegistrationPage.tsx @@ -13,29 +13,33 @@ 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, Logger, TalerErrorCode, TranslatedString } from "@gnu-taler/taler-util"; +import { + AccessToken, + HttpStatusCode, + TalerErrorCode, + TranslatedString, + assertUnreachable, +} from "@gnu-taler/taler-util"; import { LocalNotificationBanner, ShowInputErrorLabel, useLocalNotification, - useTranslationContext + 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 { useBackendState } from "../hooks/backend.js"; +import { useSettingsContext } from "../context/settings.js"; +import { RouteDefinition } from "../route.js"; import { undefinedIfEmpty } from "../utils.js"; import { getRandomPassword, getRandomUsername } from "./rnd.js"; -import { useSettingsContext } from "../context/settings.js"; - -const logger = new Logger("RegistrationPage"); export function RegistrationPage({ - onComplete, - onCancel + onRegistrationSuccesful, + routeCancel, }: { - onComplete: () => void; - onCancel: () => void; + onRegistrationSuccesful: (user: string, password: string) => void; + routeCancel: RouteDefinition<Record<string, never>>; }): VNode { const { i18n } = useTranslationContext(); const { config } = useBankCoreApiContext(); @@ -44,50 +48,58 @@ export function RegistrationPage({ <p>{i18n.str`Currently, the bank is not accepting new registrations!`}</p> ); } - return <RegistrationForm onComplete={onComplete} onCancel={onCancel} />; + 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}$/; +export const EMAIL_REGEX = /^[\w-.]+@([\w-]+\.)+[\w-]{2,4}$/; -/** +/** * Collect and submit registration data. */ -function RegistrationForm({ onComplete, onCancel }: { onComplete: () => void, onCancel: () => void }): VNode { - const backend = useBackendState(); +function RegistrationForm({ + onRegistrationSuccesful, + routeCancel, +}: { + onRegistrationSuccesful: (user: string, password: string) => void; + routeCancel: RouteDefinition<Record<string, never>>; +}): 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 [phone, setPhone] = useState<string | undefined>(); + // const [email, setEmail] = useState<string | undefined>(); const [repeatPassword, setRepeatPassword] = useState<string | undefined>(); - const [notification, notify, handleError] = useLocalNotification() + const [notification, notify, handleError] = useLocalNotification(); const settings = useSettingsContext(); - const { api } = useBankCoreApiContext() + const { api } = useBankCoreApiContext(); // const { register } = useTestingAPI(); const { i18n } = useTranslationContext(); const errors = undefinedIfEmpty({ - name: !name - ? i18n.str`Missing name` - : undefined, + 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, + // 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` @@ -96,106 +108,97 @@ function RegistrationForm({ onComplete, onCancel }: { onComplete: () => void, on : undefined, }); - async function doRegistrationAndLogin(name: string, username: string, password: string, onComplete: () => void) { + async function doRegistrationAndLogin( + name: string, + username: string, + password: string, + onComplete: () => void, + ) { await handleError(async () => { - createAccount: { - const resp = await api.createAccount("" as AccessToken, { name, username, password }); - if (resp.type === "fail") { - switch (resp.case) { - case HttpStatusCode.BadRequest: return notify({ + const resp = await api.createAccount("" as AccessToken, { + name, + username, + password, + }); + if (resp.type === "ok") { + onComplete(); + } else { + switch (resp.case) { + case HttpStatusCode.BadRequest: + return notify({ type: "error", title: i18n.str`Server replied with invalid phone or email.`, description: resp.detail.hint as TranslatedString, debug: resp.detail, - }) - case TalerErrorCode.BANK_UNALLOWED_DEBIT: return notify({ + }); + case TalerErrorCode.BANK_UNALLOWED_DEBIT: + return notify({ type: "error", title: i18n.str`Registration is disabled because the bank ran out of bonus credit.`, description: resp.detail.hint as TranslatedString, debug: resp.detail, - }) - case HttpStatusCode.Unauthorized: return notify({ + }); + case HttpStatusCode.Unauthorized: + return notify({ type: "error", title: i18n.str`No enough permission to create that account.`, description: resp.detail.hint as TranslatedString, debug: resp.detail, - }) - case TalerErrorCode.BANK_REGISTER_PAYTO_URI_REUSE: return notify({ + }); + case TalerErrorCode.BANK_REGISTER_PAYTO_URI_REUSE: + return notify({ type: "error", title: i18n.str`That account id is already taken.`, description: resp.detail.hint as TranslatedString, debug: resp.detail, - }) - case TalerErrorCode.BANK_REGISTER_USERNAME_REUSE: return notify({ + }); + case TalerErrorCode.BANK_REGISTER_USERNAME_REUSE: + return notify({ type: "error", title: i18n.str`That username is already taken.`, description: resp.detail.hint as TranslatedString, debug: resp.detail, - }) - case TalerErrorCode.BANK_RESERVED_USERNAME_CONFLICT: return notify({ + }); + case TalerErrorCode.BANK_RESERVED_USERNAME_CONFLICT: + return notify({ type: "error", title: i18n.str`That 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({ + }); + 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({ + }); + 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({ + }); + 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({ + }); + 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) - } - } - } - login: { - const resp = await api.getAuthenticationAPI(username).createAccessToken(password, { - scope: "readwrite", - duration: { d_us: "forever" }, - refreshable: true, - }) - - if (resp.type === "ok") { - backend.logIn({ username, token: resp.body.access_token }); - } else { - switch (resp.case) { - case HttpStatusCode.Unauthorized: return notify({ - type: "error", - title: i18n.str`Wrong credentials for "${username}"`, - description: resp.detail.hint as TranslatedString, - debug: resp.detail, - }) - case HttpStatusCode.NotFound: return notify({ - type: "error", - title: i18n.str`Account not found`, - description: resp.detail.hint as TranslatedString, - debug: resp.detail, - }) - default: assertUnreachable(resp) - } + }); + default: + assertUnreachable(resp); } - } - onComplete() - }) + }); } async function doRegistrationStep() { @@ -204,18 +207,21 @@ function RegistrationForm({ onComplete, onCancel }: { onComplete: () => void, on setUsername(undefined); setPassword(undefined); setRepeatPassword(undefined); - onComplete(); - }) + onRegistrationSuccesful(username, password); + }); } - async function doRandomRegistration(tries: number = 3) { + async function doRandomRegistration() { const user = getRandomUsername(); - const pass = settings.simplePasswordForRandomAccounts ? "123" : getRandomPassword(); - const username = `_${user.first}-${user.second}_` - const name = `${user.first}, ${user.second}` - await doRegistrationAndLogin(name, username, pass, onComplete) - + const password = settings.simplePasswordForRandomAccounts + ? "123" + : getRandomPassword(); + const username = `_${user.first}-${user.second}_`; + const name = `${user.first}, ${user.second}`; + await doRegistrationAndLogin(name, username, password, () => { + onRegistrationSuccesful(username, password); + }); } return ( @@ -228,7 +234,9 @@ function RegistrationForm({ onComplete, onCancel }: { onComplete: () => void, on </div> <div class="mt-10 sm:mx-auto sm:w-full sm:max-w-sm"> - <form class="space-y-6" noValidate + <form + class="space-y-6" + noValidate onSubmit={(e) => { e.preventDefault(); }} @@ -236,7 +244,10 @@ function RegistrationForm({ onComplete, onCancel }: { onComplete: () => void, on autoCorrect="off" > <div> - <label for="username" class="block text-sm font-medium leading-6 text-gray-900"> + <label + for="username" + class="block text-sm font-medium leading-6 text-gray-900" + > <i18n.Translate>Username</i18n.Translate> <b style={{ color: "red" }}> *</b> </label> @@ -265,7 +276,10 @@ function RegistrationForm({ onComplete, onCancel }: { onComplete: () => void, on <div> <div class="flex items-center justify-between"> - <label for="password" class="block text-sm font-medium leading-6 text-gray-900"> + <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> @@ -294,7 +308,10 @@ function RegistrationForm({ onComplete, onCancel }: { onComplete: () => void, on <div> <div class="flex items-center justify-between"> - <label for="register-repeat" class="block text-sm font-medium leading-6 text-gray-900"> + <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> @@ -323,7 +340,10 @@ function RegistrationForm({ onComplete, onCancel }: { onComplete: () => void, on <div> <div class="flex items-center justify-between"> - <label for="name" class="block text-sm font-medium leading-6 text-gray-900"> + <label + for="name" + class="block text-sm font-medium leading-6 text-gray-900" + > <i18n.Translate>Name</i18n.Translate> <b style={{ color: "red" }}> *</b> </label> @@ -403,50 +423,43 @@ function RegistrationForm({ onComplete, onCancel }: { onComplete: () => void, on </div> */} <div class="flex w-full justify-between"> - <button type="submit" + <a + 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" - onClick={(e) => { - e.preventDefault() - onCancel() - }} > <i18n.Translate>Cancel</i18n.Translate> - </button> - <button type="submit" + </a> + <button + type="submit" 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() + e.preventDefault(); - doRegistrationStep() + doRegistrationStep(); }} > <i18n.Translate>Register</i18n.Translate> </button> </div> - </form> - {settings.allowRandomAccountCreation && + {settings.allowRandomAccountCreation && ( <p class="mt-10 text-center text-sm text-gray-500 border-t"> - <button type="submit" + <button + type="submit" 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() + e.preventDefault(); + doRandomRegistration(); }} > <i18n.Translate>Create a random temporary user</i18n.Translate> </button> </p> - } + )} </div> </div> - </Fragment> ); } - -export function assertUnreachable(x: never): never { - throw new Error("Didn't expect to get here"); -} diff --git a/packages/demobank-ui/src/pages/SolveChallengePage.tsx b/packages/demobank-ui/src/pages/SolveChallengePage.tsx index 095a0f492..6d2d6512e 100644 --- a/packages/demobank-ui/src/pages/SolveChallengePage.tsx +++ b/packages/demobank-ui/src/pages/SolveChallengePage.tsx @@ -18,13 +18,12 @@ import { AbsoluteTime, Amounts, HttpStatusCode, - Logger, TalerCorebankApi, TalerError, TalerErrorCode, TranslatedString, assertUnreachable, - parsePaytoUri + parsePaytoUri, } from "@gnu-taler/taler-util"; import { Attention, @@ -32,7 +31,7 @@ import { LocalNotificationBanner, ShowInputErrorLabel, useLocalNotification, - useTranslationContext + useTranslationContext, } from "@gnu-taler/web-util/browser"; import { format } from "date-fns"; import { Fragment, VNode, h } from "preact"; @@ -43,40 +42,41 @@ import { useWithdrawalDetails } from "../hooks/access.js"; import { useBackendState } from "../hooks/backend.js"; import { ChallengeInProgess, useBankState } from "../hooks/bank-state.js"; import { useConversionInfo } from "../hooks/circuit.js"; +import { RouteDefinition } from "../route.js"; import { undefinedIfEmpty } from "../utils.js"; import { RenderAmount } from "./PaytoWireTransferForm.js"; import { OperationNotFound } from "./WithdrawalQRCode.js"; -const logger = new Logger("SolveChallenge"); - export function SolveChallengePage({ - onContinue, + onChallengeCompleted, + routeClose, }: { - onContinue: () => void; + onChallengeCompleted: () => void; + routeClose: RouteDefinition<Record<string, never>>; }): VNode { - const { api } = useBankCoreApiContext() + const { api } = useBankCoreApiContext(); const { i18n } = useTranslationContext(); const [bankState, updateBankState] = useBankState(); const [code, setCode] = useState<string | undefined>(undefined); - const [notification, notify, handleError] = useLocalNotification() + const [notification, notify, handleError] = useLocalNotification(); const { state } = useBackendState(); - const creds = state.status !== "loggedIn" ? undefined : state + const creds = state.status !== "loggedIn" ? undefined : state; if (!bankState.currentChallenge) { - return <div> - <span>no challenge to solve </span> - <button type="button" - 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={() => { - onContinue() - }} - > - <i18n.Translate>Continue</i18n.Translate> - </button> - </div> + return ( + <div> + <span>no challenge to solve </span> + <a + href={routeClose.url({})} + 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 ch = bankState.currentChallenge; const errors = undefinedIfEmpty({ code: !code ? i18n.str`required` : undefined, }); @@ -86,34 +86,38 @@ export function SolveChallengePage({ 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) + const newCh = structuredClone(ch); + newCh.sent = AbsoluteTime.now(); + newCh.info = resp.body; + updateBankState("currentChallenge", newCh); } else { 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) + 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() { @@ -121,55 +125,68 @@ export function SolveChallengePage({ await handleError(async () => { { const resp = await api.confirmChallenge(creds, ch.id, { - tan: code + tan: code, }); if (resp.type === "fail") { - setCode("") + 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 attemps, 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) + 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 attemps, 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) + 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); @@ -180,36 +197,43 @@ export function SolveChallengePage({ title: i18n.str`The operation failed.`, description: resp.detail.hint as TranslatedString, debug: resp.detail, - }) + }); } - // another challenge required + // 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), sent: AbsoluteTime.never(), - request: ch.request as any, - }) + request: ch.request, + }); return notify({ type: "info", title: i18n.str`The operation needs another confirmation to complete.`, - }) + }); } - updateBankState("currentChallenge", undefined) - return onContinue() + 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` + 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) + })(ch.operation); return ( <Fragment> @@ -217,25 +241,29 @@ export function SolveChallengePage({ <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"> + <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> + <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} /> - {ch.info && + <ChallengeDetails + challenge={bankState.currentChallenge} + onStart={startChallenge} + /> + {ch.info && ( <div class="mt-3 text-sm leading-6"> <form class="bg-white shadow-sm ring-1 ring-gray-900/5" autoCapitalize="none" autoCorrect="off" - onSubmit={e => { - e.preventDefault() + onSubmit={(e) => { + e.preventDefault(); }} > <div class="px-4 py-6 sm:p-8"> @@ -252,314 +280,408 @@ export function SolveChallengePage({ 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) + setCode(e.currentTarget.value); }} /> </div> - <ShowInputErrorLabel message={errors?.code} isDirty={code !== undefined} /> + <ShowInputErrorLabel + message={errors?.code} + isDirty={code !== undefined} + /> </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" + <a + href={routeClose.url({})} 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={() => { - updateBankState("currentChallenge", undefined) - onContinue() - }} > <i18n.Translate>Cancel</i18n.Translate> - </button> - <button type="submit" + </a> + <button + type="submit" 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() + completeChallenge(); + e.preventDefault(); }} > <i18n.Translate>Confirm</i18n.Translate> </button> </div> </form> - - {/* <ShouldBeSameUser username={details.username}> */} - {/* </ShouldBeSameUser> */} </div> - } + )} </div> </div> </Fragment> - ); } -function ChallengeDetails({ challenge, onStart }: { challenge: ChallengeInProgess, onStart: () => void }): VNode { +function ChallengeDetails({ + challenge, + onStart, +}: { + challenge: ChallengeInProgess; + onStart: () => void; +}): VNode { const { i18n, dateLocale } = useTranslationContext(); const { config } = useBankCoreApiContext(); - return <div class="px-4 mt-4 "> - <div class="w-full"> - <div class="flex justify-center"> - - {challenge.info ? - <button type="submit" - 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() - }} - > - <i18n.Translate>Send again</i18n.Translate> - </button> - : - <button type="submit" - 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() - }} - > - <i18n.Translate>Send code</i18n.Translate> - </button> - } - </div> - <div class="mt-6 border-t 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">Account</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">Amount</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">To account</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">Cashout account</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">Email</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">Phone</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">Debit threshold</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">Is this account public?</dt> - <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0"> - {challenge.request.is_public ? "enable" : "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">Name</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 && + return ( + <div class="px-4 mt-4 "> + <div class="w-full"> + <div class="flex justify-center"> + {challenge.info ? ( + <button + type="submit" + 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> + ) : ( + <button + type="submit" + 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 code</i18n.Translate> + </button> + )} + </div> + <div class="mt-6 border-t 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">Authentication channel</dt> + <dt class="text-sm font-medium leading-6 text-gray-900"> + Account + </dt> <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0"> - {challenge.request.tan_channel} + {challenge.request} </dd> </div> - } - </Fragment> + ); + case "create-transaction": { + const payto = parsePaytoUri(challenge.request.payto_uri)!; + return ( + <Fragment> + {challenge.request.amount && ( + <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0"> + <dt class="text-sm font-medium leading-6 text-gray-900"> + Amount + </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"> + To account + </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"> + Cashout account + </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"> + Email + </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"> + Phone + </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"> + Debit threshold + </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"> + Is this account public? + </dt> + <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0"> + {challenge.request.is_public ? "enable" : "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"> + Name + </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"> + Authentication channel + </dt> + <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0"> + {challenge.request.tan_channel} + </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"> + New password + </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); } - 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">New password</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"> - {format(challenge.sent.t_ms, "dd/MM/yyyy HH:mm:ss", { locale: dateLocale })} - </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> + {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"> + {format(challenge.sent.t_ms, "dd/MM/yyyy HH:mm:ss", { + locale: dateLocale, + })} + </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> </div> - </div> + ); } function ShowWithdrawalDetails({ id }: { id: string }): VNode { - const { i18n } = useTranslationContext(); - const details = useWithdrawalDetails(id) + const details = useWithdrawalDetails(id); const { config } = useBankCoreApiContext(); if (!details) { - return <Loading /> + return <Loading />; } if (details instanceof TalerError) { - return <ErrorLoadingWithDebug error={details} /> + return <ErrorLoadingWithDebug error={details} />; } if (details.type === "fail") { switch (details.case) { case HttpStatusCode.BadRequest: - case HttpStatusCode.NotFound: return <OperationNotFound onClose={undefined} /> - default: assertUnreachable(details) + 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">Withdraw id</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 && + 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">To account</dt> + <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"> - {details.body.selected_exchange_account} + <RenderAmount + value={Amounts.parseOrThrow(details.body.amount)} + spec={config.currency_specification} + /> </dd> </div> - } - </Fragment> + {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"> + Withdraw id + </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"> + To account + </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 { +function ShowCashoutDetails({ + request, +}: { + request: TalerCorebankApi.CashoutRequest; +}): VNode { const { i18n } = useTranslationContext(); const info = useConversionInfo(); if (!info) { - return <Loading /> + return <Loading />; } if (info instanceof TalerError) { - return <ErrorLoadingWithDebug error={info} /> + return <ErrorLoadingWithDebug error={info} />; } if (info.type === "fail") { switch (info.case) { case HttpStatusCode.NotImplemented: { - return <Attention type="danger" title={i18n.str`Cashout not implemented`}> - </Attention>; + return ( + <Attention + type="danger" + title={i18n.str`Cashout not implemented`} + ></Attention> + ); } - default: assertUnreachable(info.case) + default: + assertUnreachable(info.case); } } - - return <Fragment> - {request.subject !== undefined && + 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">Subject</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">Subject</dt> + <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"> - {request.subject} + <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">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> -}
\ No newline at end of file + <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 index c3d1c3f7e..1e48b818a 100644 --- a/packages/demobank-ui/src/pages/WalletWithdrawForm.tsx +++ b/packages/demobank-ui/src/pages/WalletWithdrawForm.tsx @@ -18,59 +18,73 @@ import { AmountJson, Amounts, HttpStatusCode, - Logger, TranslatedString, - parseWithdrawUri + assertUnreachable, + parseWithdrawUri, } from "@gnu-taler/taler-util"; import { + Attention, + LocalNotificationBanner, notifyError, useLocalNotification, - useTranslationContext + useTranslationContext, } from "@gnu-taler/web-util/browser"; -import { Fragment, VNode, h } from "preact"; +import { VNode, h } from "preact"; import { forwardRef } from "preact/compat"; import { useState } from "preact/hooks"; -import { Attention } from "@gnu-taler/web-util/browser"; +import { privatePages } from "../Routing.js"; import { useBankCoreApiContext } from "../context/config.js"; import { useBackendState } from "../hooks/backend.js"; +import { useBankState } from "../hooks/bank-state.js"; import { usePreferences } from "../hooks/preferences.js"; -import { undefinedIfEmpty, withRuntimeErrorHandling } from "../utils.js"; +import { RouteDefinition } from "../route.js"; +import { undefinedIfEmpty } from "../utils.js"; import { OperationState } from "./OperationState/index.js"; import { InputAmount, doAutoFocus } from "./PaytoWireTransferForm.js"; -import { assertUnreachable } from "./WithdrawalOperationPage.js"; -import { LocalNotificationBanner } from "@gnu-taler/web-util/browser"; -import { useBankState } from "../hooks/bank-state.js"; -const logger = new Logger("WalletWithdrawForm"); const RefAmount = forwardRef(InputAmount); - -function OldWithdrawalForm({ goToConfirmOperation, limit, onCancel, focus }: { +function OldWithdrawalForm({ + onOperationCreated, + limit, + routeCancel, + focus, +}: { limit: AmountJson; focus?: boolean; - goToConfirmOperation: (operationId: string) => void; - onCancel: () => void; + onOperationCreated: (wopid: string) => void; + routeCancel: RouteDefinition<Record<string, never>>; }): VNode { const { i18n } = useTranslationContext(); - const [settings] = usePreferences() + const [settings] = usePreferences(); const [bankState, updateBankState] = useBankState(); const { state: credentials } = useBackendState(); - const creds = credentials.status !== "loggedIn" ? undefined : credentials + const creds = credentials.status !== "loggedIn" ? undefined : credentials; - const { api } = useBankCoreApiContext() - const [amountStr, setAmountStr] = useState<string | undefined>(`${settings.maxWithdrawalAmount}`); - const [notification, notify, handleError] = useLocalNotification() - - if (!!bankState.currentWithdrawalOperationId) { - return <Attention type="warning" title={i18n.str`There is an operation already`}> - <span ref={focus ? doAutoFocus : undefined} /> - <i18n.Translate> - Complete or cancel the operation in</i18n.Translate> <a class="font-semibold text-yellow-700 hover:text-yellow-600" href={`#/operation/${bankState.currentWithdrawalOperationId}`}> - <i18n.Translate>this page</i18n.Translate> - </a> + const { api } = useBankCoreApiContext(); + const [amountStr, setAmountStr] = useState<string | undefined>( + `${settings.maxWithdrawalAmount}`, + ); + const [notification, notify, handleError] = useLocalNotification(); - </Attention> + if (bankState.currentWithdrawalOperationId) { + return ( + <Attention type="warning" title={i18n.str`There is an operation already`}> + <span ref={focus ? doAutoFocus : undefined} /> + <i18n.Translate> + Complete or cancel the operation in + </i18n.Translate>{" "} + <a + class="font-semibold text-yellow-700 hover:text-yellow-600" + href={privatePages.operationDetails.url({ + wopid: bankState.currentWithdrawalOperationId, + })} + > + <i18n.Translate>this page</i18n.Translate> + </a> + </Attention> + ); } const trimmedAmountStr = amountStr?.trim(); @@ -101,10 +115,14 @@ function OldWithdrawalForm({ goToConfirmOperation, limit, onCancel, focus }: { if (!uri) { return notifyError( i18n.str`Server responded with an invalid withdraw URI`, - i18n.str`Withdraw URI: ${resp.body.taler_withdraw_uri}`); + i18n.str`Withdraw URI: ${resp.body.taler_withdraw_uri}`, + ); } else { - updateBankState("currentWithdrawalOperationId", uri.withdrawalOperationId) - goToConfirmOperation(uri.withdrawalOperationId); + updateBankState( + "currentWithdrawalOperationId", + uri.withdrawalOperationId, + ); + onOperationCreated(uri.withdrawalOperationId); } } else { switch (resp.case) { @@ -114,7 +132,7 @@ function OldWithdrawalForm({ goToConfirmOperation, limit, onCancel, focus }: { title: i18n.str`The operation was rejected due to insufficient funds`, description: resp.detail.hint as TranslatedString, debug: resp.detail, - }) + }); break; } case HttpStatusCode.Unauthorized: { @@ -123,7 +141,7 @@ function OldWithdrawalForm({ goToConfirmOperation, limit, onCancel, focus }: { title: i18n.str`The operation was rejected due to insufficient funds`, description: resp.detail.hint as TranslatedString, debug: resp.detail, - }) + }); break; } case HttpStatusCode.NotFound: { @@ -132,159 +150,184 @@ function OldWithdrawalForm({ goToConfirmOperation, limit, onCancel, focus }: { title: i18n.str`Account not found`, description: resp.detail.hint as TranslatedString, debug: resp.detail, - }) + }); break; } - default: assertUnreachable(resp) + 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} /> + 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 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> - <div class="mt-4"> - <div class="sm:inline"> - - <button type="button" - 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" - 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" - 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" - 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 class="mt-4"> + <div class="sm:inline"> + <button + type="button" + 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" + 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" + 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" + 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> - <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" class="text-sm font-semibold leading-6 text-gray-900" - onClick={onCancel} - > - <i18n.Translate>Cancel</i18n.Translate></button> - <button type="submit" - 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> + <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({})} + class="text-sm font-semibold leading-6 text-gray-900" + > + <i18n.Translate>Cancel</i18n.Translate> + </a> + <button + type="submit" + 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, - onCancel, + routeCancel, onAuthorizationRequired, - goToConfirmOperation, + onOperationCreated, + onOperationAborted, }: { limit: AmountJson; focus?: boolean; - onAuthorizationRequired: () => void, - goToConfirmOperation: (operationId: string) => void; - onCancel: () => void; + onAuthorizationRequired: () => void; + onOperationCreated: (wopid: string) => void; + onOperationAborted: () => void; + routeCancel: RouteDefinition<Record<string, never>>; }): VNode { const { i18n } = useTranslationContext(); - const [settings, updateSettings] = usePreferences() + 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 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 GNU Taler Wallet`} onClose={() => { - updateSettings("showInstallWallet", false); - }}> + 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 wallet</i18n.Translate> + </h2> + <p class="mt-1 text-sm text-gray-500"> <i18n.Translate> - If you don't have one yet you can follow the instruction in</i18n.Translate> <a target="_blank" 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> - } + After using your wallet you will need to confirm or cancel the + operation on this site. + </i18n.Translate> + </p> + </div> - {!settings.fastWithdrawal ? - <OldWithdrawalForm - focus={focus} - limit={limit} - onCancel={onCancel} - goToConfirmOperation={goToConfirmOperation} - /> - : - <OperationState - currency={limit.currency} - onAuthorizationRequired={onAuthorizationRequired} - onClose={onCancel} - /> - } + <div class="col-span-2"> + {settings.showInstallWallet && ( + <Attention + title={i18n.str`You need a GNU 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" + 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} + limit={limit} + routeCancel={routeCancel} + onOperationCreated={onOperationCreated} + /> + ) : ( + <OperationState + currency={limit.currency} + onAuthorizationRequired={onAuthorizationRequired} + routeClose={routeCancel} + onAbort={onOperationAborted} + // route={routeCancel} + /> + )} + </div> </div> - </div> ); } - diff --git a/packages/demobank-ui/src/pages/WireTransfer.tsx b/packages/demobank-ui/src/pages/WireTransfer.tsx index 25d43a832..190afd66e 100644 --- a/packages/demobank-ui/src/pages/WireTransfer.tsx +++ b/packages/demobank-ui/src/pages/WireTransfer.tsx @@ -1,18 +1,47 @@ -import { Amounts, HttpStatusCode, TalerError } from "@gnu-taler/taler-util"; -import { Loading, notifyInfo, useTranslationContext } from "@gnu-taler/web-util/browser"; +/* + 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/access.js"; import { useBackendState } from "../hooks/backend.js"; import { LoginForm } from "./LoginForm.js"; import { PaytoWireTransferForm } from "./PaytoWireTransferForm.js"; -import { assertUnreachable } from "./WithdrawalOperationPage.js"; +import { RouteDefinition } from "../route.js"; -export function WireTransfer({ toAccount, onAuthorizationRequired, onCancel, onSuccess }: { +export function WireTransfer({ + toAccount, + onAuthorizationRequired, + routeCancel, + onSuccess, +}: { onSuccess?: () => void; - toAccount?: string, - onCancel?: () => void, - onAuthorizationRequired: () => void, + toAccount?: string; + routeCancel?: RouteDefinition<Record<string, never>>; + onAuthorizationRequired: () => void; }): VNode { const { i18n } = useTranslationContext(); const r = useBackendState(); @@ -20,16 +49,19 @@ export function WireTransfer({ toAccount, onAuthorizationRequired, onCancel, onS const result = useAccountDetails(account); if (!result) { - return <Loading /> + return <Loading />; } if (result instanceof TalerError) { - return <ErrorLoadingWithDebug error={result} /> + 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) + case HttpStatusCode.Unauthorized: + return <LoginForm currentUser={account} />; + case HttpStatusCode.NotFound: + return <LoginForm currentUser={account} />; + default: + assertUnreachable(result); } } const { body: data } = result; @@ -50,9 +82,9 @@ export function WireTransfer({ toAccount, onAuthorizationRequired, onCancel, onS onAuthorizationRequired={onAuthorizationRequired} onSuccess={() => { notifyInfo(i18n.str`Wire transfer created!`); - if (onSuccess) onSuccess() + if (onSuccess) onSuccess(); }} - onCancel={onCancel} + routeCancel={routeCancel} /> ); } diff --git a/packages/demobank-ui/src/pages/WithdrawalConfirmationQuestion.tsx b/packages/demobank-ui/src/pages/WithdrawalConfirmationQuestion.tsx index 34faed7ec..66c27ef4c 100644 --- a/packages/demobank-ui/src/pages/WithdrawalConfirmationQuestion.tsx +++ b/packages/demobank-ui/src/pages/WithdrawalConfirmationQuestion.tsx @@ -18,20 +18,20 @@ import { AbsoluteTime, AmountJson, HttpStatusCode, - Logger, PaytoUri, PaytoUriIBAN, PaytoUriTalerBank, TalerErrorCode, TranslatedString, - WithdrawUriResult + WithdrawUriResult, + assertUnreachable, } from "@gnu-taler/taler-util"; import { Attention, LocalNotificationBanner, notifyInfo, useLocalNotification, - useTranslationContext + useTranslationContext, } from "@gnu-taler/web-util/browser"; import { ComponentChildren, Fragment, VNode, h } from "preact"; import { mutate } from "swr"; @@ -41,20 +41,17 @@ import { useBankState } from "../hooks/bank-state.js"; import { usePreferences } from "../hooks/preferences.js"; import { LoginForm } from "./LoginForm.js"; import { RenderAmount } from "./PaytoWireTransferForm.js"; -import { assertUnreachable } from "./WithdrawalOperationPage.js"; - -const logger = new Logger("WithdrawalConfirmationQuestion"); interface Props { onAborted: () => void; withdrawUri: WithdrawUriResult; details: { - account: PaytoUri, - reserve: string, - username: string, - amount: AmountJson, - }, - onAuthorizationRequired: () => void, + account: PaytoUri; + reserve: string; + username: string; + amount: AmountJson; + }; + onAuthorizationRequired: () => void; } /** * Additional authentication required to complete the operation. @@ -67,101 +64,116 @@ export function WithdrawalConfirmationQuestion({ withdrawUri, }: Props): VNode { const { i18n } = useTranslationContext(); - const [settings] = usePreferences() + const [settings] = usePreferences(); const { state: credentials } = useBackendState(); - const creds = credentials.status !== "loggedIn" ? undefined : credentials - const [, updateBankState] = useBankState() + const creds = credentials.status !== "loggedIn" ? undefined : credentials; + const [, updateBankState] = useBankState(); - const [notification, notify, handleError] = useLocalNotification() + const [notification, notify, handleError] = useLocalNotification(); - const { config, api } = useBankCoreApiContext() + const { config, api } = useBankCoreApiContext(); async function doTransfer() { await handleError(async () => { if (!creds) return; - const resp = await api.confirmWithdrawalById(creds, withdrawUri.withdrawalOperationId); + const resp = await api.confirmWithdrawalById( + creds, + withdrawUri.withdrawalOperationId, + ); if (resp.type === "ok") { - mutate(() => true)// clean any info that we have + mutate(() => true); // clean any info that we have if (!settings.showWithdrawalSuccess) { - notifyInfo(i18n.str`Wire transfer completed!`) + 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 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), sent: AbsoluteTime.never(), request: withdrawUri.withdrawalOperationId, - }) - return onAuthorizationRequired() + }); + return onAuthorizationRequired(); } - default: assertUnreachable(resp) + default: + assertUnreachable(resp); } } - }) + }); } async function doCancel() { await handleError(async () => { if (!creds) return; - const resp = await api.abortWithdrawalById(creds, withdrawUri.withdrawalOperationId); + 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, - }) + 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) + assertUnreachable(resp); } } } - }) + }); } return ( @@ -174,74 +186,100 @@ export function WithdrawalConfirmationQuestion({ <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() + 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> + <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>Taler Exchange operator's account</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 && + 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>Taler Exchange operator's name</i18n.Translate> + <i18n.Translate> + Taler Exchange operator's account + </i18n.Translate> </dt> - <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0">{p.params["receiver-name"]}</dd> + <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0"> + {p.iban} + </dd> </div> - } - </Fragment> + {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> + Taler Exchange operator's name + </i18n.Translate> + </dt> + <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0"> + {p.params["receiver-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>Taler Exchange operator's account</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 && + 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>Taler Exchange operator's name</i18n.Translate> + <i18n.Translate> + Taler Exchange operator's account + </i18n.Translate> </dt> - <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0">{p.params["receiver-name"]}</dd> + <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0"> + {p.account} + </dd> </div> - } - </Fragment> + {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> + Taler Exchange operator's name + </i18n.Translate> + </dt> + <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0"> + {p.params["receiver-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>Taler Exchange operator'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> - + 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> + Taler Exchange operator'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"> @@ -249,58 +287,73 @@ export function WithdrawalConfirmationQuestion({ <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} /> + <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" class="text-sm font-semibold leading-6 text-gray-900" + <button + type="button" + class="text-sm font-semibold leading-6 text-gray-900" onClick={doCancel} > - <i18n.Translate>Cancel</i18n.Translate></button> - <button type="submit" + <i18n.Translate>Cancel</i18n.Translate> + </button> + <button + type="submit" 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() + e.preventDefault(); + doTransfer(); }} > <i18n.Translate>Transfer</i18n.Translate> </button> </div> - </form> </div> </ShouldBeSameUser> </div> </div> </div> - - </Fragment > + </Fragment> ); } -export function ShouldBeSameUser({ username, children }: { username: string, children: ComponentChildren }): VNode { +export function ShouldBeSameUser({ + username, + children, +}: { + username: string; + children: ComponentChildren; +}): VNode { const { state: credentials } = useBackendState(); - const { i18n } = useTranslationContext() + const { i18n } = useTranslationContext(); if (credentials.status === "loggedOut") { - return <Fragment> - <Attention type="info" title={i18n.str`Authentication required`} /> - <LoginForm currentUser={username} fixedUser /> - </Fragment> + 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> + <Attention + type="warning" + title={i18n.str`This operation was created with other username`} + /> + <LoginForm currentUser={username} fixedUser /> + </Fragment> + ); } - return <Fragment> - {children} - </Fragment> -}
\ No newline at end of file + return <Fragment>{children}</Fragment>; +} diff --git a/packages/demobank-ui/src/pages/WithdrawalOperationPage.tsx b/packages/demobank-ui/src/pages/WithdrawalOperationPage.tsx index e0e2bf0f5..e69a4dfb2 100644 --- a/packages/demobank-ui/src/pages/WithdrawalOperationPage.tsx +++ b/packages/demobank-ui/src/pages/WithdrawalOperationPage.tsx @@ -14,32 +14,26 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ -import { - Logger, - parseWithdrawUri, - stringifyWithdrawUri -} from "@gnu-taler/taler-util"; -import { - Attention, - useTranslationContext -} from "@gnu-taler/web-util/browser"; -import { Fragment, VNode, h } from "preact"; +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"; -const logger = new Logger("AccountPage"); - export function WithdrawalOperationPage({ operationId, onAuthorizationRequired, - onContinue, + onOperationAborted, + routeClose, }: { onAuthorizationRequired: () => void; operationId: string; - onContinue: () => void; + onOperationAborted: () => void; + routeClose: RouteDefinition<Record<string, never>>; }): VNode { - const { api } = useBankCoreApiContext() + const { api } = useBankCoreApiContext(); const uri = stringifyWithdrawUri({ bankIntegrationApiBaseUrl: api.getIntegrationAPI().baseUrl, withdrawalOperationId: operationId, @@ -48,25 +42,26 @@ export function WithdrawalOperationPage({ const { i18n } = useTranslationContext(); const [, updateBankState] = useBankState(); - if (!parsedUri) { - return <Attention type="danger" title={i18n.str`The Withdrawal URI is not valid`}> - {uri} - </Attention> + return ( + <Attention + type="danger" + title={i18n.str`The Withdrawal URI is not valid`} + > + {uri} + </Attention> + ); } return ( <WithdrawalQRCode withdrawUri={parsedUri} onAuthorizationRequired={onAuthorizationRequired} - onClose={() => { - updateBankState("currentWithdrawalOperationId", undefined) - onContinue() + onOperationAborted={() => { + updateBankState("currentWithdrawalOperationId", undefined); + onOperationAborted(); }} + routeClose={routeClose} /> ); } - -export function assertUnreachable(x: never): never { - throw new Error("Didn't expect to get here"); -} diff --git a/packages/demobank-ui/src/pages/WithdrawalQRCode.tsx b/packages/demobank-ui/src/pages/WithdrawalQRCode.tsx index 30c1fe998..3cf552f39 100644 --- a/packages/demobank-ui/src/pages/WithdrawalQRCode.tsx +++ b/packages/demobank-ui/src/pages/WithdrawalQRCode.tsx @@ -17,26 +17,29 @@ import { Amounts, HttpStatusCode, - Logger, TalerError, WithdrawUriResult, - parsePaytoUri + assertUnreachable, + parsePaytoUri, } from "@gnu-taler/taler-util"; -import { Attention, Loading, notifyInfo, useTranslationContext } from "@gnu-taler/web-util/browser"; -import { Fragment, VNode, h } from "preact"; +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/access.js"; +import { RouteDefinition } from "../route.js"; import { QrCodeSection } from "./QrCodeSection.js"; import { WithdrawalConfirmationQuestion } from "./WithdrawalConfirmationQuestion.js"; -import { assertUnreachable } from "./WithdrawalOperationPage.js"; - -const logger = new Logger("WithdrawalQRCode"); interface Props { withdrawUri: WithdrawUriResult; - onClose: () => void; - onAuthorizationRequired: () => void, - + onOperationAborted: () => void; + routeClose: RouteDefinition<Record<string, never>>; + onAuthorizationRequired: () => void; } /** * Offer the QR code (and a clickable taler://-link) to @@ -45,90 +48,107 @@ interface Props { */ export function WithdrawalQRCode({ withdrawUri, - onClose, + onOperationAborted, + routeClose, onAuthorizationRequired, }: Props): VNode { const { i18n } = useTranslationContext(); const result = useWithdrawalDetails(withdrawUri.withdrawalOperationId); if (!result) { - return <Loading /> + return <Loading />; } if (result instanceof TalerError) { - return <ErrorLoadingWithDebug error={result} /> + return <ErrorLoadingWithDebug error={result} />; } if (result.type === "fail") { switch (result.case) { case HttpStatusCode.BadRequest: - case HttpStatusCode.NotFound: return <OperationNotFound onClose={onClose} /> - default: assertUnreachable(result) + case HttpStatusCode.NotFound: + return <OperationNotFound routeClose={routeClose} />; + default: + assertUnreachable(result); } } const { body: data } = result; if (data.status === "aborted") { - return <section id="main" class="content"> - <h1 class="nav">{i18n.str`Operation aborted`}</h1> - <section> - <p> - <i18n.Translate> - The wire transfer to the Taler Exchange operator's account was aborted, your balance - was not affected. - </i18n.Translate> - </p> - <p> - <i18n.Translate> - You can close this page now or continue to the account page. - </i18n.Translate> - </p> - <a class="pure-button pure-button-primary" - style={{ float: "right" }} - onClick={async (e) => { - e.preventDefault(); - onClose() - }}> - {i18n.str`Continue`} - </a> - + return ( + <section id="main" class="content"> + <h1 class="nav">{i18n.str`Operation aborted`}</h1> + <section> + <p> + <i18n.Translate> + The wire transfer to the Taler Exchange operator's account was + aborted, your balance was not affected. + </i18n.Translate> + </p> + <p> + <i18n.Translate> + You can close this page now or continue to the account page. + </i18n.Translate> + </p> + <a + href={routeClose.url({})} + class="pure-button pure-button-primary" + style={{ float: "right" }} + > + <i18n.Translate>Continue</i18n.Translate> + </a> + </section> </section> - </section> + ); } 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> + 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({})} + 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> - <div class="mt-5 sm:mt-6"> - <button type="button" - class="inline-flex w-full justify-center rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" - onClick={async (e) => { - e.preventDefault(); - onClose() - }}> - <i18n.Translate>Done</i18n.Translate> - </button> - </div> - </div> - - + ); } if (data.status === "pending") { return ( @@ -136,33 +156,55 @@ export function WithdrawalQRCode({ withdrawUri={withdrawUri} onAborted={() => { notifyInfo(i18n.str`Operation canceled`); - onClose() + onOperationAborted(); }} /> ); } - const account = !data.selected_exchange_account ? undefined : parsePaytoUri(data.selected_exchange_account) + 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> + 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> + 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 ( + <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 ( @@ -172,53 +214,71 @@ export function WithdrawalQRCode({ username: data.username, account, reserve: data.selected_reserve_pub, - amount: Amounts.parseOrThrow(data.amount) + amount: Amounts.parseOrThrow(data.amount), }} onAuthorizationRequired={onAuthorizationRequired} onAborted={() => { notifyInfo(i18n.str`Operation canceled`); - onClose() + onOperationAborted(); }} /> ); } - -export function OperationNotFound({ onClose }: { onClose: (() => void) | undefined }): VNode { +export function OperationNotFound({ + routeClose, +}: { + routeClose: RouteDefinition<Record<string, never>> | 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> + 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 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({})} + 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> - {onClose && - <div class="mt-5 sm:mt-6"> - <button type="button" - class="inline-flex w-full justify-center rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" - onClick={async (e) => { - e.preventDefault(); - onClose() - }}> - <i18n.Translate>Cotinue to dashboard</i18n.Translate> - </button> - </div> - } - </div> -}
\ No newline at end of file + ); +} diff --git a/packages/demobank-ui/src/pages/account/CashoutListForAccount.tsx b/packages/demobank-ui/src/pages/account/CashoutListForAccount.tsx index d2f3ae83e..670bbaea0 100644 --- a/packages/demobank-ui/src/pages/account/CashoutListForAccount.tsx +++ b/packages/demobank-ui/src/pages/account/CashoutListForAccount.tsx @@ -1,40 +1,66 @@ +/* + 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 { useBackendState } from "../../hooks/backend.js"; import { ProfileNavigation } from "../ProfileNavigation.js"; import { CreateCashout } from "../business/CreateCashout.js"; +import { RouteDefinition } from "../../route.js"; interface Props { - account: string, - onClose: () => void, - onAuthorizationRequired: () => void, - onSelected: (cid: number) => void + account: string; + routeClose: RouteDefinition<Record<string, never>>; + onAuthorizationRequired: () => void; + routeCashoutDetails: RouteDefinition<{ cid: string }>; } -export function CashoutListForAccount({ account, onAuthorizationRequired, onSelected, onClose }: Props): VNode { +export function CashoutListForAccount({ + account, + onAuthorizationRequired, + routeCashoutDetails, + routeClose, +}: Props): VNode { const { i18n } = useTranslationContext(); const { state: credentials } = useBackendState(); - const accountIsTheCurrentUser = credentials.status === "loggedIn" ? - credentials.username === account : false - - return <Fragment> - {accountIsTheCurrentUser ? - <ProfileNavigation current="cashouts" /> - : - <h1 class="text-base font-semibold leading-6 text-gray-900"> - <i18n.Translate>Cashout for account {account}</i18n.Translate> - </h1> - } - - <CreateCashout focus onCancel={onClose} onAuthorizationRequired={onAuthorizationRequired} account={account} /> - - <Cashouts - account={account} - onSelected={onSelected} - /> - </Fragment> -} + const accountIsTheCurrentUser = + credentials.status === "loggedIn" + ? credentials.username === account + : false; + return ( + <Fragment> + {accountIsTheCurrentUser ? ( + <ProfileNavigation current="cashouts" /> + ) : ( + <h1 class="text-base font-semibold leading-6 text-gray-900"> + <i18n.Translate>Cashout for account {account}</i18n.Translate> + </h1> + )} + + <CreateCashout + focus + 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 index 0dfdb39f3..9f8fb72bc 100644 --- a/packages/demobank-ui/src/pages/account/ShowAccountDetails.tsx +++ b/packages/demobank-ui/src/pages/account/ShowAccountDetails.tsx @@ -1,112 +1,156 @@ -import { AbsoluteTime, HttpStatusCode, TalerCorebankApi, TalerError, TalerErrorCode, TranslatedString } from "@gnu-taler/taler-util"; -import { Loading, LocalNotificationBanner, notifyInfo, useLocalNotification, useTranslationContext } from "@gnu-taler/web-util/browser"; +/* + 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/access.js"; import { useBackendState } from "../../hooks/backend.js"; +import { useBankState } from "../../hooks/bank-state.js"; +import { RouteDefinition } from "../../route.js"; import { LoginForm } from "../LoginForm.js"; import { ProfileNavigation } from "../ProfileNavigation.js"; -import { assertUnreachable } from "../WithdrawalOperationPage.js"; import { AccountForm } from "../admin/AccountForm.js"; -import { useBankState } from "../../hooks/bank-state.js"; export function ShowAccountDetails({ account, - onClear, + routeClose, onUpdateSuccess, onAuthorizationRequired, }: { - onClear?: () => void; + routeClose: RouteDefinition<Record<string, never>>; onUpdateSuccess: () => void; - onAuthorizationRequired: () => void, + onAuthorizationRequired: () => void; account: string; }): VNode { const { i18n } = useTranslationContext(); const { state: credentials } = useBackendState(); - const creds = credentials.status !== "loggedIn" ? undefined : credentials - const { api } = useBankCoreApiContext() - const accountIsTheCurrentUser = credentials.status === "loggedIn" ? - credentials.username === account : false + const creds = credentials.status !== "loggedIn" ? undefined : credentials; + const { api } = useBankCoreApiContext(); + const accountIsTheCurrentUser = + credentials.status === "loggedIn" + ? credentials.username === account + : false; const [update, setUpdate] = useState(false); - const [submitAccount, setSubmitAccount] = useState<TalerCorebankApi.AccountReconfiguration | undefined>(); - const [notification, notify, handleError] = useLocalNotification() - const [, updateBankState] = useBankState() + const [submitAccount, setSubmitAccount] = useState< + TalerCorebankApi.AccountReconfiguration | undefined + >(); + const [notification, notify, handleError] = useLocalNotification(); + const [, updateBankState] = useBankState(); const result = useAccountDetails(account); if (!result) { - return <Loading /> + return <Loading />; } if (result instanceof TalerError) { - return <ErrorLoadingWithDebug error={result} /> + return <ErrorLoadingWithDebug error={result} />; } if (result.type === "fail") { switch (result.case) { case HttpStatusCode.Unauthorized: - case HttpStatusCode.NotFound: return <LoginForm currentUser={account} /> - default: assertUnreachable(result) + case HttpStatusCode.NotFound: + return <LoginForm currentUser={account} />; + default: + assertUnreachable(result); } } async function doUpdate() { if (!update || !submitAccount || !creds) return; await handleError(async () => { - const resp = await api.updateAccount({ - token: creds.token, - username: account, - }, submitAccount); + 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.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), sent: AbsoluteTime.never(), request: submitAccount, - }) - return onAuthorizationRequired() + }); + return onAuthorizationRequired(); } case TalerErrorCode.BANK_TAN_CHANNEL_NOT_SUPPORTED: { return notify({ @@ -116,39 +160,53 @@ export function ShowAccountDetails({ debug: resp.detail, }); } - default: assertUnreachable(resp) + default: + assertUnreachable(resp); } } - }) - + }); } return ( <Fragment> <LocalNotificationBanner notification={notification} showDebug={true} /> - {accountIsTheCurrentUser ? + {accountIsTheCurrentUser ? ( <ProfileNavigation current="details" /> - : + ) : ( <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"> + <span + class="text-sm text-black font-semibold leading-6 " + id="availability-label" + > <i18n.Translate>Change details</i18n.Translate> </span> </span> - <button type="button" data-enabled={!update} class="bg-indigo-600 data-[enabled=true]:bg-gray-200 relative inline-flex h-5 w-10 flex-shrink-0 cursor-pointer rounded-full ring-2 border-gray-600 transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-indigo-600 focus:ring-offset-2" role="switch" aria-checked="false" aria-labelledby="availability-label" aria-describedby="availability-description" + <button + type="button" + data-enabled={!update} + class="bg-indigo-600 data-[enabled=true]:bg-gray-200 relative inline-flex h-5 w-10 flex-shrink-0 cursor-pointer rounded-full ring-2 border-gray-600 transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-indigo-600 focus:ring-offset-2" + role="switch" + aria-checked="false" + aria-labelledby="availability-label" + aria-describedby="availability-description" onClick={() => { - setUpdate(!update) - }}> - <span aria-hidden="true" data-enabled={!update} class="translate-x-5 data-[enabled=true]:translate-x-0 pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out"></span> + setUpdate(!update); + }} + > + <span + aria-hidden="true" + data-enabled={!update} + class="translate-x-5 data-[enabled=true]:translate-x-0 pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out" + ></span> </button> </div> </h2> @@ -162,15 +220,14 @@ export function ShowAccountDetails({ 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"> - {onClear ? - <button type="button" class="text-sm font-semibold leading-6 text-gray-900" - onClick={onClear} - > - <i18n.Translate>Cancel</i18n.Translate> - </button> - : <div /> - } - <button type="submit" + <a + href={routeClose.url({})} + class="text-sm font-semibold leading-6 text-gray-900" + > + <i18n.Translate>Cancel</i18n.Translate> + </a> + <button + type="submit" 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={!update || !submitAccount} onClick={doUpdate} @@ -183,4 +240,3 @@ export function ShowAccountDetails({ </Fragment> ); } - diff --git a/packages/demobank-ui/src/pages/account/UpdateAccountPassword.tsx b/packages/demobank-ui/src/pages/account/UpdateAccountPassword.tsx index 32e100e43..3b35c1fe1 100644 --- a/packages/demobank-ui/src/pages/account/UpdateAccountPassword.tsx +++ b/packages/demobank-ui/src/pages/account/UpdateAccountPassword.tsx @@ -1,45 +1,76 @@ -import { notifyInfo, useLocalNotification, useTranslationContext } from "@gnu-taler/web-util/browser"; +/* + 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 { ShowInputErrorLabel } from "@gnu-taler/web-util/browser"; import { useBankCoreApiContext } from "../../context/config.js"; import { useBackendState } from "../../hooks/backend.js"; -import { undefinedIfEmpty, withRuntimeErrorHandling } from "../../utils.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"; -import { assertUnreachable } from "../WithdrawalOperationPage.js"; -import { LocalNotificationBanner } from "@gnu-taler/web-util/browser"; -import { AbsoluteTime, HttpStatusCode, TalerErrorCode } from "@gnu-taler/taler-util"; -import { useBankState } from "../../hooks/bank-state.js"; export function UpdateAccountPassword({ account: accountName, - onCancel, + routeClose, onUpdateSuccess, onAuthorizationRequired, focus, }: { - onCancel: () => void; - focus?: boolean, - onAuthorizationRequired: () => void, + routeClose: RouteDefinition<Record<string, never>>; + focus?: boolean; + onAuthorizationRequired: () => void; onUpdateSuccess: () => void; account: string; }): VNode { const { i18n } = useTranslationContext(); const { state: credentials } = useBackendState(); - const token = credentials.status !== "loggedIn" ? undefined : credentials.token + const token = + credentials.status !== "loggedIn" ? undefined : credentials.token; const { api } = useBankCoreApiContext(); const [current, setCurrent] = useState<string | undefined>(); const [password, setPassword] = useState<string | undefined>(); const [repeat, setRepeat] = useState<string | undefined>(); - const [, updateBankState] = useBankState() + const [, updateBankState] = useBankState(); - const accountIsTheCurrentUser = credentials.status === "loggedIn" ? - credentials.username === accountName : false + const accountIsTheCurrentUser = + credentials.status === "loggedIn" + ? credentials.username === accountName + : false; const errors = undefinedIfEmpty({ - current: !accountIsTheCurrentUser ? undefined : !current ? i18n.str`required` : undefined, + current: !accountIsTheCurrentUser + ? undefined + : !current + ? i18n.str`required` + : undefined, password: !password ? i18n.str`required` : undefined, repeat: !repeat ? i18n.str`required` @@ -47,8 +78,7 @@ export function UpdateAccountPassword({ ? i18n.str`password doesn't match` : undefined, }); - const [notification, notify, handleError] = useLocalNotification() - + const [notification, notify, handleError] = useLocalNotification(); async function doChangePassword() { if (!!errors || !password || !token) return; @@ -56,54 +86,62 @@ export function UpdateAccountPassword({ const request = { old_password: current, new_password: password, - } - const resp = await api.updatePassword({ username: accountName, token }, request); + }; + 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.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), sent: AbsoluteTime.never(), request, - }) - return onAuthorizationRequired() + }); + return onAuthorizationRequired(); } - default: assertUnreachable(resp) + default: + assertUnreachable(resp); } } - }) + }); } return ( <Fragment> <LocalNotificationBanner notification={notification} /> - {accountIsTheCurrentUser ? - <ProfileNavigation current="credentials" /> : + {accountIsTheCurrentUser ? ( + <ProfileNavigation current="credentials" /> + ) : ( <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"> @@ -115,8 +153,8 @@ export function UpdateAccountPassword({ 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() + onSubmit={(e) => { + e.preventDefault(); }} > <div class="px-4 py-6 sm:p-8"> @@ -138,7 +176,7 @@ export function UpdateAccountPassword({ data-error={!!errors?.password && password !== undefined} value={password ?? ""} onChange={(e) => { - setPassword(e.currentTarget.value) + setPassword(e.currentTarget.value); }} autocomplete="off" /> @@ -165,7 +203,7 @@ export function UpdateAccountPassword({ data-error={!!errors?.repeat && repeat !== undefined} value={repeat ?? ""} onChange={(e) => { - setRepeat(e.currentTarget.value) + setRepeat(e.currentTarget.value); }} // placeholder="" autocomplete="off" @@ -175,12 +213,12 @@ export function UpdateAccountPassword({ isDirty={repeat !== undefined} /> </div> - <p class="mt-2 text-sm text-gray-500" > + <p class="mt-2 text-sm text-gray-500"> <i18n.Translate>repeat the same password</i18n.Translate> </p> </div> - {accountIsTheCurrentUser ? + {accountIsTheCurrentUser ? ( <div class="sm:col-span-5"> <label class="block text-sm font-medium leading-6 text-gray-900" @@ -197,7 +235,7 @@ export function UpdateAccountPassword({ data-error={!!errors?.current && current !== undefined} value={current ?? ""} onChange={(e) => { - setCurrent(e.currentTarget.value) + setCurrent(e.currentTarget.value); }} autocomplete="off" /> @@ -206,29 +244,29 @@ export function UpdateAccountPassword({ isDirty={current !== undefined} /> </div> - <p class="mt-2 text-sm text-gray-500" > - <i18n.Translate>your current password, for security</i18n.Translate> + <p class="mt-2 text-sm text-gray-500"> + <i18n.Translate> + your current password, for security + </i18n.Translate> </p> </div> - : undefined} - + ) : undefined} </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"> - {onCancel ? - <button type="button" class="text-sm font-semibold leading-6 text-gray-900" - onClick={onCancel} - > - <i18n.Translate>Cancel</i18n.Translate> - </button> - : <div /> - } - <button type="submit" + <a + href={routeClose.url({})} + class="text-sm font-semibold leading-6 text-gray-900" + > + <i18n.Translate>Cancel</i18n.Translate> + </a> + <button + type="submit" 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() + e.preventDefault(); + doChangePassword(); }} > <i18n.Translate>Change</i18n.Translate> @@ -237,6 +275,5 @@ export function UpdateAccountPassword({ </form> </div> </Fragment> - ); -}
\ No newline at end of file +} diff --git a/packages/demobank-ui/src/pages/admin/AccountForm.tsx b/packages/demobank-ui/src/pages/admin/AccountForm.tsx index e08fee8bc..05b9d6a72 100644 --- a/packages/demobank-ui/src/pages/admin/AccountForm.tsx +++ b/packages/demobank-ui/src/pages/admin/AccountForm.tsx @@ -1,13 +1,47 @@ -import { AmountString, Amounts, PaytoString, TalerCorebankApi, TranslatedString, buildPayto, parsePaytoUri, stringifyPaytoUri } from "@gnu-taler/taler-util"; -import { Attention, CopyButton, ShowInputErrorLabel, useTranslationContext } from "@gnu-taler/web-util/browser"; -import { ComponentChildren, Fragment, VNode, h } from "preact"; +/* + 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 { ErrorMessageMappingFor, PartialButDefined, TanChannel, WithIntermediate, undefinedIfEmpty, validateIBAN } from "../../utils.js"; +import { useBackendState } from "../../hooks/backend.js"; +import { + ErrorMessageMappingFor, + TanChannel, + undefinedIfEmpty, + validateIBAN, +} from "../../utils.js"; import { InputAmount, doAutoFocus } from "../PaytoWireTransferForm.js"; -import { assertUnreachable } from "../WithdrawalOperationPage.js"; import { getRandomPassword } from "../rnd.js"; -import { useBackendState } from "../../hooks/backend.js"; const IBAN_REGEX = /^[A-Z][A-Z0-9]*$/; const EMAIL_REGEX = @@ -15,29 +49,29 @@ const EMAIL_REGEX = 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", -} + 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 -} + 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 */ @@ -49,14 +83,14 @@ export function AccountForm<PurposeType extends keyof ChangeByPurposeType>({ focus, children, }: { - focus?: boolean, - children: ComponentChildren, - username?: string, + focus?: boolean; + children: ComponentChildren; + username?: string; template: TalerCorebankApi.AccountData | undefined; onChange: ChangeByPurposeType[PurposeType]; purpose: PurposeType; }): VNode { - const { config, hints } = useBankCoreApiContext() + const { config, hints } = useBankCoreApiContext(); const { i18n } = useTranslationContext(); const { state: credentials } = useBackendState(); const [form, setForm] = useState<AccountFormData>({}); @@ -65,87 +99,115 @@ export function AccountForm<PurposeType extends keyof ChangeByPurposeType>({ ErrorMessageMappingFor<typeof defaultValue> | undefined >(undefined); - const defaultValue: AccountFormData = { - debit_threshold: Amounts.stringifyValue(template?.debit_threshold ?? config.default_debit_threshold), + 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: stringifyIbanPayto(template?.cashout_payto_uri) ?? "" as PaytoString, - payto_uri: stringifyIbanPayto(template?.payto_uri) ?? "" as PaytoString, + cashout_payto_uri: + stringifyIbanPayto(template?.cashout_payto_uri) ?? ("" as PaytoString), + payto_uri: stringifyIbanPayto(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 showingCurrentUserInfo = credentials.status !== "loggedIn" ? false : username === credentials.username - const userIsAdmin = credentials.status !== "loggedIn" ? false : credentials.isUserAdministrator - - const editableUsername = (purpose === "create") - const editableName = (purpose === "create" || purpose === "update" && (config.allow_edit_name || userIsAdmin)) - const editableCashout = showingCurrentUserInfo && (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 + }; + + const OLD_CASHOUT_API = hints.indexOf(VersionHint.CASHOUT_BEFORE_2FA) !== -1; + + const showingCurrentUserInfo = + credentials.status !== "loggedIn" + ? false + : username === credentials.username; + const userIsAdmin = + credentials.status !== "loggedIn" ? false : credentials.isUserAdministrator; + + const editableUsername = purpose === "create"; + const editableName = + purpose === "create" || + (purpose === "update" && (config.allow_edit_name || userIsAdmin)); + const editableCashout = + showingCurrentUserInfo && + (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 cashoutParsed = !newForm.cashout_payto_uri ? undefined - : buildPayto("iban", newForm.cashout_payto_uri, undefined);; + : buildPayto("iban", newForm.cashout_payto_uri, undefined); const internalParsed = !newForm.payto_uri ? undefined - : buildPayto("iban", newForm.payto_uri, undefined);; + : buildPayto("iban", newForm.payto_uri, undefined); 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 : - !cashoutParsed - ? i18n.str`it doesnt have the pattern of an IBAN number` : - !cashoutParsed.isKnown || cashoutParsed.targetType !== "iban" - ? i18n.str`only "IBAN" target are supported` : - !IBAN_REGEX.test(cashoutParsed.iban) - ? i18n.str`IBAN should have just uppercased letters and numbers` : - validateIBAN(cashoutParsed.iban, i18n)), - payto_uri: (!newForm.payto_uri - ? undefined : - !editableAccount ? undefined : - !internalParsed - ? i18n.str`it doesnt have the pattern of an IBAN number` : - !internalParsed.isKnown || internalParsed.targetType !== "iban" - ? i18n.str`only "IBAN" target are supported` : - !IBAN_REGEX.test(internalParsed.iban) - ? i18n.str`IBAN should have just uppercased letters and numbers` : - validateIBAN(internalParsed.iban, i18n)), + const parsedAmount = Amounts.parse( + `${config.currency}:${trimmedAmountStr}`, + ); + + const errors = undefinedIfEmpty< + ErrorMessageMappingFor<typeof defaultValue> + >({ + cashout_payto_uri: !newForm.cashout_payto_uri + ? undefined + : !editableCashout + ? undefined + : !cashoutParsed + ? i18n.str`it doesnt have the pattern of an IBAN number` + : !cashoutParsed.isKnown || cashoutParsed.targetType !== "iban" + ? i18n.str`only "IBAN" target are supported` + : !IBAN_REGEX.test(cashoutParsed.iban) + ? i18n.str`IBAN should have just uppercased letters and numbers` + : validateIBAN(cashoutParsed.iban, i18n), + payto_uri: !newForm.payto_uri + ? undefined + : !editableAccount + ? undefined + : !internalParsed + ? i18n.str`it doesnt have the pattern of an IBAN number` + : !internalParsed.isKnown || internalParsed.targetType !== "iban" + ? i18n.str`only "IBAN" target are supported` + : !IBAN_REGEX.test(internalParsed.iban) + ? i18n.str`IBAN should have just uppercased letters and numbers` + : validateIBAN(internalParsed.iban, i18n), email: !newForm.email - ? undefined : - !EMAIL_REGEX.test(newForm.email) - ? i18n.str`it doesnt have the pattern of an email` : - undefined, + ? undefined + : !EMAIL_REGEX.test(newForm.email) + ? i18n.str`it doesnt 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) + ? 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, + : 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); @@ -153,20 +215,26 @@ export function AccountForm<PurposeType extends keyof ChangeByPurposeType>({ if (!onChange) return; if (errors) { - onChange(undefined) + onChange(undefined); } else { - const cashout = !newForm.cashout_payto_uri ? undefined : buildPayto("iban", newForm.cashout_payto_uri, undefined) - const cashoutURI = !cashout ? undefined : stringifyPaytoUri(cashout) + const cashout = !newForm.cashout_payto_uri + ? undefined + : buildPayto("iban", newForm.cashout_payto_uri, undefined); + const cashoutURI = !cashout ? undefined : stringifyPaytoUri(cashout); - const internal = !newForm.payto_uri ? undefined : buildPayto("iban", newForm.payto_uri, undefined); - const internalURI = !internal ? undefined : stringifyPaytoUri(internal) + const internal = !newForm.payto_uri + ? undefined + : buildPayto("iban", newForm.payto_uri, undefined); + const internalURI = !internal ? undefined : stringifyPaytoUri(internal); - const threshold = !parsedAmount ? undefined : Amounts.stringify(parsedAmount) + 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"] + // typescript doesn't correctly narrow a generic type + const callback = onChange as ChangeByPurposeType["create"]; const result: TalerCorebankApi.RegisterAccountRequest = { name: newForm.name!, password: getRandomPassword(), @@ -180,15 +248,17 @@ export function AccountForm<PurposeType extends keyof ChangeByPurposeType>({ payto_uri: internalURI, is_public: !!newForm.isPublic, is_taler_exchange: !!newForm.isExchange, - // @ts-ignore - tan_channel: newForm.tan_channel === "remove" ? null : newForm.tan_channel, - } - callback(result) + 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"] + // typescript doesn't correctly narrow a generic type + const callback = onChange as ChangeByPurposeType["update"]; const result: TalerCorebankApi.AccountReconfiguration = { cashout_payto_uri: cashoutURI, @@ -199,17 +269,17 @@ export function AccountForm<PurposeType extends keyof ChangeByPurposeType>({ debit_threshold: threshold, is_public: !!newForm.isPublic, name: newForm.name, - // @ts-ignore - tan_channel: newForm?.tan_channel === "remove" ? null : newForm.tan_channel, - } - callback(result) + tan_channel: + newForm.tan_channel === "remove" ? null : newForm.tan_channel, + }; + callback(result); return; } case "show": { return; } default: { - assertUnreachable(purpose) + assertUnreachable(purpose); } } } @@ -219,13 +289,12 @@ export function AccountForm<PurposeType extends keyof ChangeByPurposeType>({ 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() + 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" @@ -256,8 +325,10 @@ export function AccountForm<PurposeType extends keyof ChangeByPurposeType>({ isDirty={form.username !== undefined} /> </div> - <p class="mt-2 text-sm text-gray-500" > - <i18n.Translate>account identification in the bank</i18n.Translate> + <p class="mt-2 text-sm text-gray-500"> + <i18n.Translate> + account identification in the bank + </i18n.Translate> </p> </div> @@ -290,26 +361,30 @@ export function AccountForm<PurposeType extends keyof ChangeByPurposeType>({ isDirty={form.name !== undefined} /> </div> - <p class="mt-2 text-sm text-gray-500" > - <i18n.Translate>name of the person owner the account</i18n.Translate> + <p class="mt-2 text-sm text-gray-500"> + <i18n.Translate> + name of the person owner the account + </i18n.Translate> </p> </div> - <PaytoField type="iban" name="internal-account" label={i18n.str`Internal IBAN`} - help={purpose === "create" ? - i18n.str`if empty a random account number will be assigned` : - i18n.str`account identification for bank transfer`} + help={ + purpose === "create" + ? i18n.str`if empty a random account number will be assigned` + : i18n.str`account identification for bank transfer` + } value={(form.payto_uri ?? defaultValue.payto_uri) as PaytoString} disabled={!editableAccount} error={errors?.payto_uri} onChange={(e) => { - form.payto_uri = e as PaytoString - updateForm(structuredClone(form)) - }} /> + form.payto_uri = e as PaytoString; + updateForm(structuredClone(form)); + }} + /> <div class="sm:col-span-5"> <label @@ -369,71 +444,114 @@ export function AccountForm<PurposeType extends keyof ChangeByPurposeType>({ </div> </div> - {showingCurrentUserInfo && + {showingCurrentUserInfo && ( <PaytoField type="iban" name="cashout-account" label={i18n.str`Cashout IBAN`} help={i18n.str`account number where the money is going to be sent when doing cashouts`} - value={(form.cashout_payto_uri ?? defaultValue.cashout_payto_uri) as PaytoString} + value={ + (form.cashout_payto_uri ?? + defaultValue.cashout_payto_uri) as PaytoString + } disabled={!editableCashout} error={errors?.cashout_payto_uri} onChange={(e) => { - form.cashout_payto_uri = e as PaytoString - updateForm(structuredClone(form)) - }} /> - } + form.cashout_payto_uri = e as PaytoString; + updateForm(structuredClone(form)); + }} + /> + )} <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> + <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)) - }} + onChange={ + !editableThreshold + ? undefined + : (e) => { + form.debit_threshold = e as AmountString; + updateForm(structuredClone(form)); + } + } /> <ShowInputErrorLabel - message={errors?.debit_threshold ? String(errors?.debit_threshold) : undefined} + 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 is user able to transfer after zero balance</i18n.Translate> + <p class="mt-2 text-sm text-gray-500"> + <i18n.Translate> + how much is user able to transfer after zero balance + </i18n.Translate> </p> </div> - {purpose !== "create" || !userIsAdmin ? undefined : + {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"> + <span + class="text-sm text-black font-medium leading-6 " + id="availability-label" + > <i18n.Translate>Is this a Taler Exchange?</i18n.Translate> </span> </span> - <button type="button" 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" - + <button + type="button" + 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> + 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> - } + )} {/* channel, not shown if old cashout api */} - {OLD_CASHOUT_API ? undefined : config.supported_tan_channels.length === 0 ? + {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`}> + <Attention + type="warning" + title={i18n.str`No cashout channel available`} + > <i18n.Translate> This server doesn't support second factor authentication. </i18n.Translate> </Attention> </div> - : + ) : ( <div class="sm:col-span-5"> <label class="block text-sm font-medium leading-6 text-gray-900" @@ -443,85 +561,166 @@ export function AccountForm<PurposeType extends keyof ChangeByPurposeType>({ </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 + {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 } - 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" /> + 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 "> + <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 a email in your profile to enable this option`} + {purpose !== "show" && + !hasEmail && + i18n.str`add a 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 + 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 + )} + + {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 } - 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" /> + 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"> + <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`} + {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 + 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"> <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"> + <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" 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" - + <button + type="button" + 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 - 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> + form.isPublic = !form.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 accesible</i18n.Translate> + <p class="mt-2 text-sm text-gray-500"> + <i18n.Translate> + public accounts have their balance publicly accesible + </i18n.Translate> </p> </div> - </div> </div> {children} @@ -530,15 +729,16 @@ export function AccountForm<PurposeType extends keyof ChangeByPurposeType>({ } function stringifyIbanPayto(s: PaytoString | undefined): string | undefined { - if (s === undefined) return undefined - const p = parsePaytoUri(s) - if (p === undefined) return undefined - if (!p.isKnown) return undefined - if (p.targetType !== "iban") return undefined - return p.iban + if (s === undefined) return undefined; + const p = parsePaytoUri(s); + if (p === undefined) return undefined; + if (!p.isKnown) return undefined; + if (p.targetType !== "iban") return undefined; + return p.iban; } -{/* <div class="sm:col-span-5"> +{ + /* <div class="sm:col-span-5"> <label class="block text-sm font-medium leading-6 text-gray-900" for="cashout" @@ -572,112 +772,129 @@ function stringifyIbanPayto(s: PaytoString | undefined): string | undefined { <p class="mt-2 text-sm text-gray-500" > <i18n.Translate></i18n.Translate> </p> - </div> */} + </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 { +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 ?? ""} - /> + 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> - <ShowInputErrorLabel - message={error} - isDirty={value !== undefined} - /> + <p class="mt-2 text-sm text-gray-500">{help}</p> </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 ?? ""} - /> - <CopyButton - class="p-2 rounded-full text-black shadow-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 " - getContent={() => value ?? ""} - /> + 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 ?? ""} + /> + </div> + <ShowInputErrorLabel message={error} isDirty={value !== undefined} /> </div> - <ShowInputErrorLabel - message={error} - isDirty={value !== undefined} - /> + <p class="mt-2 text-sm text-gray-500"> + {/* <i18n.Translate>internal account id</i18n.Translate> */} + {help} + </p> </div> - <p class="mt-2 text-sm text-gray-500" > - {/* <i18n.Translate>internal account id</i18n.Translate> */} - {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} - /> + 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> - <p class="mt-2 text-sm text-gray-500" > - {/* <i18n.Translate>bitcoin address</i18n.Translate> */} - {help} - </p> - </div> + ); } - assertUnreachable(type) + assertUnreachable(type); } diff --git a/packages/demobank-ui/src/pages/admin/AccountList.tsx b/packages/demobank-ui/src/pages/admin/AccountList.tsx index 4ec25660b..1cee4c58a 100644 --- a/packages/demobank-ui/src/pages/admin/AccountList.tsx +++ b/packages/demobank-ui/src/pages/admin/AccountList.tsx @@ -1,143 +1,205 @@ -import { Amounts, HttpStatusCode, TalerError } from "@gnu-taler/taler-util"; +/* + 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/circuit.js"; import { RenderAmount } from "../PaytoWireTransferForm.js"; -import { assertUnreachable } from "../WithdrawalOperationPage.js"; +import { RouteDefinition } from "../../route.js"; interface Props { - onCreateAccount: () => void; + routeCreate: RouteDefinition<Record<string, never>>; - onShowAccountDetails: (aid: string) => void; - onRemoveAccount: (aid: string) => void; - onUpdateAccountPassword: (aid: string) => void; - onShowCashoutForAccount: (aid: string) => void; + routeShowAccount: RouteDefinition<{ account: string }>; + routeRemoveAccount: RouteDefinition<{ account: string }>; + routeUpdatePasswordAccount: RouteDefinition<{ account: string }>; + routeShowCashoutsAccount: RouteDefinition<{ account: string }>; } -export function AccountList({ onRemoveAccount, onShowAccountDetails, onUpdateAccountPassword, onShowCashoutForAccount, onCreateAccount }: Props): VNode { +export function AccountList({ + routeCreate, + routeRemoveAccount, + routeShowAccount, + routeShowCashoutsAccount, + routeUpdatePasswordAccount, +}: Props): VNode { const result = useBusinessAccounts(); const { i18n } = useTranslationContext(); - const { config } = useBankCoreApiContext() + const { config } = useBankCoreApiContext(); if (!result) { - return <Loading /> + return <Loading />; } if (result instanceof TalerError) { - return <ErrorLoadingWithDebug error={result} /> + return <ErrorLoadingWithDebug error={result} />; } if (result.data.type === "fail") { switch (result.data.case) { - case HttpStatusCode.Unauthorized: return <Fragment /> - default: assertUnreachable(result.data.case) + case HttpStatusCode.Unauthorized: + return <Fragment />; + default: + assertUnreachable(result.data.case); } } const { accounts } = result.data.body; - 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 business account in the bank.</i18n.Translate> - </p> - </div> - <div class="mt-4 sm:ml-16 sm:mt-0 sm:flex-none"> - <button 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" - onClick={(e) => { - e.preventDefault() - onCreateAccount() - }}> - <i18n.Translate>Create account</i18n.Translate> - </button> + 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 business account in the bank. + </i18n.Translate> + </p> + </div> + <div class="mt-4 sm:ml-16 sm:mt-0 sm:flex-none"> + <a + href={routeCreate.url({})} + 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> - <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></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 href="#" class="text-indigo-600 hover:text-indigo-900" - onClick={(e) => { - e.preventDefault(); - onShowAccountDetails(item.username) - }} - > - {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 href="#" class="text-indigo-600 hover:text-indigo-900" - onClick={(e) => { - e.preventDefault(); - onUpdateAccountPassword(item.username) - }} - > - <i18n.Translate>change password</i18n.Translate> - </a> - <br /> - {noBalance ? - <a href="#" class="text-indigo-600 hover:text-indigo-900" onClick={(e) => { - e.preventDefault(); - onRemoveAccount(item.username) - }} - > - <i18n.Translate>remove</i18n.Translate> - </a> - : undefined} - </td> + <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></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"; - </tbody> - </table> - )} + 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 + 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 + href={routeUpdatePasswordAccount.url({ + account: item.username, + })} + class="text-indigo-600 hover:text-indigo-900" + > + <i18n.Translate>change password</i18n.Translate> + </a> + <br /> + <a + href={routeShowCashoutsAccount.url({ + account: item.username, + })} + class="text-indigo-600 hover:text-indigo-900" + > + <i18n.Translate>cashouts</i18n.Translate> + </a> + <br /> + {noBalance ? ( + <a + 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> </div> </div> </div> - </div> - </Fragment> - -}
\ No newline at end of file + </Fragment> + ); +} diff --git a/packages/demobank-ui/src/pages/admin/AdminHome.tsx b/packages/demobank-ui/src/pages/admin/AdminHome.tsx index b7ef3aa00..4a8eb5b97 100644 --- a/packages/demobank-ui/src/pages/admin/AdminHome.tsx +++ b/packages/demobank-ui/src/pages/admin/AdminHome.tsx @@ -1,8 +1,43 @@ -import { AmountString, Amounts, CurrencySpecification, HttpStatusCode, TalerCorebankApi, TalerError, assertUnreachable } from "@gnu-taler/taler-util"; -import { Attention, useLang, useTranslationContext } from "@gnu-taler/web-util/browser"; -import { format, getDate, getHours, getMonth, getYear, setDate, setHours, setMonth, setYear, sub } from "date-fns"; +/* + 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 { privatePages } from "../../Routing.js"; import { ErrorLoadingWithDebug } from "../../components/ErrorLoadingWithDebug.js"; import { Transactions } from "../../components/Transactions/index.js"; import { useBankCoreApiContext } from "../../context/config.js"; @@ -10,247 +45,464 @@ import { useConversionInfo, useLastMonitorInfo } from "../../hooks/circuit.js"; import { RenderAmount } from "../PaytoWireTransferForm.js"; import { WireTransfer } from "../WireTransfer.js"; import { AccountList } from "./AccountList.js"; +import { RouteDefinition } from "../../route.js"; /** * Query account information and show QR code if there is pending withdrawal */ interface Props { - onCreateAccount: () => void; - onShowAccountDetails: (aid: string) => void; - onRemoveAccount: (aid: string) => void; - onUpdateAccountPassword: (aid: string) => void; - onShowCashoutForAccount: (aid: string) => void; + routeCreate: RouteDefinition<Record<string, never>>; + + routeShowAccount: RouteDefinition<{ account: string }>; + routeRemoveAccount: RouteDefinition<{ account: string }>; + routeUpdatePasswordAccount: RouteDefinition<{ account: string }>; + routeShowCashoutsAccount: RouteDefinition<{ account: string }>; onAuthorizationRequired: () => void; } -export function AdminHome({ onCreateAccount, onAuthorizationRequired, onRemoveAccount, onShowAccountDetails, onShowCashoutForAccount, onUpdateAccountPassword }: Props): VNode { - return <Fragment> - <Metrics /> - <WireTransfer onAuthorizationRequired={onAuthorizationRequired} /> - - <Transactions account="admin" /> - <AccountList - onCreateAccount={onCreateAccount} - onRemoveAccount={onRemoveAccount} - onShowCashoutForAccount={onShowCashoutForAccount} - onShowAccountDetails={onShowAccountDetails} - onUpdateAccountPassword={onUpdateAccountPassword} - /> +export function AdminHome({ + routeCreate, + routeRemoveAccount, + routeShowAccount, + routeShowCashoutsAccount, + routeUpdatePasswordAccount, + onAuthorizationRequired, +}: Props): VNode { + return ( + <Fragment> + <Metrics /> + <WireTransfer onAuthorizationRequired={onAuthorizationRequired} /> - </Fragment> + <Transactions account="admin" /> + <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() +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 }); + 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) + assertUnreachable(timeframe); } -export function getTimeframesForDate(time: Date, timeframe: TalerCorebankApi.MonitorTimeframeParam): { current: number, previous: number } { +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) + 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(): VNode { - const { i18n, dateLocale } = useTranslationContext() - const [metricType, setMetricType] = useState<TalerCorebankApi.MonitorTimeframeParam>(TalerCorebankApi.MonitorTimeframeParam.hour); + 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 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} /> + return <ErrorLoadingWithDebug error={resp} />; } if (!respInfo) return <Fragment />; if (respInfo instanceof TalerError) { - return <ErrorLoadingWithDebug error={respInfo} /> + return <ErrorLoadingWithDebug error={respInfo} />; } if (respInfo.type === "fail") { switch (respInfo.case) { case HttpStatusCode.NotImplemented: { - return <Attention type="danger" title={i18n.str`Cashout not implemented`}> - </Attention>; + return ( + <Attention + type="danger" + title={i18n.str`Cashout not implemented`} + ></Attention> + ); } - default: assertUnreachable(respInfo.case) + default: + assertUnreachable(respInfo.case); } } if (resp.current.type !== "ok" || resp.previous.type !== "ok") { - return <Fragment /> + 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 any) - }}> - <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>Last 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"> - <nav class="isolate flex divide-x divide-gray-200 rounded-lg shadow" aria-label="Tabs"> - <a href="#" 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> - </a> - <a href="#" onClick={(e) => { e.preventDefault(); setMetricType(TalerCorebankApi.MonitorTimeframeParam.day) }} data-selected={metricType == TalerCorebankApi.MonitorTimeframeParam.day} 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>Last 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> - </a> - <a href="#" 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> - </a> - <a href="#" 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> - </a> - </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> - </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> - <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> - </dt> - <MetricValue - current={resp.current.body.talerInVolume} - previous={resp.previous.body.talerInVolume} - spec={config.currency_specification} - /> + 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>Last 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="px-4 py-5 sm:p-6"> - <dt class="text-base font-normal text-gray-900"> - <i18n.Translate>Payout</i18n.Translate> - </dt> - <MetricValue - current={resp.current.body.talerOutVolume} - previous={resp.previous.body.talerOutVolume} - spec={config.currency_specification} - /> + <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" + 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" + 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>Last 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" + 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" + 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> - </dl> - <div class="flex justify-end mt-2"> - <a href="#/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> + <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> + </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> + <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> + </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> + </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={privatePages.statsDownload.url({})} + 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() +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 cv = !current ? undefined : Amounts.stringifyValue(current); + const currAmount = !cv ? undefined : Number.parseFloat(cv); + const prevAmount = !previous + ? undefined + : Number.parseFloat(Amounts.stringifyValue(previous)); - 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"> + 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; - <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> + 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> - {!!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>Descreased by</i18n.Translate></span> : - <span class="sr-only"><i18n.Translate>Increased by</i18n.Translate></span> - } - {rateStr} - </span> - } - </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> + )} - </dd> - </Fragment> + {negative ? ( + <span class="sr-only"> + <i18n.Translate>Descreased 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 index 1cfbd8234..c4e4266f9 100644 --- a/packages/demobank-ui/src/pages/admin/CreateNewAccount.tsx +++ b/packages/demobank-ui/src/pages/admin/CreateNewAccount.tsx @@ -1,28 +1,57 @@ -import { HttpStatusCode, TalerCorebankApi, TalerErrorCode, TranslatedString } from "@gnu-taler/taler-util"; -import { Attention, LocalNotificationBanner, notifyInfo, useLocalNotification, useTranslationContext } from "@gnu-taler/web-util/browser"; +/* + 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 { mutate } from "swr"; import { useBankCoreApiContext } from "../../context/config.js"; import { useBackendState } from "../../hooks/backend.js"; -import { assertUnreachable } from "../WithdrawalOperationPage.js"; -import { getRandomPassword } from "../rnd.js"; -import { AccountForm, AccountFormData } from "./AccountForm.js"; +import { RouteDefinition } from "../../route.js"; +import { AccountForm } from "./AccountForm.js"; export function CreateNewAccount({ - onCancel, + routeCancel, onCreateSuccess, }: { - onCancel: () => void; + routeCancel: RouteDefinition<Record<string, never>>; onCreateSuccess: () => void; }): VNode { const { i18n } = useTranslationContext(); - const { state: credentials } = useBackendState() - const token = credentials.status !== "loggedIn" ? undefined : credentials.token + const { state: credentials } = useBackendState(); + const token = + credentials.status !== "loggedIn" ? undefined : credentials.token; const { api } = useBankCoreApiContext(); - const [submitAccount, setSubmitAccount] = useState<TalerCorebankApi.RegisterAccountRequest | undefined>(); - const [notification, notify, handleError] = useLocalNotification() + const [submitAccount, setSubmitAccount] = useState< + TalerCorebankApi.RegisterAccountRequest | undefined + >(); + const [notification, notify, handleError] = useLocalNotification(); async function doCreate() { if (!submitAccount || !token) return; @@ -41,83 +70,108 @@ export function CreateNewAccount({ const resp = await api.createAccount(token, submitAccount); if (resp.type === "ok") { - mutate(() => true)// clean account list + mutate(() => true); // clean account list notifyInfo( i18n.str`Account created with password "${submitAccount.password}". The user must change the password on the next login.`, ); 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) + 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 <Attention type="warning" title={i18n.str`Can't create accounts`} onClose={onCancel}> - <i18n.Translate>Only system admin can create accounts.</i18n.Translate> - </Attention> + 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({})} + 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 ( @@ -137,26 +191,24 @@ export function CreateNewAccount({ }} > <div class="flex items-center justify-between gap-x-6 border-t border-gray-900/10 px-4 py-4 sm:px-8"> - {onCancel ? - <button type="button" class="text-sm font-semibold leading-6 text-gray-900" - onClick={onCancel} - > - <i18n.Translate>Cancel</i18n.Translate> - </button> - : <div /> - } - <button type="submit" + <a + href={routeCancel.url({})} + class="text-sm font-semibold leading-6 text-gray-900" + > + <i18n.Translate>Cancel</i18n.Translate> + </a> + <button + type="submit" 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() + e.preventDefault(); + doCreate(); }} > <i18n.Translate>Create</i18n.Translate> </button> </div> - </AccountForm> </div> ); diff --git a/packages/demobank-ui/src/pages/admin/RemoveAccount.tsx b/packages/demobank-ui/src/pages/admin/RemoveAccount.tsx index beadad957..36e1a4eac 100644 --- a/packages/demobank-ui/src/pages/admin/RemoveAccount.tsx +++ b/packages/demobank-ui/src/pages/admin/RemoveAccount.tsx @@ -1,5 +1,36 @@ -import { AbsoluteTime, Amounts, HttpStatusCode, TalerError, TalerErrorCode, TranslatedString } from "@gnu-taler/taler-util"; -import { Attention, Loading, LocalNotificationBanner, ShowInputErrorLabel, notifyInfo, useLocalNotification, useTranslationContext } from "@gnu-taler/web-util/browser"; +/* + 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"; @@ -9,43 +40,46 @@ import { useBackendState } from "../../hooks/backend.js"; import { undefinedIfEmpty } from "../../utils.js"; import { LoginForm } from "../LoginForm.js"; import { doAutoFocus } from "../PaytoWireTransferForm.js"; -import { assertUnreachable } from "../WithdrawalOperationPage.js"; import { useBankState } from "../../hooks/bank-state.js"; +import { RouteDefinition } from "../../route.js"; export function RemoveAccount({ account, - onCancel, + routeCancel, onUpdateSuccess, onAuthorizationRequired, focus, }: { focus?: boolean; - onAuthorizationRequired: () => void, - onCancel: () => void; + onAuthorizationRequired: () => void; + routeCancel: RouteDefinition<Record<string, never>>; onUpdateSuccess: () => void; account: string; }): VNode { const { i18n } = useTranslationContext(); const result = useAccountDetails(account); - const [accountName, setAccountName] = useState<string | undefined>() + const [accountName, setAccountName] = useState<string | undefined>(); const { state } = useBackendState(); - const token = state.status !== "loggedIn" ? undefined : state.token - const { api } = useBankCoreApiContext() - const [notification, notify, handleError] = useLocalNotification() - const [, updateBankState] = useBankState() + const token = state.status !== "loggedIn" ? undefined : state.token; + const { api } = useBankCoreApiContext(); + const [notification, notify, handleError] = useLocalNotification(); + const [, updateBankState] = useBankState(); if (!result) { - return <Loading /> + return <Loading />; } if (result instanceof TalerError) { - return <ErrorLoadingWithDebug error={result} /> + 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) + case HttpStatusCode.Unauthorized: + return <LoginForm currentUser={account} />; + case HttpStatusCode.NotFound: + return <LoginForm currentUser={account} />; + default: + assertUnreachable(result); } } @@ -55,9 +89,24 @@ export function RemoveAccount({ } const isBalanceEmpty = Amounts.isZero(balance); if (!isBalanceEmpty) { - return <Attention type="warning" title={i18n.str`Can't delete the account`} onClose={onCancel}> - <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> + 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({})} + 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() { @@ -69,45 +118,49 @@ export function RemoveAccount({ 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.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(), request: account, - }) - return onAuthorizationRequired() + }); + return onAuthorizationRequired(); } default: { - assertUnreachable(resp) + assertUnreachable(resp); } } } - }) + }); } const errors = undefinedIfEmpty({ @@ -118,12 +171,14 @@ export function RemoveAccount({ : undefined, }); - return ( <div> <LocalNotificationBanner notification={notification} /> - <Attention type="warning" title={i18n.str`You are going to remove the account`}> + <Attention + type="warning" + title={i18n.str`You are going to remove the account`} + > <i18n.Translate>This step can't be undone.</i18n.Translate> </Attention> @@ -137,13 +192,12 @@ export function RemoveAccount({ 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() + 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" @@ -158,10 +212,12 @@ export function RemoveAccount({ 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} + data-error={ + !!errors?.accountName && accountName !== undefined + } value={accountName ?? ""} onChange={(e) => { - setAccountName(e.currentTarget.value) + setAccountName(e.currentTarget.value); }} placeholder={account} autocomplete="off" @@ -171,30 +227,28 @@ export function RemoveAccount({ 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 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"> - {onCancel ? - <button type="button" class="text-sm font-semibold leading-6 text-gray-900" - onClick={onCancel} - > - <i18n.Translate>Cancel</i18n.Translate> - </button> - : <div /> - } - <button type="submit" + <a + href={routeCancel.url({})} + class="text-sm font-semibold leading-6 text-gray-900" + > + <i18n.Translate>Cancel</i18n.Translate> + </a> + <button + type="submit" 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() + e.preventDefault(); + doRemove(); }} > <i18n.Translate>Delete</i18n.Translate> diff --git a/packages/demobank-ui/src/pages/business/CreateCashout.tsx b/packages/demobank-ui/src/pages/business/CreateCashout.tsx index 254a0d81f..93bd2c89d 100644 --- a/packages/demobank-ui/src/pages/business/CreateCashout.tsx +++ b/packages/demobank-ui/src/pages/business/CreateCashout.tsx @@ -17,13 +17,13 @@ import { AbsoluteTime, Amounts, HttpStatusCode, - TalerCorebankApi, TalerError, TalerErrorCode, TranslatedString, + assertUnreachable, encodeCrock, getRandomBytes, - parsePaytoUri + parsePaytoUri, } from "@gnu-taler/taler-util"; import { Attention, @@ -32,7 +32,7 @@ import { ShowInputErrorLabel, notifyInfo, useLocalNotification, - useTranslationContext + useTranslationContext, } from "@gnu-taler/web-util/browser"; import { Fragment, VNode, h } from "preact"; import { useEffect, useState } from "preact/hooks"; @@ -40,24 +40,22 @@ import { ErrorLoadingWithDebug } from "../../components/ErrorLoadingWithDebug.js import { VersionHint, useBankCoreApiContext } from "../../context/config.js"; import { useAccountDetails } from "../../hooks/access.js"; import { useBackendState } from "../../hooks/backend.js"; -import { - useConversionInfo, - useEstimator -} from "../../hooks/circuit.js"; -import { - TanChannel, - undefinedIfEmpty -} from "../../utils.js"; -import { LoginForm } from "../LoginForm.js"; -import { InputAmount, RenderAmount, doAutoFocus } from "../PaytoWireTransferForm.js"; -import { assertUnreachable } from "../WithdrawalOperationPage.js"; import { useBankState } from "../../hooks/bank-state.js"; +import { useConversionInfo, useEstimator } from "../../hooks/circuit.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, - onCancel?: () => void; + focus?: boolean; + onAuthorizationRequired: () => void; + routeClose: RouteDefinition<Record<string, never>>; } type FormType = { @@ -70,12 +68,11 @@ type ErrorFrom<T> = { [P in keyof T]+?: string; }; - export function CreateCashout({ account: accountName, onAuthorizationRequired, focus, - onCancel, + routeClose, }: Props): VNode { const { i18n } = useTranslationContext(); const resultAccount = useAccountDetails(accountName); @@ -84,95 +81,130 @@ export function CreateCashout({ estimateByDebit: calculateFromDebit, } = useEstimator(); const { state: credentials } = useBackendState(); - const creds = credentials.status !== "loggedIn" ? undefined : credentials - const [, updateBankState] = useBankState() + const creds = credentials.status !== "loggedIn" ? undefined : credentials; + const [, updateBankState] = useBankState(); - const { api, config, hints } = useBankCoreApiContext() - const [form, setForm] = useState<Partial<FormType>>({ isDebit: true, }); - const [notification, notify, handleError] = useLocalNotification() + const { 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 <Attention type="warning" title={i18n.str`Unable to create a cashout`} onClose={onCancel}> - <i18n.Translate>The bank configuration does not support cashout operations.</i18n.Translate> - </Attention> + 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({})} + 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 + const OLD_CASHOUT_API = hints.indexOf(VersionHint.CASHOUT_BEFORE_2FA) !== -1; if (!resultAccount) { - return <Loading /> + return <Loading />; } if (resultAccount instanceof TalerError) { - return <ErrorLoadingWithDebug error={resultAccount} /> + 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) + case HttpStatusCode.Unauthorized: + return <LoginForm currentUser={accountName} />; + case HttpStatusCode.NotFound: + return <LoginForm currentUser={accountName} />; + default: + assertUnreachable(resultAccount); } } if (!info) { - return <Loading /> + return <Loading />; } if (info instanceof TalerError) { - return <ErrorLoadingWithDebug error={info} /> + return <ErrorLoadingWithDebug error={info} />; } if (info.type === "fail") { switch (info.case) { case HttpStatusCode.NotImplemented: { - return <Attention type="danger" title={i18n.str`Cashout not implemented`}> - </Attention>; + return ( + <Attention + type="danger" + title={i18n.str`Cashout not implemented`} + ></Attention> + ); } - default: assertUnreachable(info.case) + default: + assertUnreachable(info.case); } } - - const conversionInfo = info.body.conversion_rate + const conversionInfo = info.body.conversion_rate; if (!conversionInfo) { - return <div>conversion enabled but server replied without conversion_rate</div> + 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) - } + 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 { + 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 zeroCalc = { + debit: regionalZero, + credit: fiatZero, + beforeFee: fiatZero, + }; const [calc, setCalc] = useState(zeroCalc); const sellFee = Amounts.parseOrThrow(conversionInfo.cashout_fee); - const sellRate = conversionInfo.cashout_ratio + 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}`, + `${form.isDebit ? regional_currency : fiat_currency}:${ + !form.amount ? "0" : form.amount + }`, ); useEffect(() => { async function doAsync() { await handleError(async () => { if (Amounts.isNonZero(inputAmount)) { - const resp = await (form.isDebit ? - calculateFromDebit(inputAmount, sellFee) : - calculateFromCredit(inputAmount, sellFee)); - setCalc(resp) + const resp = await (form.isDebit + ? calculateFromDebit(inputAmount, sellFee) + : calculateFromCredit(inputAmount, sellFee)); + setCalc(resp); } - }) + }); } - doAsync() + doAsync(); }, [form.amount, form.isDebit]); const balanceAfter = Amounts.sub(account.balance, calc.debit).amount; @@ -198,10 +230,13 @@ export function CreateCashout({ const trimmedAmountStr = form.amount?.trim(); async function createCashout() { - const request_uid = encodeCrock(getRandomBytes(32)) + 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 + // 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 = { @@ -210,10 +245,10 @@ export function CreateCashout({ amount_debit: Amounts.stringify(calc.debit), subject: form.subject, tan_channel: form.channel, - } - const resp = await api.createCashout(creds, request) + }; + const resp = await api.createCashout(creds, request); if (resp.type === "ok") { - notifyInfo(i18n.str`Cashout created`) + notifyInfo(i18n.str`Cashout created`); } else { switch (resp.case) { case HttpStatusCode.Accepted: { @@ -222,102 +257,127 @@ export function CreateCashout({ id: String(resp.body.challenge_id), sent: AbsoluteTime.never(), request, - }) - return onAuthorizationRequired() + }); + 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 succeded 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`Cashouts are not supported`, - 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, - }); + 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 succeded 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`Cashouts are not supported`, + 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) + assertUnreachable(resp); } - }) + }); } - const cashoutDisabled = config.supported_tan_channels.length < 1 || !resultAccount.body.cashout_payto_uri - console.log("disab", cashoutDisabled) - const cashoutAccount = !resultAccount.body.cashout_payto_uri ? undefined : - parsePaytoUri(resultAccount.body.cashout_payto_uri); - const cashoutAccountName = !cashoutAccount ? undefined : cashoutAccount.targetPath + const cashoutDisabled = + config.supported_tan_channels.length < 1 || + !resultAccount.body.cashout_payto_uri; + console.log("disab", cashoutDisabled); + const cashoutAccount = !resultAccount.body.cashout_payto_uri + ? undefined + : parsePaytoUri(resultAccount.body.cashout_payto_uri); + const cashoutAccountName = !cashoutAccount + ? undefined + : cashoutAccount.targetPath; 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> + <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>Convertion rate</i18n.Translate></dt> + <dt class="text-sm text-gray-600"> + <i18n.Translate>Convertion 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> + <span> + <i18n.Translate>Balance</i18n.Translate> + </span> </dt> <dd class="text-sm text-gray-900"> - <RenderAmount value={account.balance} spec={regional_currency_specification} /> + <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> + <span> + <i18n.Translate>Fee</i18n.Translate> + </span> </dt> <dd class="text-sm text-gray-900"> - <RenderAmount value={sellFee} spec={fiat_currency_specification} /> + <RenderAmount + value={sellFee} + spec={fiat_currency_specification} + /> </dd> </div> - {cashoutAccountName ? + {cashoutAccountName ? ( <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> + <span> + <i18n.Translate>To account</i18n.Translate> + </span> </dt> - <dd class="text-sm text-gray-900"> - {cashoutAccountName} - </dd> - </div> : + <dd class="text-sm text-gray-900">{cashoutAccountName}</dd> + </div> + ) : ( <div class="flex items-center justify-between border-t-2 afu pt-4"> <Attention type="warning" title={i18n.str`No cashout account`}> <i18n.Translate> @@ -325,17 +385,15 @@ export function CreateCashout({ </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() + onSubmit={(e) => { + e.preventDefault(); }} > <div class="px-4 py-6 sm:p-8"> @@ -370,7 +428,6 @@ export function CreateCashout({ isDirty={form.subject !== undefined} /> </div> - </div> {/* amount */} @@ -384,14 +441,25 @@ export function CreateCashout({ ? i18n.str`Amount to send` : i18n.str`Amount to receive`} </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" + <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> + 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 @@ -399,53 +467,78 @@ export function CreateCashout({ left currency={limit.currency} value={trimmedAmountStr} - onChange={cashoutDisabled ? undefined : (value) => { - form.amount = value; - updateForm(structuredClone(form)); - }} + 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> + <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} /> + <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> + <span> + <i18n.Translate>Balance left</i18n.Translate> + </span> </dt> <dd class="text-sm text-gray-900"> - <RenderAmount value={balanceAfter} spec={regional_currency_specification} /> + <RenderAmount + value={balanceAfter} + spec={regional_currency_specification} + /> </dd> </div> - {Amounts.isZero(sellFee) || Amounts.isZero(calc.beforeFee) ? undefined : ( + {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> + <span> + <i18n.Translate>Before fee</i18n.Translate> + </span> </dt> <dd class="text-sm text-gray-900"> - <RenderAmount value={calc.beforeFee} spec={fiat_currency_specification} /> + <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> + <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} /> + <RenderAmount + value={calc.credit} + withColor + spec={fiat_currency_specification} + /> </dd> </div> </dl> @@ -453,15 +546,20 @@ export function CreateCashout({ )} {/* channel, not shown if new cashout api */} - {!OLD_CASHOUT_API ? undefined : config.supported_tan_channels.length === 0 ? + {!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`}> + <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 + 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" @@ -471,72 +569,124 @@ export function CreateCashout({ </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" /> + {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 "> + <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`} + {!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 + 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" /> + )} + + {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"> + <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`} + {!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 + 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"> - {onCancel ? - <button type="button" class="text-sm font-semibold leading-6 text-gray-900" - onClick={onCancel} - > - <i18n.Translate>Cancel</i18n.Translate> - </button> - : <div /> - } - <button type="submit" + <a + href={routeClose.url({})} + type="button" + class="text-sm font-semibold leading-6 text-gray-900" + > + <i18n.Translate>Cancel</i18n.Translate> + </a> + <button + type="submit" 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() + e.preventDefault(); + createCashout(); }} > <i18n.Translate>Cashout</i18n.Translate> diff --git a/packages/demobank-ui/src/pages/business/ShowCashoutDetails.tsx b/packages/demobank-ui/src/pages/business/ShowCashoutDetails.tsx index 478d631fb..589e29793 100644 --- a/packages/demobank-ui/src/pages/business/ShowCashoutDetails.tsx +++ b/packages/demobank-ui/src/pages/business/ShowCashoutDetails.tsx @@ -17,93 +17,99 @@ import { Amounts, HttpStatusCode, TalerError, - TalerErrorCode, - TranslatedString + assertUnreachable, } from "@gnu-taler/taler-util"; import { Attention, Loading, - LocalNotificationBanner, - ShowInputErrorLabel, - useLocalNotification, - useTranslationContext + useTranslationContext, } from "@gnu-taler/web-util/browser"; import { format } from "date-fns"; -import { Fragment, VNode, h } from "preact"; -import { useState } from "preact/hooks"; -import { mutate } from "swr"; +import { VNode, h } from "preact"; import { ErrorLoadingWithDebug } from "../../components/ErrorLoadingWithDebug.js"; -import { useBankCoreApiContext } from "../../context/config.js"; -import { useBackendState } from "../../hooks/backend.js"; -import { - useCashoutDetails, useConversionInfo -} from "../../hooks/circuit.js"; -import { - undefinedIfEmpty -} from "../../utils.js"; +import { useCashoutDetails, useConversionInfo } from "../../hooks/circuit.js"; +import { RouteDefinition } from "../../route.js"; import { RenderAmount } from "../PaytoWireTransferForm.js"; -import { assertUnreachable } from "../WithdrawalOperationPage.js"; interface Props { id: string; - onCancel: () => void; + routeClose: RouteDefinition<Record<string, never>>; } -export function ShowCashoutDetails({ - id, - onCancel, -}: Props): VNode { +export function ShowCashoutDetails({ id, routeClose }: Props): VNode { const { i18n, dateLocale } = useTranslationContext(); - const { state } = useBackendState(); - const cid = Number.parseInt(id, 10) + 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`} /> + return ( + <Attention + type="danger" + title={i18n.str`cashout id should be a number`} + /> + ); } if (!result) { - return <Loading /> + return <Loading />; } if (result instanceof TalerError) { - return <ErrorLoadingWithDebug error={result} /> + 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`Cashouts are not supported`}> - </Attention> - default: assertUnreachable(result) + 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`Cashouts are not supported`} + ></Attention> + ); + default: + assertUnreachable(result); } } if (!info) { - return <Loading /> + return <Loading />; } if (info instanceof TalerError) { - return <ErrorLoadingWithDebug error={info} /> + return <ErrorLoadingWithDebug error={info} />; } if (info.type === "fail") { switch (info.case) { case HttpStatusCode.NotImplemented: { - return <Attention type="danger" title={i18n.str`Cashout not implemented`} /> + return ( + <Attention type="danger" title={i18n.str`Cashout not implemented`} /> + ); } - default: assertUnreachable(info.case) + default: + assertUnreachable(info.case); } } - const { fiat_currency_specification, regional_currency_specification } = info.body + 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> + <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> + <dt class="text-sm text-gray-600"> + <i18n.Translate>Subject</i18n.Translate> + </dt> <dd class="text-sm ">{result.body.subject}</dd> </div> </dl> @@ -113,48 +119,64 @@ export function ShowCashoutDetails({ <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" ? + {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> + <dt class=" text-gray-600"> + <i18n.Translate>Created</i18n.Translate> + </dt> <dd class="text-sm "> - {format(result.body.creation_time.t_s * 1000, "dd/MM/yyyy HH:mm:ss", { locale: dateLocale })} + {format( + result.body.creation_time.t_s * 1000, + "dd/MM/yyyy HH:mm:ss", + { locale: dateLocale }, + )} </dd> </div> - : undefined} + ) : 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> + <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} /> + <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> - + <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} /> + <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" }}> - <button type="button" class="text-sm font-semibold leading-6 text-gray-900" - onClick={onCancel} + <a + href={routeClose.url({})} + class="text-sm font-semibold leading-6 text-gray-900" > - <i18n.Translate>Cancel</i18n.Translate></button> + <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 index 46111425e..d04a1515d 100644 --- a/packages/demobank-ui/src/pages/rnd.ts +++ b/packages/demobank-ui/src/pages/rnd.ts @@ -1,5 +1,19 @@ -import { createEddsaKeyPair, encodeCrock, getRandomBytes } from "@gnu-taler/taler-util" +/* + 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", @@ -1526,8 +1540,8 @@ const noun = [ "tomorrow", "wake", "wrap", - "yesterday" -] + "yesterday", +]; const adj = [ "abandoned", @@ -2877,17 +2891,17 @@ const adj = [ "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) +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] - } + second: noun[n], + }; } export function getRandomPassword(): string { - return encodeCrock(getRandomBytes(16)) -}
\ No newline at end of file + return encodeCrock(getRandomBytes(16)); +} |