commit e09939cfe18f7fe29b8da45c8f5b68ffcf8c0e25 parent 8803e1ca647bf6f05388c58df2906e5768bb58fb Author: Sebastian <sebasjm@taler-systems.com> Date: Mon, 1 Jun 2026 11:16:19 -0300 several fixes and improvements better error reporting (user can copy the content) normalize safeHandler and MFA simplify button only one notificaton handler prevent printing sensitive info Diffstat:
38 files changed, 1458 insertions(+), 1170 deletions(-)
diff --git a/packages/bank-ui/src/Routing.tsx b/packages/bank-ui/src/Routing.tsx @@ -15,13 +15,11 @@ */ import { - LocalNotificationBanner, urlPattern, useBankCoreApiContext, - useChallengeHandler, useCurrentLocation, - useLocalNotificationBetter, useNavigationContext, + useNotificationContext, useTranslationContext, } from "@gnu-taler/web-util/browser"; import { Fragment, VNode, h } from "preact"; @@ -33,34 +31,34 @@ import { TalerErrorCode, TokenRequest, assertUnreachable, - createRFC8959AccessTokenEncoded, + createRFC8959AccessTokenEncoded } from "@gnu-taler/taler-util"; import { useEffect } from "preact/hooks"; +import { useBankChallengeHandlerContext } from "./context/challenge.js"; import { useRefreshSessionBeforeExpires, useSessionState, } from "./hooks/session.js"; -import { AccountPage } from "./pages/AccountPage/index.js"; -import { BankFrame } from "./pages/BankFrame.js"; -import { ConversionRateClassDetails } from "./pages/ConversionRateClassDetails.js"; -import { LoginForm, SESSION_DURATION } from "./pages/LoginForm.js"; -import { NewConversionRateClass } from "./pages/NewConversionRateClass.js"; -import { PublicHistoriesPage } from "./pages/PublicHistoriesPage.js"; -import { RegistrationPage } from "./pages/RegistrationPage.js"; -import { ShowNotifications } from "./pages/ShowNotifications.js"; -import { SolveMFAChallenges } from "./pages/SolveMFA.js"; -import { WireTransfer } from "./pages/WireTransfer.js"; -import { WithdrawalOperationPage } from "./pages/WithdrawalOperationPage.js"; import { CashoutListForAccount } from "./pages/account/CashoutListForAccount.js"; import { ShowAccountDetails } from "./pages/account/ShowAccountDetails.js"; import { UpdateAccountPassword } from "./pages/account/UpdateAccountPassword.js"; +import { AccountPage } from "./pages/AccountPage/index.js"; import { AdminHome } from "./pages/admin/AdminHome.js"; import { CreateNewAccount } from "./pages/admin/CreateNewAccount.js"; import { DownloadStats } from "./pages/admin/DownloadStats.js"; import { RemoveAccount } from "./pages/admin/RemoveAccount.js"; +import { BankFrame } from "./pages/BankFrame.js"; +import { ConversionRateClassDetails } from "./pages/ConversionRateClassDetails.js"; +import { LoginForm, SESSION_DURATION } from "./pages/LoginForm.js"; +import { NewConversionRateClass } from "./pages/NewConversionRateClass.js"; +import { PublicHistoriesPage } from "./pages/PublicHistoriesPage.js"; import { ConversionConfig } from "./pages/regional/ConversionConfig.js"; import { CreateCashout } from "./pages/regional/CreateCashout.js"; import { ShowCashoutDetails } from "./pages/regional/ShowCashoutDetails.js"; +import { RegistrationPage } from "./pages/RegistrationPage.js"; +import { ShowNotifications } from "./pages/ShowNotifications.js"; +import { WireTransfer } from "./pages/WireTransfer.js"; +import { WithdrawalOperationPage } from "./pages/WithdrawalOperationPage.js"; const TALER_SCREEN_ID = 100; @@ -117,9 +115,9 @@ function PublicRounting({ const { navigateTo } = useNavigationContext(); const { config, lib } = useBankCoreApiContext(); - const [notification, safeFunctionHandler] = useLocalNotificationBetter(); + const { actionHandler, showError } = useNotificationContext(); - const mfa = useChallengeHandler(); + const mfa = useBankChallengeHandlerContext(); useEffect(() => { if (location === undefined) { @@ -133,9 +131,9 @@ function PublicRounting({ refreshable: true, } as TokenRequest; - const login = safeFunctionHandler( - i18n.str`login`, - (username: string, password: string, challengeIds: string[]) => + // i18n.str`login`, + const login = actionHandler( + (ct, username: string, password: string, challengeIds: string[]) => lib.bank.createAccessToken( username, { type: "basic", username, password }, @@ -151,12 +149,18 @@ function PublicRounting({ AbsoluteTime.fromProtocolTimestamp(success.expiration), ); - login.onFail = (fail, username) => { + login.onFail = showError(i18n.str`Failed to login.`, (fail, username) => { switch (fail.case) { - case HttpStatusCode.Accepted: { - mfa.onChallengeRequired(fail.body); - return i18n.str`A second factor authentication is required.`; - } + case HttpStatusCode.Accepted: + mfa.onNewChallenge( + i18n.str`Identity verification.`, + username, + fail.body, + login.lambda((prev, next) => + !prev ? undefined : [prev[0], prev[1], next[0]], + ), + ); + return undefined; case HttpStatusCode.Unauthorized: return i18n.str`Wrong credentials for "${username}"`; case TalerErrorCode.GENERIC_FORBIDDEN: @@ -168,24 +172,8 @@ function PublicRounting({ default: assertUnreachable(fail); } - }; - - const repeatLogin = login.lambda((ids: string[]) => { - return [login.args![0], login.args![1], ids]; }); - if (mfa.pendingChallenge) { - return ( - <SolveMFAChallenges - currentChallenge={mfa.pendingChallenge} - description={i18n.str`New web session`} - onCancel={mfa.doCancelChallenge} - username={login.args![0]} - onCompleted={repeatLogin} - /> - ); - } - switch (location.name) { case undefined: case "login": { @@ -214,7 +202,6 @@ function PublicRounting({ case "register": { return ( <Fragment> - <LocalNotificationBanner notification={notification} /> <RegistrationPage onRegistrationSuccesful={(usr, pwd) => { login.withArgs(usr, pwd, []).call(); diff --git a/packages/bank-ui/src/app.tsx b/packages/bank-ui/src/app.tsx @@ -27,6 +27,7 @@ import { BankApiProvider, BrowserHashNavigationProvider, Loading, + NotificationProvider, TalerWalletIntegrationBrowserProvider, TranslationProvider, } from "@gnu-taler/web-util/browser"; @@ -50,7 +51,11 @@ import { import { strings } from "./i18n/strings.js"; import { BankFrame } from "./pages/BankFrame.js"; import { UiSettings, fetchSettings } from "./settings.js"; +import { BankChallengeHandlerProvider } from "./context/challenge.js"; +import { SolveChallengeDialog } from "./pages/SolveMFA.js"; + const WITH_LOCAL_STORAGE_CACHE = false; + export function App() { const [settings, setSettings] = useState<UiSettings>(); useEffect(() => { @@ -62,7 +67,9 @@ export function App() { return ( <SettingsProvider value={settings}> <TranslationProvider source={strings}> - <SubApp baseUrl={baseUrl} /> + <NotificationProvider> + <SubApp baseUrl={baseUrl} /> + </NotificationProvider> </TranslationProvider> </SettingsProvider> ); @@ -232,11 +239,15 @@ function SubApp({ baseUrl }: { baseUrl: string }) { keepPreviousData: true, }} > - <TalerWalletIntegrationBrowserProvider> - <BrowserHashNavigationProvider> - <Routing /> - </BrowserHashNavigationProvider> - </TalerWalletIntegrationBrowserProvider> + <BankChallengeHandlerProvider> + <TalerWalletIntegrationBrowserProvider> + <BrowserHashNavigationProvider> + <SolveChallengeDialog> + <Routing /> + </SolveChallengeDialog> + </BrowserHashNavigationProvider> + </TalerWalletIntegrationBrowserProvider> + </BankChallengeHandlerProvider> </SWRConfig> </BankApiProvider> ); diff --git a/packages/bank-ui/src/components/Cashouts/index.ts b/packages/bank-ui/src/components/Cashouts/index.ts @@ -20,6 +20,7 @@ import { TalerCoreBankErrorsByMethod, TalerCorebankApi, TalerError, + TranslatedString, } from "@gnu-taler/taler-util"; import { ErrorLoading, @@ -52,6 +53,7 @@ export namespace State { export interface LoadingUriError { status: "loading-error"; error: TalerError; + title: TranslatedString; } export interface Failed { diff --git a/packages/bank-ui/src/components/Cashouts/state.ts b/packages/bank-ui/src/components/Cashouts/state.ts @@ -17,11 +17,13 @@ import { TalerError } from "@gnu-taler/taler-util"; import { useCashouts } from "../../hooks/regional.js"; import { Props, State } from "./index.js"; +import { useTranslationContext } from "@gnu-taler/web-util/browser"; export function useComponentState({ account, routeCashoutDetails, }: Props): State { + const { i18n } = useTranslationContext(); const result = useCashouts(account); if (!result) { return { @@ -33,6 +35,7 @@ export function useComponentState({ return { status: "loading-error", error: result, + title: i18n.str`Failed to load cashouts.`, }; } if (result.type === "fail") { diff --git a/packages/bank-ui/src/components/Cashouts/views.tsx b/packages/bank-ui/src/components/Cashouts/views.tsx @@ -81,7 +81,12 @@ export function ReadyView({ if (!conversionResp) { return <Loading />; } else if (conversionResp instanceof TalerError) { - return <ErrorLoading error={conversionResp} />; + return ( + <ErrorLoading + error={conversionResp} + title={i18n.str`Failed to load conversion rate information.`} + /> + ); } else if (conversionResp.type === "fail") { switch (conversionResp.case) { case HttpStatusCode.NotImplemented: { diff --git a/packages/bank-ui/src/components/Transactions/index.ts b/packages/bank-ui/src/components/Transactions/index.ts @@ -14,7 +14,7 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ -import { AbsoluteTime, AmountJson, TalerError } from "@gnu-taler/taler-util"; +import { AbsoluteTime, AmountJson, TalerError, TranslatedString } from "@gnu-taler/taler-util"; import { ErrorLoading, Loading, @@ -48,6 +48,7 @@ export namespace State { export interface LoadingUriError { status: "loading-error"; error: TalerError; + title: TranslatedString; } export interface BaseInfo { diff --git a/packages/bank-ui/src/components/Transactions/state.ts b/packages/bank-ui/src/components/Transactions/state.ts @@ -23,12 +23,14 @@ import { } from "@gnu-taler/taler-util"; import { useTransactions } from "../../hooks/account.js"; import { Props, State, Transaction } from "./index.js"; +import { useTranslationContext } from "@gnu-taler/web-util/browser"; export function useComponentState({ account, routeCreateWireTransfer, }: Props): State { const result = useTransactions(account); + const { i18n } = useTranslationContext(); if (!result) { return { status: "loading", @@ -39,6 +41,7 @@ export function useComponentState({ return { status: "loading-error", error: result, + title: i18n.str`Failed to load transactions.`, }; } if (result.type === "fail") { diff --git a/packages/bank-ui/src/context/challenge.ts b/packages/bank-ui/src/context/challenge.ts @@ -0,0 +1,145 @@ +/* + 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 { + Challenge, + ChallengeRequestResponse, + ChallengeResponse, + TranslatedString +} from "@gnu-taler/taler-util"; +import { + SafeHandler, + useBankCoreApiContext, +} from "@gnu-taler/web-util/browser"; +import { ComponentChildren, createContext, h, VNode } from "preact"; +import { useContext, useState } from "preact/hooks"; + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ + +export type ContextType = { + pending?: MfaState; + cancel(): void; + onNewChallenge( + operation: TranslatedString, + username: string, + challenge: ChallengeResponse, + handler: SafeHandler<[string[]], any>, + ): void; +}; + +const initial: ContextType = { + onNewChallenge: () => { + throw Error("BankChallengeHandlerProvider not initialized"); + }, + cancel: () => { + throw Error("BankChallengeHandlerProvider not initialized"); + }, +}; +const Context = createContext<ContextType>(initial); + +export const useBankChallengeHandlerContext = (): ContextType => + useContext(Context); + +type MfaState = { + requirement: ChallengeResponse; + loadingFirstChallenge: boolean; + username: string; + title: TranslatedString; + retry: SafeHandler<[string[]], any>; + initial?: { request: Challenge; response?: ChallengeRequestResponse }; +}; + +export const BankChallengeHandlerProvider = ({ + children, +}: { + children: ComponentChildren; +}): VNode => { + const [state, setState] = useState<MfaState>(); + const { lib } = useBankCoreApiContext(); + + /** + * Check the response of the handler and + * expect a MFA response and verify: + * - if there is only one challenge + * - or multiple challenge and all needs to be completed + * then start for the first challenge right away. + * + * The first challenge should always be the cheaper based on server + * selection. + * + */ + async function onNewChallenge( + operation: TranslatedString, + username: string, + requirement: ChallengeResponse, + handler: SafeHandler<[string[]], any>, + ) { + const loadingFirstChallenge = + requirement.combi_and === true || requirement.challenges.length === 1; + + handler.addListener((ev) => { + if (ev === "success") { + setState(undefined); + } + }); + + // Set the sate now, if "LFC" is true "initial" is undefined it means "loading" + setState({ + username, + title: operation, + requirement, + retry: handler, + loadingFirstChallenge, + }); + + if (loadingFirstChallenge) { + const challenge = requirement.challenges[0]; + const result = await lib.bank.sendChallenge( + username, + challenge.challenge_id, + ); + + setState({ + username, + title: operation, + retry: handler, + requirement, + loadingFirstChallenge, + initial: { + request: challenge, + response: result.type === "ok" ? result.body : undefined, + }, + }); + } + } + + function cancel() { + setState(undefined); + } + + return h(Context.Provider, { + value: { + pending: state, + cancel, + onNewChallenge, + }, + children, + }); +}; + diff --git a/packages/bank-ui/src/pages/AccountPage/index.ts b/packages/bank-ui/src/pages/AccountPage/index.ts @@ -19,6 +19,7 @@ import { AmountJson, TalerCorebankApi, TalerError, + TranslatedString, } from "@gnu-taler/taler-util"; import { ErrorLoading, @@ -72,6 +73,7 @@ export namespace State { export interface LoadingError { status: "loading-error"; error: TalerError; + title: TranslatedString; } export interface BaseInfo { diff --git a/packages/bank-ui/src/pages/AccountPage/state.ts b/packages/bank-ui/src/pages/AccountPage/state.ts @@ -25,6 +25,7 @@ import { import { useAccountDetails } from "../../hooks/account.js"; import { IntAmounts } from "../regional/CreateCashout.js"; import { Props, State } from "./index.js"; +import { useTranslationContext } from "@gnu-taler/web-util/browser"; export function useComponentState({ account, @@ -40,7 +41,7 @@ export function useComponentState({ routeClose, }: Props): State { const result = useAccountDetails(account); - + const { i18n } = useTranslationContext(); if (!result) { return { status: "loading", @@ -52,6 +53,7 @@ export function useComponentState({ return { status: "loading-error", error: result, + title: i18n.str`Failed to load account details.`, }; } diff --git a/packages/bank-ui/src/pages/BankFrame.tsx b/packages/bank-ui/src/pages/BankFrame.tsx @@ -19,7 +19,6 @@ import { Amounts, ObservabilityEventType, TalerError, - TranslatedString, assertUnreachable, } from "@gnu-taler/taler-util"; import { @@ -29,14 +28,13 @@ import { RenderAmount, RouteDefinition, ToastBanner, - notifyError, - notifyException, useBankCoreApiContext, useCommonPreferences, + useRenderErrorReport, useTranslationContext, } from "@gnu-taler/web-util/browser"; import { ComponentChildren, Fragment, VNode, h } from "preact"; -import { useEffect, useErrorBoundary, useState } from "preact/hooks"; +import { useEffect, useState } from "preact/hooks"; import { useSettingsContext } from "../context/settings.js"; import { useAccountDetails } from "../hooks/account.js"; import { useBankState } from "../hooks/bank-state.js"; @@ -73,28 +71,11 @@ export function BankFrame({ const d = useBankCoreApiContext(); const config = d === undefined ? undefined : d.config; const authenticator = d === undefined ? undefined : d.lib.bank; - const [error, resetError] = useErrorBoundary(); - useEffect(() => { - if (error) { - console.error( - `Internal error, this is mostly a bug in the application. Please report: `, - error, - ); - if (error instanceof Error) { - notifyException( - i18n.str`Internal error, please report. There should be more information in the console.`, - error, - ); - } else { - notifyError( - i18n.str`Internal error, please report.`, - String(error) as TranslatedString, - ); - } - resetError(); - } - }, [error]); + useRenderErrorReport({ + hash: __GIT_HASH__, + version: __VERSION__, + }); return ( <div @@ -207,7 +188,7 @@ export function BankFrame({ </Header> </div> - <div class="fixed z-20 top-14 w-full"> + <div class="fixed z-40 top-10 w-full"> <div class="mx-auto w-4/5"> <ToastBanner /> </div> @@ -337,7 +318,7 @@ function AppActivity(): VNode { return ( <div data-status={status} - class="fixed z-20 bottom-0 w-full ease-in-out delay-1000 transition-transform data-[status=ok]:scale-y-0" + class="fixed z-40 bottom-0 w-full ease-in-out delay-1000 transition-transform data-[status=ok]:scale-y-0" > <div data-status={status} diff --git a/packages/bank-ui/src/pages/ConversionRateClassDetails.tsx b/packages/bank-ui/src/pages/ConversionRateClassDetails.tsx @@ -10,17 +10,16 @@ import { } from "@gnu-taler/taler-util"; import { Attention, - ButtonBetter, + Button, ErrorLoading, InputText, InputToggle, Loading, - LocalNotificationBanner, RenderAmount, RouteDefinition, ShowInputErrorLabel, useBankCoreApiContext, - useLocalNotificationBetter, + useNotificationContext, useTranslationContext, } from "@gnu-taler/web-util/browser"; import { Fragment, h, VNode } from "preact"; @@ -91,7 +90,12 @@ export function ConversionRateClassDetails({ return <Loading />; } if (detailsResult instanceof TalerError) { - return <ErrorLoading error={detailsResult} />; + return ( + <ErrorLoading + error={detailsResult} + title={i18n.str`Failed to load conversion details.`} + /> + ); } if (detailsResult.type === "fail") { switch (detailsResult.case) { @@ -139,7 +143,7 @@ function Form({ const { state: credentials } = useSessionState(); const creds = credentials.status !== "loggedIn" ? undefined : credentials; const { lib, config } = useBankCoreApiContext(); - const [notification, safeFunctionHandler] = useLocalNotificationBetter(); + const { actionHandler, showError } = useNotificationContext(); const [section, setSection] = useState< "detail" | "cashout" | "cashin" | "users" | "test" | "delete" >("detail"); @@ -168,28 +172,32 @@ function Form({ ), ); - const deleteClass = safeFunctionHandler( - i18n.str`delete conversion rate class`, - (token: AccessToken) => lib.bank.deleteConversionRateClass(token, classId), + // i18n.str`delete conversion rate class`, + const deleteClass = actionHandler( + (ct, token: AccessToken) => + lib.bank.deleteConversionRateClass(token, classId), !creds || section !== "delete" || detailsResult.num_users > 0 ? undefined : [creds.token], ); deleteClass.onSuccess = onClassDeleted; - deleteClass.onFail = (fail) => { - switch (fail.case) { - case HttpStatusCode.Unauthorized: - return i18n.str`Unauthorized`; - case HttpStatusCode.Forbidden: - return i18n.str`Forbidden`; - case HttpStatusCode.NotFound: - return i18n.str`NotFound`; - case HttpStatusCode.NotImplemented: - return i18n.str`NotImplemented`; - default: - assertUnreachable(fail); - } - }; + deleteClass.onFail = showError( + i18n.str`Failed to delete conversion rate class`, + (fail) => { + switch (fail.case) { + case HttpStatusCode.Unauthorized: + return i18n.str`Unauthorized`; + case HttpStatusCode.Forbidden: + return i18n.str`Forbidden`; + case HttpStatusCode.NotFound: + return i18n.str`NotFound`; + case HttpStatusCode.NotImplemented: + return i18n.str`NotImplemented`; + default: + assertUnreachable(fail); + } + }, + ); const input: TalerCorebankApi.ConversionRateClassInput | undefined = status.status === "fail" @@ -209,30 +217,38 @@ function Form({ cashout_rounding_mode: status.result.conv.cashout_rounding_mode, }; - const updateClass = safeFunctionHandler( - i18n.str`update conversion rate class`, - lib.bank.updateConversionRateClass.bind(lib.bank), - !creds || !input ? undefined : [creds.token, classId, input], + // i18n.str`update conversion rate class`, + const updateClass = actionHandler( + ( + ct, + t: AccessToken, + id: number, + i: TalerCorebankApi.ConversionRateClassInput, + ) => lib.bank.updateConversionRateClass(t, id, i), + // !creds || !input ? undefined : ([creds.token, classId, input] as const), ); updateClass.onSuccess = () => { setSection("detail"); }; - updateClass.onFail = (fail) => { - switch (fail.case) { - case HttpStatusCode.Unauthorized: - return i18n.str`Unauthorized`; - case HttpStatusCode.Forbidden: - return i18n.str`Forbidden`; - case HttpStatusCode.NotFound: - return i18n.str`Not Found`; - case HttpStatusCode.NotImplemented: - return i18n.str`Not implemented`; - case TalerErrorCode.BANK_NAME_REUSE: - return i18n.str`The name of the conversion is already used.`; - default: - assertUnreachable(fail); - } - }; + updateClass.onFail = showError( + i18n.str`Failed to update conversion rate class.`, + (fail) => { + switch (fail.case) { + case HttpStatusCode.Unauthorized: + return i18n.str`Unauthorized`; + case HttpStatusCode.Forbidden: + return i18n.str`Forbidden`; + case HttpStatusCode.NotFound: + return i18n.str`Not Found`; + case HttpStatusCode.NotImplemented: + return i18n.str`Not implemented`; + case TalerErrorCode.BANK_NAME_REUSE: + return i18n.str`The name of the conversion is already used.`; + default: + assertUnreachable(fail); + } + }, + ); const updateRequest: TalerCorebankApi.ConversionRateClassInput | undefined = status.status === "fail" @@ -252,101 +268,46 @@ function Form({ cashout_rounding_mode: status.result.conv.cashout_rounding_mode, }; - const updateDetails = updateClass.lambda( - ( - t: AccessToken, - id: number, - r: TalerCorebankApi.ConversionRateClassInput, - ) => [t, id, r], + const updateDetails = !creds || - !updateRequest || - section !== "detail" || - status.errors?.name || - status.errors?.description || - (status.result.name === initalState.name && - status.result.description === initalState.description) - ? undefined - : [creds.token, classId, updateRequest], - ); - - // const doUpdateDetails1 = - // !creds || - // section !== "detail" || - // status.errors?.name || - // status.errors?.description || - // (status.result.name === initalState.name && - // status.result.description === initalState.description) - // ? undefined - // : doUpdateClass2; - - const updateCashin = updateClass.lambda( - ( - t: AccessToken, - id: number, - r: TalerCorebankApi.ConversionRateClassInput, - ) => [t, id, r], + !updateRequest || + section !== "detail" || + status.errors?.name || + status.errors?.description || + (status.result.name === initalState.name && + status.result.description === initalState.description) + ? updateClass + : updateClass.withArgs(creds.token, classId, updateRequest); + + const updateCashin = !creds || - !updateRequest || - section !== "cashin" || - status.errors?.conv?.cashin_fee || - status.errors?.conv?.cashin_min_amount || - status.errors?.conv?.cashin_ratio || - status.errors?.conv?.cashin_rounding_mode - ? undefined - : [creds.token, classId, updateRequest], - ); - // const doUpdateCashin1 = - // !creds || - // section !== "cashin" || - // status.errors?.conv?.cashin_fee || - // status.errors?.conv?.cashin_min_amount || - // status.errors?.conv?.cashin_ratio || - // status.errors?.conv?.cashin_rounding_mode - // ? undefined - // : doUpdateClass2; - - const updateCashout = updateClass.lambda( - ( - t: AccessToken, - id: number, - r: TalerCorebankApi.ConversionRateClassInput, - ) => [t, id, r], + !updateRequest || + section !== "cashin" || + status.errors?.conv?.cashin_fee || + status.errors?.conv?.cashin_min_amount || + status.errors?.conv?.cashin_ratio || + status.errors?.conv?.cashin_rounding_mode + ? updateClass + : updateClass.withArgs(creds.token, classId, updateRequest); + + const updateCashout = !creds || - !updateRequest || - section !== "cashout" || - // no errors on fields - status.errors?.conv?.cashout_fee || - status.errors?.conv?.cashout_min_amount || - status.errors?.conv?.cashout_ratio || - status.errors?.conv?.cashout_rounding_mode || - // at least on field changed - (status.result?.conv?.cashout_fee === initalState.conv.cashout_fee && - status.result?.conv?.cashout_min_amount === - initalState.conv.cashout_min_amount && - status.result?.conv?.cashout_ratio === initalState.conv.cashout_ratio && - status.result?.conv?.cashout_rounding_mode === - initalState.conv.cashout_rounding_mode) - ? undefined - : [creds.token, classId, updateRequest], - ); - - // const doUpdateCashout1 = - // !creds || - // section !== "cashout" || - // // no errors on fields - // status.errors?.conv?.cashout_fee || - // status.errors?.conv?.cashout_min_amount || - // status.errors?.conv?.cashout_ratio || - // status.errors?.conv?.cashout_rounding_mode || - // // at least on field changed - // (status.result?.conv?.cashout_fee === initalState.conv.cashout_fee && - // status.result?.conv?.cashout_min_amount === - // initalState.conv.cashout_min_amount && - // status.result?.conv?.cashout_ratio === initalState.conv.cashout_ratio && - // status.result?.conv?.cashout_rounding_mode === - // initalState.conv.cashout_rounding_mode) - // ? undefined - // : doUpdateClass2; + !updateRequest || + section !== "cashout" || + // no errors on fields + status.errors?.conv?.cashout_fee || + status.errors?.conv?.cashout_min_amount || + status.errors?.conv?.cashout_ratio || + status.errors?.conv?.cashout_rounding_mode || + // at least on field changed + (status.result?.conv?.cashout_fee === initalState.conv.cashout_fee && + status.result?.conv?.cashout_min_amount === + initalState.conv.cashout_min_amount && + status.result?.conv?.cashout_ratio === initalState.conv.cashout_ratio && + status.result?.conv?.cashout_rounding_mode === + initalState.conv.cashout_rounding_mode) + ? updateClass + : updateClass.withArgs(creds.token, classId, updateRequest); const default_rate = conversionInfo.conversion_rate; @@ -375,7 +336,6 @@ function Form({ return ( <div> - <LocalNotificationBanner notification={notification} /> <div class="grid grid-cols-1 gap-x-8 gap-y-8 pt-6 md:grid-cols-3 bg-gray-100 my-4 px-4 pb-4 rounded-lg"> <div class="px-4 sm:px-0"> <h2 class="text-base font-semibold leading-7 text-gray-900"> @@ -712,50 +672,50 @@ function Form({ </a> {section == "cashin" ? ( <Fragment> - <ButtonBetter + <Button submit name="update conversion" class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" onClick={updateCashin} > <i18n.Translate>Update</i18n.Translate> - </ButtonBetter> + </Button> </Fragment> ) : undefined} {section == "cashout" ? ( <Fragment> - <ButtonBetter + <Button submit name="update conversion" class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" onClick={updateCashout} > <i18n.Translate>Update</i18n.Translate> - </ButtonBetter> + </Button> </Fragment> ) : undefined} {section == "detail" ? ( <Fragment> - <ButtonBetter + <Button submit name="update conversion" class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" onClick={updateDetails} > <i18n.Translate>Update</i18n.Translate> - </ButtonBetter> + </Button> </Fragment> ) : undefined} {section == "delete" ? ( <Fragment> - <ButtonBetter + <Button submit name="update conversion" 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" onClick={deleteClass} > <i18n.Translate>Delete</i18n.Translate> - </ButtonBetter> + </Button> </Fragment> ) : undefined} </div> @@ -889,7 +849,7 @@ function TestConversionClass({ info: TalerBankConversionApi.TalerConversionInfoConfig; }): VNode { const { i18n } = useTranslationContext(); - const [notification, safeFunctionHandler] = useLocalNotificationBetter(); + const { actionHandler, showError } = useNotificationContext(); const { estimateByDebit: calculateCashoutFromDebit } = useCashoutEstimatorForClass(classId); @@ -911,9 +871,9 @@ function TestConversionClass({ const in_fee = Amounts.parseOrThrow(info.conversion_rate.cashin_fee); const out_fee = Amounts.parseOrThrow(info.conversion_rate.cashout_fee); - const calculate = safeFunctionHandler( - i18n.str`calculate cashout fee`, - async (amount: AmountJson) => { + // i18n.str`calculate cashout fee`, + const calculate = actionHandler( + async (ct, amount: AmountJson) => { const respCashin = await calculateCashinFromDebit(amount, in_fee); if (respCashin.type === "fail") { return respCashin; @@ -933,24 +893,27 @@ function TestConversionClass({ ); calculate.onSuccess = (resp) => setCalc(resp); - calculate.onFail = (fail) => { - switch (fail.case) { - case HttpStatusCode.BadRequest: - return i18n.str`The server didn't understand the request.`; - case HttpStatusCode.Conflict: - return i18n.str`The amount is too small`; - case HttpStatusCode.NotImplemented: - return i18n.str`Conversion is not implemented.`; - case TalerErrorCode.GENERIC_PARAMETER_MISSING: - return i18n.str`At least debit or credit needs to be provided`; - case TalerErrorCode.GENERIC_PARAMETER_MALFORMED: - return i18n.str`The amount is malfored`; - case TalerErrorCode.GENERIC_CURRENCY_MISMATCH: - return i18n.str`The currency is not supported`; - default: - assertUnreachable(fail); - } - }; + calculate.onFail = showError( + i18n.str`Failed to calculate the cashout fee.`, + (fail) => { + switch (fail.case) { + case HttpStatusCode.BadRequest: + return i18n.str`The server didn't understand the request.`; + case HttpStatusCode.Conflict: + return i18n.str`The amount is too small`; + case HttpStatusCode.NotImplemented: + return i18n.str`Conversion is not implemented.`; + case TalerErrorCode.GENERIC_PARAMETER_MISSING: + return i18n.str`At least debit or credit needs to be provided`; + case TalerErrorCode.GENERIC_PARAMETER_MALFORMED: + return i18n.str`The amount is malfored`; + case TalerErrorCode.GENERIC_CURRENCY_MISMATCH: + return i18n.str`The currency is not supported`; + default: + assertUnreachable(fail); + } + }, + ); useEffect(() => { calculate.call(); @@ -1154,7 +1117,12 @@ function AccountsOnConversionClass({ classId }: { classId: number }): VNode { return <Loading />; } if (userListResult instanceof TalerError) { - return <ErrorLoading error={userListResult} />; + return ( + <ErrorLoading + error={userListResult} + title={i18n.str`Failed to load users in conversion class.`} + /> + ); } if (userListResult.type === "fail") { switch (userListResult.case) { diff --git a/packages/bank-ui/src/pages/LoginForm.tsx b/packages/bank-ui/src/pages/LoginForm.tsx @@ -27,22 +27,20 @@ import { import { dummyHttpResponse } from "@gnu-taler/taler-util/http"; import { Attention, - ButtonBetter, - LocalNotificationBanner, + Button, RouteDefinition, ShowInputErrorLabel, useBankCoreApiContext, - useChallengeHandler, - useLocalNotificationBetter, + useNotificationContext, useTranslationContext, } from "@gnu-taler/web-util/browser"; import { VNode, h } from "preact"; import { useState } from "preact/hooks"; +import { useBankChallengeHandlerContext } from "../context/challenge.js"; import { useSessionState } from "../hooks/session.js"; import { undefinedIfEmpty } from "../utils.js"; import { doAutoFocus } from "./PaytoWireTransferForm.js"; import { USERNAME_REGEX } from "./RegistrationPage.js"; -import { SolveMFAChallenges } from "./SolveMFA.js"; const TALER_SCREEN_ID = 104; @@ -76,12 +74,12 @@ export function LoginForm({ const [password, setPassword] = useState<string | undefined>(); const { i18n } = useTranslationContext(); const { + config, lib: { bank: api }, } = useBankCoreApiContext(); - const [notification, safeFunctionHandler] = useLocalNotificationBetter(); + const { actionHandler, showError } = useNotificationContext(); - const mfa = useChallengeHandler(); - const { config } = useBankCoreApiContext(); + const mfa = useBankChallengeHandlerContext(); const errors = undefinedIfEmpty({ username: !username @@ -92,18 +90,12 @@ export function LoginForm({ password: !password ? i18n.str`Missing password` : undefined, }); - const logout = safeFunctionHandler( - i18n.str`logout`, - async () => { - session.logOut(); - return opEmptySuccess(dummyHttpResponse); - }, - [], - ); + // i18n.str`logout`, + const logout = actionHandler(async () => { + session.logOut(); + return opEmptySuccess(dummyHttpResponse); + }, []); logout.onSuccess = session.logOut; - logout.onFail = (fail) => { - return undefined; - }; const tokenRequest = { scope: "readwrite", @@ -111,16 +103,16 @@ export function LoginForm({ refreshable: true, } as TokenRequest; - const login = safeFunctionHandler( - i18n.str`login`, - (username: string, password: string, challengeIds: string[]) => + // i18n.str`login`, + const login = actionHandler( + (ct, username: string, password: string, challengeIds?: string[]) => api.createAccessToken( username, { type: "basic", username, password }, tokenRequest, { challengeIds }, ), - !!errors ? undefined : [username!, password!, []], + !!errors ? undefined : [username!, password!], ); login.onSuccess = (result, username) => { @@ -131,12 +123,18 @@ export function LoginForm({ }); }; - login.onFail = (fail, username) => { + login.onFail = showError(i18n.str`Failed to login.`, (fail, username) => { switch (fail.case) { - case HttpStatusCode.Accepted: { - mfa.onChallengeRequired(fail.body); - return i18n.str`A second factor authentication is required.`; - } + case HttpStatusCode.Accepted: + mfa.onNewChallenge( + i18n.str`Identity verification.`, + username, + fail.body, + login.lambda((prev, next) => + !prev ? undefined : [prev[0], prev[1], next[0]], + ), + ); + return undefined; case TalerErrorCode.GENERIC_FORBIDDEN: return i18n.str`The account has no rights to login.`; case TalerErrorCode.BANK_ACCOUNT_LOCKED: @@ -148,29 +146,11 @@ export function LoginForm({ default: assertUnreachable(fail); } - }; - - const retryLogin = login.lambda((ids: string[]) => [ - login.args![0], - login.args![1], - ids, - ]); + }); - if (mfa.pendingChallenge) { - return ( - <SolveMFAChallenges - currentChallenge={mfa.pendingChallenge} - description={i18n.str`Account login.`} - onCancel={mfa.doCancelChallenge} - username={username!} - onCompleted={retryLogin} - /> - ); - } const onlyThisUser = fixedUser || session.state.status !== "loggedOut"; return ( <div class="flex min-h-full flex-col justify-center "> - <LocalNotificationBanner notification={notification} /> <div class="sm:mx-auto sm:w-full sm:max-w-sm"> {session.state.status !== "expired" ? undefined : ( <Attention title={i18n.str`Session expired`} type="warning" /> @@ -252,34 +232,33 @@ export function LoginForm({ {session.state.status !== "loggedOut" ? ( <div class="flex justify-between"> - <ButtonBetter - + <Button name="cancel" class="rounded-md bg-white-600 px-3 py-1.5 text-sm font-semibold leading-6 text-black shadow-sm hover:bg-gray-100 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-600" onClick={logout} > <i18n.Translate>Forget</i18n.Translate> - </ButtonBetter> + </Button> - <ButtonBetter + <Button submit name="check" class="rounded-md bg-indigo-600 disabled:bg-gray-300 px-3 py-1.5 text-sm font-semibold leading-6 text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" onClick={login} > <i18n.Translate>Verify</i18n.Translate> - </ButtonBetter> + </Button> </div> ) : ( <div> - <ButtonBetter + <Button submit name="login" - class="flex w-full justify-center rounded-md bg-indigo-600 disabled:bg-gray-300 px-3 py-1.5 text-sm font-semibold leading-6 text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" + 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 data-[failed=true]:hover:bg-error data-[failed=true]:bg-error" onClick={login} > <i18n.Translate>Log in</i18n.Translate> - </ButtonBetter> + </Button> </div> )} </form> diff --git a/packages/bank-ui/src/pages/NewConversionRateClass.tsx b/packages/bank-ui/src/pages/NewConversionRateClass.tsx @@ -6,12 +6,10 @@ import { TalerErrorCode, } from "@gnu-taler/taler-util"; import { - ButtonBetter, - LocalNotificationBanner, - notifyInfo, + Button, RouteDefinition, useBankCoreApiContext, - useLocalNotificationBetter, + useNotificationContext, useTranslationContext, } from "@gnu-taler/web-util/browser"; import { h, VNode } from "preact"; @@ -36,23 +34,23 @@ export function NewConversionRateClass({ lib: { bank: api }, } = useBankCoreApiContext(); - const [notification, safeFunctionHandler] = useLocalNotificationBetter(); + const { actionHandler, showError } = useNotificationContext(); const [submitData, setSubmitData] = useState< TalerCorebankApi.ConversionRateClassInput | undefined >(); - const create = safeFunctionHandler( - i18n.str`create conversion rate class`, - (token: AccessToken, data: TalerCorebankApi.ConversionRateClassInput) => + // i18n.str`create conversion rate class`, + const create = actionHandler( + (ct, token: AccessToken, data: TalerCorebankApi.ConversionRateClassInput) => api.createConversionRateClass(token, data), !submitData || !token ? undefined : [token, submitData], ); create.onSuccess = (success) => { - notifyInfo(i18n.str`Conversion rate class created.`); + // notifyInfo(i18n.str`Conversion rate class created.`); onCreated(success.conversion_rate_class_id); }; - create.onFail = (fail) => { + create.onFail = showError(i18n.str`Failed to create conversion class.`,(fail) => { switch (fail.case) { case HttpStatusCode.Unauthorized: return i18n.str`The rights to change the account are not sufficient`; @@ -67,11 +65,10 @@ export function NewConversionRateClass({ default: assertUnreachable(fail); } - }; + }); return ( <div class="grid grid-cols-1 gap-x-8 gap-y-8 pt-6 md:grid-cols-3 bg-gray-100 my-4 px-4 pb-4 rounded-lg"> - <LocalNotificationBanner notification={notification} /> <div class="px-4 sm:px-0"> <h2 class="text-base font-semibold leading-7 text-gray-900"> @@ -88,14 +85,14 @@ export function NewConversionRateClass({ > <i18n.Translate>Cancel</i18n.Translate> </a> - <ButtonBetter + <Button submit name="create" class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" onClick={create} > <i18n.Translate>Create</i18n.Translate> - </ButtonBetter> + </Button> </div> </ConversionRateClassForm> </div> diff --git a/packages/bank-ui/src/pages/OperationState/index.ts b/packages/bank-ui/src/pages/OperationState/index.ts @@ -17,11 +17,10 @@ import { AbsoluteTime, AmountJson, - PaytoUri, TalerCoreBankErrorsByMethod, - TalerCoreBankHttpClient, TalerError, - WithdrawUriResult, + TranslatedString, + WithdrawUriResult } from "@gnu-taler/taler-util"; import { ErrorLoading, @@ -31,6 +30,7 @@ import { } from "@gnu-taler/web-util/browser"; import { VNode } from "preact"; +import { Paytos } from "@gnu-taler/taler-util"; import { useComponentState } from "./state.js"; import { AbortedView, @@ -42,7 +42,6 @@ import { NeedConfirmationView, ReadyView, } from "./views.js"; -import { Paytos } from "@gnu-taler/taler-util"; export interface Props { routeClose: RouteDefinition; @@ -76,6 +75,7 @@ export namespace State { export interface LoadingError { status: "loading-error"; error: TalerError; + title: TranslatedString; } /** diff --git a/packages/bank-ui/src/pages/OperationState/state.ts b/packages/bank-ui/src/pages/OperationState/state.ts @@ -25,7 +25,7 @@ import { TalerUris, assertUnreachable, } from "@gnu-taler/taler-util"; -import { useBankCoreApiContext, utils } from "@gnu-taler/web-util/browser"; +import { useBankCoreApiContext, useTranslationContext, utils } from "@gnu-taler/web-util/browser"; import { useEffect, useState } from "preact/hooks"; import { useSettingsContext } from "../../context/settings.js"; import { useWithdrawalDetails } from "../../hooks/account.js"; @@ -111,6 +111,7 @@ export function useComponentState({ return (): utils.RecursiveState<State> => { const result = useWithdrawalDetails(withdrawalOperationId); + const {i18n} = useTranslationContext() const shouldCreateNewOperation = result && @@ -134,6 +135,7 @@ export function useComponentState({ return { status: "loading-error", error: result, + title: i18n.str`Failed to load withdrawal details.` }; } diff --git a/packages/bank-ui/src/pages/OperationState/views.tsx b/packages/bank-ui/src/pages/OperationState/views.tsx @@ -24,13 +24,10 @@ import { } from "@gnu-taler/taler-util"; import { Attention, - ButtonBetter, - LocalNotificationBanner, - notifyInfo, + Button, RenderAmount, useBankCoreApiContext, - useChallengeHandler, - useLocalNotificationBetter, + useNotificationContext, useTalerWalletIntegrationAPI, useTranslationContext, } from "@gnu-taler/web-util/browser"; @@ -40,7 +37,7 @@ import { QR } from "../../components/QR.js"; import { usePreferences } from "../../hooks/preferences.js"; import { LoggedIn, useSessionState } from "../../hooks/session.js"; -import { SolveMFAChallenges } from "../SolveMFA.js"; +import { useBankChallengeHandlerContext } from "../../context/challenge.js"; import { ShouldBeSameUser } from "../WithdrawalConfirmationQuestion.js"; import { State } from "./index.js"; @@ -69,11 +66,11 @@ export function NeedConfirmationView({ }: State.NeedConfirmation) { const { i18n } = useTranslationContext(); const [settings] = usePreferences(); - const [notification, safeFunctionHandler] = useLocalNotificationBetter(); + const { actionHandler, showError } = useNotificationContext(); + const mfa = useBankChallengeHandlerContext(); const { state: credentials } = useSessionState(); const creds = credentials.status !== "loggedIn" ? undefined : credentials; - const mfa = useChallengeHandler(); const { config, lib: { bank }, @@ -83,80 +80,76 @@ export function NeedConfirmationView({ ? Amounts.zeroOfCurrency(config.currency) : Amounts.parseOrThrow(config.wire_transfer_fees); - const abort = safeFunctionHandler( - i18n.str`abort withdrawal`, - (creds: LoggedIn) => bank.abortWithdrawalById(creds, operationId), - !creds ? undefined : [creds], + // i18n.str`abort withdrawal`, + const abort = actionHandler( + (ct, creds: LoggedIn) => bank.abortWithdrawalById(creds, operationId), + !creds ? undefined : ([creds] as const), ); abort.onSuccess = onAbort; - abort.onFail = (fail) => { - switch (fail.case) { - case HttpStatusCode.Conflict: - return i18n.str`The reserve operation has been confirmed previously and can't be aborted`; - case HttpStatusCode.BadRequest: - return i18n.str`The operation ID is invalid.`; - case HttpStatusCode.NotFound: - return i18n.str`The operation was not found.`; - default: - assertUnreachable(fail); - } - }; + abort.onFail = showError( + i18n.str`Failed to abort the withdrawal.`, + (fail) => { + switch (fail.case) { + case HttpStatusCode.BadRequest: + return i18n.str`The server did not understand the request.`; + case HttpStatusCode.NotFound: + return i18n.str`The operation was not found.`; + case HttpStatusCode.Conflict: + return i18n.str`The withdrawal operation has been confirmed previously and can not be aborted.`; + default: + assertUnreachable(fail); + } + }, + ); - const confirm = safeFunctionHandler( - i18n.str`confirm withdrawal`, - (creds: LoggedIn, challengeIds: string[]) => + // i18n.str`confirm withdrawal`, + const confirm = actionHandler( + (ct, creds: LoggedIn, challengeIds?: string[]) => bank.confirmWithdrawalById(creds, {}, operationId, { challengeIds }), - !creds ? undefined : [creds, []], + !creds ? undefined : ([creds, undefined as string[] | undefined] as const), ); confirm.onSuccess = () => { if (!settings.showWithdrawalSuccess) { - notifyInfo(i18n.str`Wire transfer completed!`); + // notifyInfo(i18n.str`Wire transfer completed!`); } onAbort(); }; - confirm.onFail = (fail) => { - switch (fail.case) { - case TalerErrorCode.BANK_CONFIRM_ABORT_CONFLICT: - return i18n.str`The withdrawal has been aborted previously and can't be confirmed`; - case TalerErrorCode.BANK_CONFIRM_INCOMPLETE: - return i18n.str`The withdrawal operation can't be confirmed before a wallet accepted the transaction.`; - case HttpStatusCode.BadRequest: - return i18n.str`The operation ID is invalid.`; - case HttpStatusCode.NotFound: - return i18n.str`The operation was not found.`; - case TalerErrorCode.BANK_UNALLOWED_DEBIT: - return i18n.str`Your balance is not sufficient for the operation.`; - case HttpStatusCode.Accepted: { - mfa.onChallengeRequired(fail.body); - return i18n.str`A second factor authentication is required.`; + confirm.onFail = showError( + i18n.str`Failed to confirm the withdrawal.`, + (fail, creds) => { + switch (fail.case) { + case HttpStatusCode.Accepted: + mfa.onNewChallenge( + i18n.str`Withdrawal confirmation`, + creds.username, + fail.body, + confirm.lambda((prev, next) => + !prev ? undefined : [prev[0], next[0]], + ), + ); + return undefined; + case HttpStatusCode.BadRequest: + return i18n.str`The server did not understand the request.`; + case HttpStatusCode.NotFound: + return i18n.str`The operation was not found.`; + case TalerErrorCode.BANK_UNALLOWED_DEBIT: + return i18n.str`The account does not have sufficient funds or the amount is outside the limits.`; + case TalerErrorCode.BANK_CONFIRM_ABORT_CONFLICT: + return i18n.str`The withdrawal has been aborted and can not be confirmed.`; + case TalerErrorCode.BANK_CONFIRM_INCOMPLETE: + return i18n.str`The withdrawal has no exchange and reserve public selected.`; + case TalerErrorCode.BANK_AMOUNT_DIFFERS: + return i18n.str`The starting withdrawal amount and the confirmation amount differs.`; + case TalerErrorCode.BANK_AMOUNT_REQUIRED: + return i18n.str`The bank requires a bank account which has not been specified yet.`; + default: + assertUnreachable(fail); } - case TalerErrorCode.BANK_AMOUNT_DIFFERS: - return i18n.str`The starting withdrawal amount and the confirmation amount differs.`; - case TalerErrorCode.BANK_AMOUNT_REQUIRED: - return i18n.str`The bank requires a bank account which has not been specified yet.`; - default: - assertUnreachable(fail); - } - }; - - const repeatConfirm = confirm.lambda((ids: string[]) => { - return [confirm.args![0], ids]; - }); - if (mfa.pendingChallenge) { - return ( - <SolveMFAChallenges - currentChallenge={mfa.pendingChallenge} - description={i18n.str`Confirm withdrawal.`} - username={details.username} - onCancel={mfa.doCancelChallenge} - onCompleted={repeatConfirm} - /> - ); - } + }, + ); return ( <div class="bg-white shadow sm:rounded-lg"> - <LocalNotificationBanner notification={notification} /> <div class="px-4 py-5 sm:p-6"> <h3 class="text-base font-semibold text-gray-900"> <i18n.Translate>Confirm the withdrawal operation</i18n.Translate> @@ -392,21 +385,21 @@ export function NeedConfirmationView({ </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"> - <ButtonBetter + <Button name="cancel" class="text-sm font-semibold leading-6 text-gray-900" onClick={abort} > <i18n.Translate>Cancel</i18n.Translate> - </ButtonBetter> - <ButtonBetter + </Button> + <Button submit name="transfer" class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" onClick={confirm} > <i18n.Translate>Transfer</i18n.Translate> - </ButtonBetter> + </Button> </div> </form> </ShouldBeSameUser> @@ -555,7 +548,7 @@ export function ReadyView({ }: State.Ready): VNode { const { i18n } = useTranslationContext(); const walletInegrationApi = useTalerWalletIntegrationAPI(); - const [notification, safeFunctionHandler] = useLocalNotificationBetter(); + const { actionHandler, showError } = useNotificationContext(); const { state: credentials } = useSessionState(); const creds = credentials.status !== "loggedIn" ? undefined : credentials; @@ -573,27 +566,27 @@ export function ReadyView({ walletInegrationApi.publishTalerAction(uri); }, []); - const abort = safeFunctionHandler( - i18n.str`abort withdrawal`, - (creds: LoggedIn) => bank.abortWithdrawalById(creds, operationId), - !creds ? undefined : [creds], + // i18n.str`abort withdrawal`, + const abort = actionHandler( + (ct, creds: LoggedIn) => bank.abortWithdrawalById(creds, operationId), + !creds ? undefined : ([creds] as const), ); abort.onSuccess = onAbort; - abort.onFail = (fail) => { + abort.onFail = showError(i18n.str`Failed to abort the withdrawal`, (fail) => { switch (fail.case) { - case HttpStatusCode.Conflict: - return i18n.str`The reserve operation has been confirmed previously and can't be aborted`; case HttpStatusCode.BadRequest: - return i18n.str`The operation ID is invalid.`; + return i18n.str`The server did not understand the request.`; case HttpStatusCode.NotFound: return i18n.str`The operation was not found.`; + case HttpStatusCode.Conflict: + return i18n.str`The withdrawal operation has been confirmed previously and can not be aborted.`; + default: + assertUnreachable(fail); } - }; + }); return ( <Fragment> - <LocalNotificationBanner notification={notification} /> - <div class="bg-white shadow-xl sm:rounded-lg"> <div class="px-4 py-5 sm:p-6"> <h3 class="text-base font-semibold leading-6 text-gray-900"> @@ -619,14 +612,14 @@ export function ReadyView({ </p> </div> <div class="flex items-center justify-between gap-x-6 pt-2 mt-2 "> - <ButtonBetter + <Button name="cancel" class="text-sm font-semibold leading-6 text-gray-900" // class="inline-flex items-center rounded-md bg-white px-3 py-2 text-sm font-semibold text-black shadow-sm " onClick={abort} > <i18n.Translate>Cancel</i18n.Translate> - </ButtonBetter> + </Button> <a href={talerWithdrawUri} @@ -656,12 +649,12 @@ export function ReadyView({ </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"> - <ButtonBetter + <Button class="text-sm font-semibold leading-6 text-gray-900" onClick={abort} > <i18n.Translate>Cancel</i18n.Translate> - </ButtonBetter> + </Button> </div> </div> </Fragment> diff --git a/packages/bank-ui/src/pages/PaytoWireTransferForm.tsx b/packages/bank-ui/src/pages/PaytoWireTransferForm.tsx @@ -29,23 +29,20 @@ import { assertUnreachable, } from "@gnu-taler/taler-util"; import { - ButtonBetter, + Button, InternationalizationAPI, - LocalNotificationBanner, RenderAmount, RouteDefinition, ShowInputErrorLabel, - notifyInfo, useBankCoreApiContext, - useChallengeHandler, - useLocalNotificationBetter, + useNotificationContext, useTranslationContext, } from "@gnu-taler/web-util/browser"; import { ComponentChildren, Fragment, Ref, VNode, h } from "preact"; import { useState } from "preact/hooks"; +import { useBankChallengeHandlerContext } from "../context/challenge.js"; import { LoggedIn, useSessionState } from "../hooks/session.js"; import { undefinedIfEmpty, validateIBAN, validateTalerBank } from "../utils.js"; -import { SolveMFAChallenges } from "./SolveMFA.js"; import { IntAmountJson, IntAmounts } from "./regional/CreateCashout.js"; const TALER_SCREEN_ID = 106; @@ -55,7 +52,7 @@ export interface Props { withAccount?: string; withSubject?: string; withAmount?: string; - onSuccess: () => void; + onSuccess?: () => void; routeCancel?: RouteDefinition; routeCashout?: RouteDefinition; limit: IntAmountJson; @@ -105,9 +102,8 @@ export function PaytoWireTransferForm({ const parsedAmount = Amounts.parse( `${limitWithFee.currency}:${trimmedAmountStr}`, ); - const [notification, safeFunctionHandler] = useLocalNotificationBetter(); - - const mfa = useChallengeHandler(); + const { actionHandler, showError } = useNotificationContext(); + const mfa = useBankChallengeHandlerContext(); const paytoType = config.wire_type === "X_TALER_BANK" @@ -178,13 +174,14 @@ export function PaytoWireTransferForm({ } const sAmount = sendingAmount; - const send = safeFunctionHandler( - i18n.str`send transaction`, + // i18n.str`send transaction`, + const send = actionHandler( ( + ct, creds: LoggedIn, amount: AmountString, uri: Paytos.URI, - challengeIds: string[], + challengeIds?: string[], ) => api.createTransaction( creds, @@ -196,60 +193,58 @@ export function PaytoWireTransferForm({ !parsedURI || credentials.status !== "loggedIn" ? undefined - : [credentials, sAmount, parsedURI, []], + : ([ + credentials, + sAmount, + parsedURI, + undefined as string[] | undefined, + ] as const), ); send.onSuccess = (success) => { - notifyInfo(i18n.str`The wire transfer was successfully completed!`); - onSuccess(); + // notifyInfo(i18n.str`The wire transfer was successfully completed!`); + if (onSuccess) onSuccess(); setAmount(undefined); setAccount(undefined); setSubject(undefined); rawPaytoInputSetter(undefined); }; - send.onFail = (fail, creds, amount, uri) => { - switch (fail.case) { - case HttpStatusCode.BadRequest: - return i18n.str`The request was invalid or the payto://-URI used unacceptable features.`; - case HttpStatusCode.Unauthorized: - return i18n.str`Not enough permission to complete the operation.`; - case TalerErrorCode.BANK_ADMIN_CREDITOR: - return i18n.str`The bank administrator cannot be the transfer creditor.`; - case TalerErrorCode.BANK_UNKNOWN_CREDITOR: - return i18n.str`The destination account "${uri.displayName}" was not found.`; - case TalerErrorCode.BANK_SAME_ACCOUNT: - return i18n.str`The origin and the destination of the transfer can't be the same.`; - case TalerErrorCode.BANK_UNALLOWED_DEBIT: - return i18n.str`Your balance is not sufficient for the operation.`; - case HttpStatusCode.NotFound: - return i18n.str`The origin account "${uri.displayName}" was not found.`; - case TalerErrorCode.BANK_TRANSFER_REQUEST_UID_REUSED: { - return i18n.str`The attempt to create the transaction has failed. Please try again.`; - } - case HttpStatusCode.Accepted: { - mfa.onChallengeRequired(fail.body); - return i18n.str`A second factor authentication is required.`; + send.onFail = showError( + i18n.str`Failed to create the transactions.`, + (fail, creds, amount, uri) => { + switch (fail.case) { + case HttpStatusCode.BadRequest: + return i18n.str`The request was invalid or the payto://-URI used unacceptable features.`; + case HttpStatusCode.Unauthorized: + return i18n.str`Not enough permission to complete the operation.`; + case TalerErrorCode.BANK_ADMIN_CREDITOR: + return i18n.str`The bank administrator cannot be the transfer creditor.`; + case TalerErrorCode.BANK_UNKNOWN_CREDITOR: + return i18n.str`The destination account "${uri.displayName}" was not found.`; + case TalerErrorCode.BANK_SAME_ACCOUNT: + return i18n.str`The origin and the destination of the transfer can't be the same.`; + case TalerErrorCode.BANK_UNALLOWED_DEBIT: + return i18n.str`Your balance is not sufficient for the operation.`; + case HttpStatusCode.NotFound: + return i18n.str`The origin account "${uri.displayName}" was not found.`; + case TalerErrorCode.BANK_TRANSFER_REQUEST_UID_REUSED: + return i18n.str`The attempt to create the transaction has failed. Please try again.`; + case HttpStatusCode.Accepted: + mfa.onNewChallenge( + i18n.str`Confirm transaction.`, + creds.username, + fail.body, + send.lambda((prev, next) => + !prev ? undefined : [prev[0], prev[1], prev[2], next[0]], + ), + ); + return undefined; + default: + assertUnreachable(fail); } - default: - assertUnreachable(fail); - } - }; - const repeatSend = send.lambda((ids: string[]) => { - return [send.args![0], send.args![1], send.args![2], ids]; - }); - - if (mfa.pendingChallenge) { - return ( - <SolveMFAChallenges - currentChallenge={mfa.pendingChallenge} - description={i18n.str`Confirm wire transfer.`} - onCancel={mfa.doCancelChallenge} - username={send.args![0].username} - onCompleted={repeatSend} - /> - ); - } + }, + ); return ( <div class="grid grid-cols-1 gap-x-8 gap-y-8 pt-6 my-4 md:grid-cols-3 bg-gray-100 px-4 pb-4 rounded-lg"> @@ -650,16 +645,15 @@ export function PaytoWireTransferForm({ ) : ( <div /> )} - <ButtonBetter + <Button submit name="send" class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" onClick={send} > <i18n.Translate>Send</i18n.Translate> - </ButtonBetter> + </Button> </div> - <LocalNotificationBanner notification={notification} /> </form> </div> ); diff --git a/packages/bank-ui/src/pages/QrCodeSection.tsx b/packages/bank-ui/src/pages/QrCodeSection.tsx @@ -22,12 +22,11 @@ import { WithdrawUriResult, } from "@gnu-taler/taler-util"; import { - ButtonBetter, - LocalNotificationBanner, + Button, useBankCoreApiContext, - useLocalNotificationBetter, + useNotificationContext, useTalerWalletIntegrationAPI, - useTranslationContext + useTranslationContext, } from "@gnu-taler/web-util/browser"; import { Fragment, h, VNode } from "preact"; import { useEffect } from "preact/hooks"; @@ -53,21 +52,21 @@ export function QrCodeSection({ walletInegrationApi.publishTalerAction(withdrawUri); }, []); - const [notification, safeFunctionHandler] = useLocalNotificationBetter(); + const { actionHandler, showError } = useNotificationContext(); const { lib: { bank: api }, } = useBankCoreApiContext(); - const abort = safeFunctionHandler( - i18n.str`abort withdrawal`, - (creds: UserAndToken) => + // i18n.str`abort withdrawal`, + const abort = actionHandler( + (ct, creds: UserAndToken) => api.abortWithdrawalById(creds, withdrawUri.withdrawalOperationId), !creds ? undefined : [creds], ); abort.onSuccess = onAborted; - abort.onFail = (fail) => { + abort.onFail = showError(i18n.str`Failed to abort withdrawal.`, (fail) => { switch (fail.case) { case HttpStatusCode.BadRequest: return i18n.str`The operation ID is invalid.`; @@ -78,12 +77,10 @@ export function QrCodeSection({ default: assertUnreachable(fail); } - }; + }); return ( <Fragment> - <LocalNotificationBanner notification={notification} /> - <div class="bg-white shadow-xl sm:rounded-lg"> <div class="px-4 py-5 sm:p-6"> <h3 class="text-base font-semibold leading-6 text-gray-900"> @@ -109,13 +106,13 @@ export function QrCodeSection({ </p> </div> <div class="flex items-center justify-between gap-x-6 pt-2 mt-2 "> - <ButtonBetter + <Button name="cancel" class="text-sm font-semibold leading-6 text-gray-900" onClick={abort} > <i18n.Translate>Cancel</i18n.Translate> - </ButtonBetter> + </Button> <a href={talerWithdrawUri} name="withdraw" @@ -144,12 +141,12 @@ export function QrCodeSection({ </div> </div> <div class="flex items-center justify-center gap-x-6 border-t border-gray-900/10 px-4 py-4 sm:px-8"> - <ButtonBetter + <Button class="text-sm font-semibold leading-6 text-gray-900" onClick={abort} > <i18n.Translate>Cancel</i18n.Translate> - </ButtonBetter> + </Button> </div> </div> </Fragment> diff --git a/packages/bank-ui/src/pages/RegistrationPage.tsx b/packages/bank-ui/src/pages/RegistrationPage.tsx @@ -16,24 +16,23 @@ import { assertUnreachable, HttpStatusCode, + TalerCorebankApi, TalerErrorCode, } from "@gnu-taler/taler-util"; import { - ButtonBetter, - LocalNotificationBanner, + Button, RouteDefinition, ShowInputErrorLabel, useBankCoreApiContext, - useLocalNotificationBetter, + useNotificationContext, useTranslationContext, } from "@gnu-taler/web-util/browser"; -import { Fragment, VNode, h } from "preact"; +import { Fragment, h, VNode } from "preact"; import { useState } from "preact/hooks"; import { useSettingsContext } from "../context/settings.js"; import { usePreferences } from "../hooks/preferences.js"; import { undefinedIfEmpty } from "../utils.js"; -import { getRandomPassword, getRandomUsername } from "./rnd.js"; -import { TalerCorebankApi } from "@gnu-taler/taler-util"; +import { getRandomUsername } from "./rnd.js"; const TALER_SCREEN_ID = 110; @@ -82,7 +81,7 @@ function RegistrationForm({ // const [phone, setPhone] = useState<string | undefined>(); // const [email, setEmail] = useState<string | undefined>(); const [repeatPassword, setRepeatPassword] = useState<string | undefined>(); - const [notification, safeFunctionHandler] = useLocalNotificationBetter(); + const { actionHandler, showError } = useNotificationContext(); const settings = useSettingsContext(); const [pref] = usePreferences(); @@ -120,9 +119,9 @@ function RegistrationForm({ password, }; - const register = safeFunctionHandler( - i18n.str`register new account`, - (account: TalerCorebankApi.RegisterAccountRequest) => + // i18n.str`register new account`, + const register = actionHandler( + (ct, account: TalerCorebankApi.RegisterAccountRequest) => api.createAccount(undefined, account), !!errors || !reg ? undefined : [reg], ); @@ -135,42 +134,45 @@ function RegistrationForm({ onRegistrationSuccesful(acc.username, acc.password); }; - register.onFail = (fail) => { - switch (fail.case) { - case HttpStatusCode.BadRequest: - return i18n.str`Server replied with invalid phone or email.`; - case HttpStatusCode.Unauthorized: - return i18n.str`You are not authorised to create this account.`; - case TalerErrorCode.BANK_UNALLOWED_DEBIT: - return i18n.str`Registration is disabled because the bank ran out of bonus credit.`; - case TalerErrorCode.BANK_RESERVED_USERNAME_CONFLICT: - return i18n.str`That username can't be used because is reserved.`; - case TalerErrorCode.BANK_REGISTER_USERNAME_REUSE: - return i18n.str`That username is already taken.`; - case TalerErrorCode.BANK_REGISTER_PAYTO_URI_REUSE: - return i18n.str`That account ID is already taken.`; - case TalerErrorCode.BANK_MISSING_TAN_INFO: - return i18n.str`No information for the selected authentication channel.`; - case TalerErrorCode.BANK_TAN_CHANNEL_NOT_SUPPORTED: - return i18n.str`Authentication channel is not supported.`; - case TalerErrorCode.BANK_NON_ADMIN_PATCH_DEBT_LIMIT: - return i18n.str`Only an administrator is allowed to set the debt limit.`; - case TalerErrorCode.BANK_NON_ADMIN_SET_CONVERSION_RATE_CLASS: - return i18n.str`Only the administrator can change the conversion rate.`; - case TalerErrorCode.BANK_CONVERSION_RATE_CLASS_UNKNOWN: - return i18n.str`The conversion rate class doesn't exist.`; - case TalerErrorCode.BANK_NON_ADMIN_SET_TAN_CHANNEL: - return i18n.str`Only admin can create accounts with second factor authentication.`; - case TalerErrorCode.BANK_PASSWORD_TOO_SHORT: - return i18n.str`The password is too short. Can't have less than 8 characters.`; - case TalerErrorCode.BANK_PASSWORD_TOO_LONG: - return i18n.str`The password is too long. Can't have more than 64 characters.`; - default: - assertUnreachable(fail); - } - }; + register.onFail = showError( + i18n.str`Failed to create a new account.`, + (fail) => { + switch (fail.case) { + case HttpStatusCode.BadRequest: + return i18n.str`Server replied with invalid phone or email.`; + case HttpStatusCode.Unauthorized: + return i18n.str`You are not authorised to create this account.`; + case TalerErrorCode.BANK_UNALLOWED_DEBIT: + return i18n.str`Registration is disabled because the bank ran out of bonus credit.`; + case TalerErrorCode.BANK_RESERVED_USERNAME_CONFLICT: + return i18n.str`That username can't be used because is reserved.`; + case TalerErrorCode.BANK_REGISTER_USERNAME_REUSE: + return i18n.str`That username is already taken.`; + case TalerErrorCode.BANK_REGISTER_PAYTO_URI_REUSE: + return i18n.str`That account ID is already taken.`; + case TalerErrorCode.BANK_MISSING_TAN_INFO: + return i18n.str`No information for the selected authentication channel.`; + case TalerErrorCode.BANK_TAN_CHANNEL_NOT_SUPPORTED: + return i18n.str`Authentication channel is not supported.`; + case TalerErrorCode.BANK_NON_ADMIN_PATCH_DEBT_LIMIT: + return i18n.str`Only an administrator is allowed to set the debt limit.`; + case TalerErrorCode.BANK_NON_ADMIN_SET_CONVERSION_RATE_CLASS: + return i18n.str`Only the administrator can change the conversion rate.`; + case TalerErrorCode.BANK_CONVERSION_RATE_CLASS_UNKNOWN: + return i18n.str`The conversion rate class doesn't exist.`; + case TalerErrorCode.BANK_NON_ADMIN_SET_TAN_CHANNEL: + return i18n.str`Only admin can create accounts with second factor authentication.`; + case TalerErrorCode.BANK_PASSWORD_TOO_SHORT: + return i18n.str`The password is too short. Can't have less than 8 characters.`; + case TalerErrorCode.BANK_PASSWORD_TOO_LONG: + return i18n.str`The password is too long. Can't have more than 64 characters.`; + default: + assertUnreachable(fail); + } + }, + ); - const registerRandom = register.lambda(() => { + const registerRandom = register.lambda (() => { const user = getRandomUsername(); const password = "12345678"; @@ -183,8 +185,6 @@ function RegistrationForm({ return ( <Fragment> - <LocalNotificationBanner notification={notification} /> - <div class="flex min-h-full flex-col justify-center"> <div class="sm:mx-auto sm:w-full sm:max-w-sm"> <h2 class="text-center text-2xl font-bold leading-9 tracking-tight text-gray-900">{i18n.str`Account registration`}</h2> @@ -340,27 +340,27 @@ function RegistrationForm({ > <i18n.Translate>Cancel</i18n.Translate> </a> - <ButtonBetter + <Button submit name="register" class="rounded-md bg-indigo-600 disabled:bg-gray-300 px-3 py-1.5 text-sm font-semibold leading-6 text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" onClick={register} > <i18n.Translate>Register</i18n.Translate> - </ButtonBetter> + </Button> </div> </form> {settings.allowRandomAccountCreation && ( <p class="mt-10 text-center text-sm text-gray-500 border-t"> - <ButtonBetter + <Button submit name="create random" class="flex mt-4 w-full disabled:bg-gray-300 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={registerRandom} > <i18n.Translate>Create a random temporary user</i18n.Translate> - </ButtonBetter> + </Button> </p> )} </div> diff --git a/packages/bank-ui/src/pages/ShowNotifications.tsx b/packages/bank-ui/src/pages/ShowNotifications.tsx @@ -14,41 +14,9 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ -import { Time, useNotifications } from "@gnu-taler/web-util/browser"; +import { Time } from "@gnu-taler/web-util/browser"; import { VNode, h } from "preact"; export function ShowNotifications(): VNode { - const ns = useNotifications(); - if (!ns.length) { - return <div>no notifications</div>; - } - return ( - <div> - <p>Notifications</p> - <table> - <thead></thead> - <tbody> - {ns.map((n, idx) => { - return ( - <tr key={idx}> - <td> - <Time - timestamp={n.message.when} - format="dd/MM/yyyy HH:mm:ss" - /> - </td> - <td>{n.message.title}</td> - <td> - {n.message.type === "error" - ? n.message.description - : undefined} - </td> - </tr> - ); - })} - </tbody> - </table> - {/* <ToastBanner all /> */} - </div> - ); + return <div>tbd</div> } diff --git a/packages/bank-ui/src/pages/SolveMFA.tsx b/packages/bank-ui/src/pages/SolveMFA.tsx @@ -11,27 +11,28 @@ import { } from "@gnu-taler/taler-util"; import { dummyHttpResponse } from "@gnu-taler/taler-util/http"; import { - ButtonBetter, - LocalNotificationBanner, - SafeHandlerTemplate, + Button, + SafeHandler, ShowInputErrorLabel, Time, undefinedIfEmpty, useBankCoreApiContext, - useLocalNotificationBetter, + useNotificationContext, useTranslationContext, } from "@gnu-taler/web-util/browser"; -import { Fragment, h, VNode } from "preact"; +import { ComponentChildren, Fragment, h, VNode } from "preact"; import { useEffect, useState } from "preact/hooks"; +import { useBankChallengeHandlerContext } from "../context/challenge.js"; import { doAutoFocus } from "./PaytoWireTransferForm.js"; const TALER_SCREEN_ID = 9; export interface Props { - onCompleted: SafeHandlerTemplate<[challenges: string[]], any>; + onCompleted: SafeHandler<[challenges: string[]], any>; username: string; onCancel(): void; description: TranslatedString; currentChallenge: ChallengeResponse; + focus?: boolean; } function SolveChallenge({ @@ -52,7 +53,7 @@ function SolveChallenge({ const { lib: { bank: api }, } = useBankCoreApiContext(); - const [notification, safeFunctionHandler] = useLocalNotificationBetter(); + const { actionHandler, showError } = useNotificationContext(); const [showExpired, setExpired] = useState( expiration !== undefined && AbsoluteTime.isExpired(expiration), @@ -74,34 +75,35 @@ function SolveChallenge({ }; }, []); - const doVerification = safeFunctionHandler( - i18n.str`confirm MFA challenge`, - (tan: string) => + // i18n.str`confirm MFA challenge`, + const doVerification = actionHandler( + (ct, tan: string) => api.confirmChallenge(username, challenge.challenge_id, { tan }), !errors ? [tanCode!] : undefined, ); - doVerification.onFail = (fail) => { - switch (fail.case) { - case TalerErrorCode.BANK_TRANSACTION_NOT_FOUND: - return i18n.str`Unknown challenge.`; - case HttpStatusCode.Unauthorized: - return i18n.str`Failed to validate the verification code.`; - case HttpStatusCode.TooManyRequests: - return i18n.str`Too many challenges are active right now, you must wait or confirm current challenges.`; - case TalerErrorCode.BANK_TAN_CHALLENGE_FAILED: - return i18n.str`Wrong authentication number.`; - case TalerErrorCode.BANK_TAN_CHALLENGE_EXPIRED: - return i18n.str`Expired challenge.`; - default: - assertUnreachable(fail); - } - }; + doVerification.onFail = showError( + i18n.str`Faild to verify the code.`, + (fail) => { + switch (fail.case) { + case TalerErrorCode.BANK_TRANSACTION_NOT_FOUND: + return i18n.str`Unknown challenge.`; + case HttpStatusCode.Unauthorized: + return i18n.str`Failed to validate the verification code.`; + case HttpStatusCode.TooManyRequests: + return i18n.str`Too many challenges are active right now, you must wait or confirm current challenges.`; + case TalerErrorCode.BANK_TAN_CHALLENGE_FAILED: + return i18n.str`Wrong authentication number.`; + case TalerErrorCode.BANK_TAN_CHALLENGE_EXPIRED: + return i18n.str`Expired challenge.`; + default: + assertUnreachable(fail); + } + }, + ); doVerification.onSuccess = onSolved; return ( <Fragment> - <LocalNotificationBanner notification={notification} /> - <div class="grid grid-cols-1 gap-x-8 gap-y-8 pt-6 md:grid-cols-3 bg-gray-100 my-4 px-4 pb-4 rounded-lg"> <div class="px-4 sm:px-0"> <h2 class="text-base font-semibold leading-7 text-gray-900"> @@ -205,14 +207,14 @@ function SolveChallenge({ <i18n.Translate>Back</i18n.Translate> </button> - <ButtonBetter + <Button submit name="send again" class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" onClick={doVerification} > <i18n.Translate>Verify</i18n.Translate> - </ButtonBetter> + </Button> </div> </div> </div> @@ -221,9 +223,50 @@ function SolveChallenge({ ); } -export function SolveMFAChallenges({ +export function SolveChallengeDialog({ + children, +}: { + children: ComponentChildren; +}): VNode { + const mfa = useBankChallengeHandlerContext(); + + return ( + <Fragment> + <dialog + id="dialog" + // open + open={mfa.pending !== undefined} + aria-labelledby="dialog-title" + class="z-30 fixed inset-0 size-auto max-h-none max-w-none overflow-y-auto bg-transparent backdrop:bg-transparent" + > + <div class="fixed inset-0 bg-gray-500/75 transition-opacity data-closed:opacity-0 data-enter:duration-300 data-enter:ease-out data-leave:duration-200 data-leave:ease-in dark:bg-gray-900/50"></div> + <div + tabIndex={0} + class="flex min-h-full items-end justify-center p-4 text-center focus:outline-none sm:items-center sm:p-0 w-800" + > + <div class="z-40 max-w-7xl text-left"> + {!mfa.pending ? undefined : ( + <SolveMFAChallenges + currentChallenge={mfa.pending.requirement} + description={mfa.pending.title} + onCancel={mfa.cancel} + username={mfa.pending.username} + onCompleted={mfa.pending.retry} + focus + /> + )} + </div> + </div> + </dialog> + <Fragment key="childs">{children}</Fragment> + </Fragment> + ); +} + +function SolveMFAChallenges({ currentChallenge, username, + focus, description, onCompleted, onCancel, @@ -235,7 +278,7 @@ export function SolveMFAChallenges({ ch: Challenge; expiration: AbsoluteTime; }>(); - const [notification, safeFunctionHandler] = useLocalNotificationBetter(); + const { actionHandler, showError } = useNotificationContext(); const { lib: { bank: api }, @@ -253,7 +296,7 @@ export function SolveMFAChallenges({ challenge={selected.ch} expiration={selected.expiration} username={username} - onSolved={() => { + onSolved={async () => { setSelected(undefined); const total = [...solved, selected.ch.challenge_id]; const enough = currentChallenge.combi_and @@ -261,7 +304,8 @@ export function SolveMFAChallenges({ : total.length > 0; if (enough) { - onCompleted.withArgs(total).call(); + setSolved(total); + await onCompleted.withArgs(total).call(); } else { setSolved(total); } @@ -277,9 +321,9 @@ export function SolveMFAChallenges({ ? currentSolved.length === currentChallenge.challenges.length : currentSolved.length > 0; - const sendMessage = safeFunctionHandler( - i18n.str`send MFA challenge`, - (ch: Challenge) => api.sendChallenge(username, ch.challenge_id), + // i18n.str`send MFA challenge`, + const sendMessage = actionHandler((ct, ch: Challenge) => + api.sendChallenge(username, ch.challenge_id), ); sendMessage.onSuccess = (success, ch) => { if (success.earliest_retransmission) { @@ -298,45 +342,41 @@ export function SolveMFAChallenges({ }); }; - sendMessage.onFail = (fail) => { - switch (fail.case) { - case HttpStatusCode.Unauthorized: - return i18n.str`Failed to send the verification code.`; - case HttpStatusCode.Forbidden: - return i18n.str`The request was valid, but the server is refusing action.`; - case HttpStatusCode.NotFound: - return i18n.str`The backend is not aware of the specified MFA challenge.`; - case HttpStatusCode.TooManyRequests: - return i18n.str`It is too early to request another transmission of the challenge.`; - case TalerErrorCode.BANK_TAN_CHANNEL_SCRIPT_FAILED: - return i18n.str`Code transmission failed.`; - default: - assertUnreachable(fail); - } - }; + sendMessage.onFail = showError( + i18n.str`Failed to start the challenge.`, + (fail) => { + switch (fail.case) { + case HttpStatusCode.Unauthorized: + return i18n.str`Unable to send the verification code.`; + case HttpStatusCode.Forbidden: + return i18n.str`The request was valid, but the server is refusing action.`; + case HttpStatusCode.NotFound: + return i18n.str`The backend is not aware of the specified MFA challenge.`; + case HttpStatusCode.TooManyRequests: + return i18n.str`It is too early to request another transmission of the challenge.`; + case TalerErrorCode.BANK_TAN_CHANNEL_SCRIPT_FAILED: + return i18n.str`Code transmission failed.`; + default: + assertUnreachable(fail); + } + }, + ); const complete = onCompleted.withArgs(solved); - const selectChallenge = safeFunctionHandler( - i18n.str`select challenge`, - async (ch: Challenge) => { - setSelected({ - ch, - expiration: AbsoluteTime.never(), - }); - return opEmptySuccess(dummyHttpResponse); - }, - ); - selectChallenge.onFail = (fail) => { - return undefined; - }; + // i18n.str`select challenge`, + const selectChallenge = actionHandler(async (ct, ch: Challenge) => { + setSelected({ + ch, + expiration: AbsoluteTime.never(), + }); + return opEmptySuccess(dummyHttpResponse); + }); return ( <Fragment> - <LocalNotificationBanner notification={notification} /> - <div class="grid grid-cols-1 gap-x-8 gap-y-8 pt-6 md:grid-cols-3 bg-gray-100 my-4 px-4 pb-4 rounded-lg"> - <div class="px-4 sm:px-0"> + <div class="px-4 px-0"> <h2 class="text-base font-semibold leading-7 text-gray-900"> <span class="text-sm text-black font-semibold leading-6 " @@ -356,7 +396,7 @@ export function SolveMFAChallenges({ </p> </div> - <div class="bg-white shadow-sm ring-1 ring-gray-900/5 sm:rounded-xl md:col-span-2"> + <div class="bg-white shadow-sm ring-1 ring-gray-900/5 rounded-xl md:col-span-2"> <div class="px-4 mt-4 "> <div class="w-full"> <div class="border-gray-100"> @@ -430,22 +470,22 @@ export function SolveMFAChallenges({ </dt> <dd class="mt-1 text-sm leading-6 text-gray-700 sm:mt-0"> <div class="flex justify-between"> - <ButtonBetter + <Button name="cancel" class="text-sm font-semibold leading-6 text-gray-900" onClick={doSelect} > <i18n.Translate>I have a code</i18n.Translate> - </ButtonBetter> + </Button> - <ButtonBetter + <Button submit name="send again" class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" onClick={doSend} > <i18n.Translate>Send me a message</i18n.Translate> - </ButtonBetter> + </Button> </div> </dd> {alreadySent && time.t_ms !== "never" ? ( @@ -473,14 +513,14 @@ export function SolveMFAChallenges({ <i18n.Translate>Cancel</i18n.Translate> </button> - <ButtonBetter + <Button submit name="send again" class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" onClick={complete} > <i18n.Translate>Complete</i18n.Translate> - </ButtonBetter> + </Button> </div> </div> </div> diff --git a/packages/bank-ui/src/pages/WalletWithdrawForm.tsx b/packages/bank-ui/src/pages/WalletWithdrawForm.tsx @@ -26,14 +26,12 @@ import { } from "@gnu-taler/taler-util"; import { Attention, - ButtonBetter, - LocalNotificationBanner, + Button, RenderAmount, RouteDefinition, ShowInputErrorLabel, - notifyError, useBankCoreApiContext, - useLocalNotificationBetter, + useNotificationContext, useTranslationContext, } from "@gnu-taler/web-util/browser"; import { VNode, h } from "preact"; @@ -81,7 +79,7 @@ function OldWithdrawalForm({ const [amountStr, setAmountStr] = useState<string | undefined>( `${settings.defaultSuggestedAmount ?? 1}`, ); - const [notification, safeFunctionHandler] = useLocalNotificationBetter(); + const { actionHandler, showError } = useNotificationContext(); const trimmedAmountStr = amountStr?.trim(); @@ -100,9 +98,9 @@ function OldWithdrawalForm({ : undefined, }); - const start = safeFunctionHandler( - i18n.str`create withdrawal`, - (creds: UserAndToken, amount: AmountString) => + // i18n.str`create withdrawal`, + const start = actionHandler( + (ct, creds: UserAndToken, amount: AmountString) => api.createWithdrawal( creds, preference.fastWithdrawalForm @@ -117,10 +115,11 @@ function OldWithdrawalForm({ start.onSuccess = (success) => { const uri = TalerUris.fromString(success.taler_withdraw_uri); if (uri.tag === "error" || uri.value.type !== TalerUriAction.Withdraw) { - return notifyError( - i18n.str`The server replied with an invalid taler://withdraw URI`, - i18n.str`Withdraw URI: ${success.taler_withdraw_uri}`, - ); + return ; + // notifyError( + // i18n.str`The server replied with an invalid taler://withdraw URI`, + // i18n.str`Withdraw URI: ${success.taler_withdraw_uri}`, + // ); } else { updateBankState( "currentWithdrawalOperationId", @@ -130,18 +129,21 @@ function OldWithdrawalForm({ } }; - start.onFail = (fail) => { - switch (fail.case) { - case HttpStatusCode.Conflict: - return i18n.str`The operation was rejected due to insufficient funds`; - case HttpStatusCode.Unauthorized: - return i18n.str`The operation was rejected due to insufficient funds`; - case HttpStatusCode.NotFound: - return i18n.str`Account not found`; - default: - assertUnreachable(fail); - } - }; + start.onFail = showError( + i18n.str`Failed to create the withdrawal.`, + (fail) => { + switch (fail.case) { + case HttpStatusCode.Conflict: + return i18n.str`The operation was rejected due to insufficient funds`; + case HttpStatusCode.Unauthorized: + return i18n.str`The operation was rejected due to insufficient funds`; + case HttpStatusCode.NotFound: + return i18n.str`Account not found`; + default: + assertUnreachable(fail); + } + }, + ); return ( <form @@ -152,8 +154,6 @@ function OldWithdrawalForm({ 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"> @@ -252,7 +252,7 @@ function OldWithdrawalForm({ > <i18n.Translate>Cancel</i18n.Translate> </a> - <ButtonBetter + <Button submit name="continue" class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" @@ -260,7 +260,7 @@ function OldWithdrawalForm({ onClick={start} > <i18n.Translate>Continue</i18n.Translate> - </ButtonBetter> + </Button> </div> </form> ); diff --git a/packages/bank-ui/src/pages/WireTransfer.tsx b/packages/bank-ui/src/pages/WireTransfer.tsx @@ -22,7 +22,6 @@ import { import { ErrorLoading, Loading, - notifyInfo, useTranslationContext, } from "@gnu-taler/web-util/browser"; import { Fragment, VNode, h } from "preact"; @@ -61,7 +60,7 @@ export function WireTransfer({ if (result instanceof TalerError) { return ( <Fragment> - <ErrorLoading error={result} /> + <ErrorLoading error={result} title={i18n.str`Failed to load account details.`} /> <LoginForm currentUser={account} /> </Fragment> ); @@ -103,10 +102,7 @@ export function WireTransfer({ balance={positiveBalance} withSubject={withSubject} limit={limit} - onSuccess={() => { - notifyInfo(i18n.str`The wire transfer was successfully completed!`); - if (onSuccess) onSuccess(); - }} + onSuccess={onSuccess} routeCancel={routeCancel} /> </div> diff --git a/packages/bank-ui/src/pages/WithdrawalConfirmationQuestion.tsx b/packages/bank-ui/src/pages/WithdrawalConfirmationQuestion.tsx @@ -26,19 +26,17 @@ import { } from "@gnu-taler/taler-util"; import { Attention, - ButtonBetter, - LocalNotificationBanner, + Button, RenderAmount, useBankCoreApiContext, - useChallengeHandler, - useLocalNotificationBetter, - useTranslationContext, + useNotificationContext, + useTranslationContext } from "@gnu-taler/web-util/browser"; import { ComponentChildren, Fragment, VNode, h } from "preact"; import { mutate } from "swr"; +import { useBankChallengeHandlerContext } from "../context/challenge.js"; import { LoggedIn, useSessionState } from "../hooks/session.js"; import { LoginForm } from "./LoginForm.js"; -import { SolveMFAChallenges } from "./SolveMFA.js"; const TALER_SCREEN_ID = 114; @@ -55,9 +53,7 @@ interface Props { function useComponentState(opid: string) { const { state: credentials } = useSessionState(); const creds = credentials.status !== "loggedIn" ? undefined : credentials; - const [notification, safeFunctionHandler] = useLocalNotificationBetter(); - const { i18n } = useTranslationContext(); - const mfa = useChallengeHandler(); + const { actionHandler } = useNotificationContext(); const { config, @@ -69,69 +65,36 @@ function useComponentState(opid: string) { ? Amounts.zeroOfCurrency(config.currency) : Amounts.parseOrThrow(config.wire_transfer_fees); - const confirm = safeFunctionHandler( - i18n.str`confirm withdrawal`, - (creds: LoggedIn, challengeIds: string[]) => + // i18n.str`confirm withdrawal`, + const confirm = actionHandler( + (ct, creds: LoggedIn, challengeIds?: string[]) => api.confirmWithdrawalById(creds, {}, opid, { challengeIds, }), - !creds ? undefined : [creds, []], + !creds ? undefined : [creds, undefined as string[] | undefined] as const, ); confirm.onSuccess = () => { mutate(() => true); // clean any info that we have }; - confirm.onFail = (fail) => { - switch (fail.case) { - case HttpStatusCode.Accepted: - case HttpStatusCode.BadRequest: - case HttpStatusCode.NotFound: - case TalerErrorCode.BANK_UNALLOWED_DEBIT: - case TalerErrorCode.BANK_CONFIRM_ABORT_CONFLICT: - case TalerErrorCode.BANK_CONFIRM_INCOMPLETE: - case TalerErrorCode.BANK_AMOUNT_DIFFERS: - case TalerErrorCode.BANK_AMOUNT_REQUIRED: - return i18n.str`cambiar`; - default: - assertUnreachable(fail); - } - }; - - const repeat = confirm.lambda((ids: string[]) => { - return [confirm.args![0], ids]; - }); - const abort = safeFunctionHandler( - i18n.str`abort withdrawal`, - api.abortWithdrawalById.bind(api), - !creds ? undefined : [creds, opid], + // i18n.str`abort withdrawal`, + const abort = actionHandler( + (ct, s, id) => api.abortWithdrawalById(s, id), + !creds ? undefined : ([creds, opid] as const), ); abort.onSuccess = () => { mutate(() => true); // clean any info that we have }; - abort.onFail = (fail) => { - switch (fail.case) { - case HttpStatusCode.BadRequest: - case HttpStatusCode.NotFound: - case HttpStatusCode.Conflict: - return i18n.str`cambiar`; - default: - assertUnreachable(fail); - } - }; - const spec = config.currency_specification; return { - notification, - mfa, wireFee, spec, abort, confirm, - repeat, }; } @@ -144,59 +107,64 @@ export function WithdrawalConfirmationQuestion({ withdrawUri, }: Props): VNode { const { i18n } = useTranslationContext(); - const { notification, mfa, wireFee, spec, abort, confirm, repeat } = - useComponentState(withdrawUri.withdrawalOperationId); - - confirm.onFail = (fail) => { - switch (fail.case) { - case TalerErrorCode.BANK_CONFIRM_ABORT_CONFLICT: - return i18n.str`The withdrawal has been aborted previously and can't be confirmed`; - case TalerErrorCode.BANK_CONFIRM_INCOMPLETE: - return i18n.str`The withdrawal operation can't be confirmed before a wallet accepted the transaction.`; - case HttpStatusCode.BadRequest: - return i18n.str`The operation ID is invalid.`; - case HttpStatusCode.NotFound: - return i18n.str`The operation was not found.`; - case TalerErrorCode.BANK_UNALLOWED_DEBIT: - return i18n.str`Your balance is not sufficient for the operation.`; - case TalerErrorCode.BANK_AMOUNT_DIFFERS: - return i18n.str`The starting withdrawal amount and the confirmation amount differs.`; - case TalerErrorCode.BANK_AMOUNT_REQUIRED: - return i18n.str`The bank requires a bank account which has not been specified yet.`; - case HttpStatusCode.Accepted: { - mfa.onChallengeRequired(fail.body); - return i18n.str`A second factor authentication is required.`; - } - } - }; + const { wireFee, spec, abort, confirm } = useComponentState( + withdrawUri.withdrawalOperationId, + ); - abort.onFail = (fail) => { - switch (fail.case) { - case HttpStatusCode.BadRequest: - return i18n.str`Bad request`; - case HttpStatusCode.NotFound: - return i18n.str`The withdrawal operation has been aborted.`; - case HttpStatusCode.Conflict: - return i18n.str`The withdrawal operation has been confirmed previously and can’t be aborted.`; - } - }; + const mfa = useBankChallengeHandlerContext(); + const { showError } = useNotificationContext(); - if (mfa.pendingChallenge) { - return ( - <SolveMFAChallenges - currentChallenge={mfa.pendingChallenge} - description={i18n.str`Complete withdrawal.`} - onCancel={mfa.doCancelChallenge} - onCompleted={repeat} - username={details.username} - /> - ); - } + confirm.onFail = showError( + i18n.str`Failed to confirm the withdrawal.`, + (fail, creds) => { + switch (fail.case) { + case HttpStatusCode.Accepted: + mfa.onNewChallenge( + i18n.str`Withdrawal confirmation`, + creds.username, + fail.body, + confirm.lambda((prev, next) => + !prev ? undefined : [prev[0], next[0]], + ), + ); + return undefined; + case HttpStatusCode.BadRequest: + return i18n.str`The server did not understand the request.`; + case HttpStatusCode.NotFound: + return i18n.str`The operation was not found.`; + case TalerErrorCode.BANK_UNALLOWED_DEBIT: + return i18n.str`The account does not have sufficient funds or the amount is outside the limits.`; + case TalerErrorCode.BANK_CONFIRM_ABORT_CONFLICT: + return i18n.str`The withdrawal has been aborted and can not be confirmed.`; + case TalerErrorCode.BANK_CONFIRM_INCOMPLETE: + return i18n.str`The withdrawal has no exchange and reserve public selected.`; + case TalerErrorCode.BANK_AMOUNT_DIFFERS: + return i18n.str`The starting withdrawal amount and the confirmation amount differs.`; + case TalerErrorCode.BANK_AMOUNT_REQUIRED: + return i18n.str`The bank requires a bank account which has not been specified yet.`; + default: + assertUnreachable(fail); + } + }, + ); + abort.onFail = showError( + i18n.str`Failed to abort the withdrawal.`, + (fail) => { + switch (fail.case) { + case HttpStatusCode.BadRequest: + return i18n.str`The server did not understand the request.`; + case HttpStatusCode.NotFound: + return i18n.str`The operation was not found.`; + case HttpStatusCode.Conflict: + return i18n.str`The withdrawal operation has been confirmed previously and can not be aborted.`; + default: + assertUnreachable(fail); + } + }, + ); return ( <Fragment> - <LocalNotificationBanner notification={notification} /> - <div class="bg-white shadow sm:rounded-lg"> <div class="px-4 py-5 sm:p-6"> <h3 class="text-base font-semibold text-gray-900"> @@ -450,21 +418,21 @@ export function WithdrawalConfirmationQuestion({ </div> <div class="flex items-center justify-between gap-x-6 border-t border-gray-900/10 px-4 py-4 sm:px-8"> - <ButtonBetter + <Button name="cancel" class="text-sm font-semibold leading-6 text-gray-900" onClick={abort} > <i18n.Translate>Cancel</i18n.Translate> - </ButtonBetter> - <ButtonBetter + </Button> + <Button submit name="transfer" class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" onClick={confirm} > <i18n.Translate>Transfer</i18n.Translate> - </ButtonBetter> + </Button> </div> </form> </div> diff --git a/packages/bank-ui/src/pages/WithdrawalQRCode.tsx b/packages/bank-ui/src/pages/WithdrawalQRCode.tsx @@ -26,7 +26,6 @@ import { ErrorLoading, Loading, RouteDefinition, - notifyInfo, useTranslationContext, } from "@gnu-taler/web-util/browser"; import { VNode, h } from "preact"; @@ -62,7 +61,12 @@ export function WithdrawalQRCode({ return <Loading />; } if (result instanceof TalerError) { - return <ErrorLoading error={result} />; + return ( + <ErrorLoading + error={result} + title={i18n.str`Failed to load withdrawal details.`} + /> + ); } if (result.type === "fail") { switch (result.case) { @@ -188,13 +192,7 @@ export function WithdrawalQRCode({ if (data.status === "pending") { return ( - <QrCodeSection - withdrawUri={withdrawUri} - onAborted={() => { - notifyInfo(i18n.str`Operation aborted`); - onOperationAborted(); - }} - /> + <QrCodeSection withdrawUri={withdrawUri} onAborted={onOperationAborted} /> ); } diff --git a/packages/bank-ui/src/pages/account/ShowAccountDetails.tsx b/packages/bank-ui/src/pages/account/ShowAccountDetails.tsx @@ -23,28 +23,25 @@ import { } from "@gnu-taler/taler-util"; import { Attention, - ButtonBetter, + Button, CopyButton, ErrorLoading, Loading, - LocalNotificationBanner, RouteDefinition, - notifyInfo, useBankCoreApiContext, - useChallengeHandler, - useLocalNotificationBetter, + useNotificationContext, useTranslationContext, } from "@gnu-taler/web-util/browser"; import { Fragment, VNode, h } from "preact"; import { useState } from "preact/hooks"; import { Paytos } from "@gnu-taler/taler-util"; +import { useBankChallengeHandlerContext } from "../../context/challenge.js"; import { useAccountDetails } from "../../hooks/account.js"; import { useSessionState } from "../../hooks/session.js"; import { AccountForm } from "../admin/AccountForm.js"; import { LoginForm } from "../LoginForm.js"; import { ProfileNavigation } from "../ProfileNavigation.js"; -import { SolveMFAChallenges } from "../SolveMFA.js"; const TALER_SCREEN_ID = 118; @@ -81,12 +78,12 @@ export function ShowAccountDetails({ ? credentials.username === account : false; + const mfa = useBankChallengeHandlerContext(); + const [submitAccount, setSubmitAccount] = useState< TalerCorebankApi.AccountReconfiguration | undefined >(); - const [notification, safeFunctionHandler] = useLocalNotificationBetter(); - - const mfa = useChallengeHandler(); + const { actionHandler, showError, displayInfo } = useNotificationContext(); const result = useAccountDetails(account); if (!result) { @@ -95,7 +92,10 @@ export function ShowAccountDetails({ if (result instanceof TalerError) { return ( <Fragment> - <ErrorLoading error={result} /> + <ErrorLoading + error={result} + title={i18n.str`Failed to load account details.`} + /> <LoginForm currentUser={account} /> </Fragment> ); @@ -110,60 +110,66 @@ export function ShowAccountDetails({ } } - const update = safeFunctionHandler( - i18n.str`update account`, + // i18n.str`update account`, + const update = actionHandler( ( + ct, username: string, token: AccessToken, account: TalerCorebankApi.AccountReconfiguration, - challengeIds: string[], + challengeIds?: string[], ) => bank.updateAccount({ username, token }, account, { challengeIds }), !sessionToken || !submitAccount ? undefined - : [account, sessionToken, submitAccount, []], + : ([account, sessionToken, submitAccount, undefined as string[] | undefined] as const), ); update.onSuccess = (success) => { - notifyInfo(i18n.str`Account updated`); - onUpdateSuccess(); + displayInfo(i18n.str`Account updated`); + // onUpdateSuccess(); }; - update.onFail = (fail) => { - switch (fail.case) { - case HttpStatusCode.Unauthorized: - return i18n.str`The rights to change the account are not sufficient`; - case HttpStatusCode.NotFound: - return i18n.str`The username was not found`; - case TalerErrorCode.BANK_NON_ADMIN_PATCH_LEGAL_NAME: - return i18n.str`You can't change the legal name, please contact the your account administrator.`; - case TalerErrorCode.BANK_NON_ADMIN_PATCH_DEBT_LIMIT: - return i18n.str`You can't change the debt limit, please contact the your account administrator.`; - case TalerErrorCode.BANK_NON_ADMIN_PATCH_CASHOUT: - return i18n.str`You can't change the cashout address, please contact the your account administrator.`; - case TalerErrorCode.BANK_MISSING_TAN_INFO: - return i18n.str`No information for the selected authentication channel.`; - case HttpStatusCode.Accepted: { - mfa.onChallengeRequired(fail.body); - return i18n.str`A second factor authentication is required.`; + update.onFail = showError( + i18n.str`Failed to update the account.`, + (fail, username) => { + switch (fail.case) { + case HttpStatusCode.Unauthorized: + return i18n.str`The rights to change the account are not sufficient`; + case HttpStatusCode.NotFound: + return i18n.str`The username was not found`; + case TalerErrorCode.BANK_NON_ADMIN_PATCH_LEGAL_NAME: + return i18n.str`You can't change the legal name, please contact the your account administrator.`; + case TalerErrorCode.BANK_NON_ADMIN_PATCH_DEBT_LIMIT: + return i18n.str`You can't change the debt limit, please contact the your account administrator.`; + case TalerErrorCode.BANK_NON_ADMIN_PATCH_CASHOUT: + return i18n.str`You can't change the cashout address, please contact the your account administrator.`; + case TalerErrorCode.BANK_MISSING_TAN_INFO: + return i18n.str`No information for the selected authentication channel.`; + case HttpStatusCode.Accepted: + mfa.onNewChallenge( + i18n.str`Account update`, + username, + fail.body, + update.lambda((prev, next) => + !prev ? undefined : [prev[0], prev[1], prev[2], next[0]], + ), + ); + return undefined; + case TalerErrorCode.BANK_TAN_CHANNEL_NOT_SUPPORTED: + return i18n.str`Authentication channel is not supported.`; + case TalerErrorCode.BANK_NON_ADMIN_SET_CONVERSION_RATE_CLASS: + return i18n.str`Only the administrator can change the conversion rate.`; + case TalerErrorCode.BANK_CONVERSION_RATE_CLASS_UNKNOWN: + return i18n.str`The conversion rate class doesn't exist.`; + case TalerErrorCode.BANK_PASSWORD_TOO_SHORT: + return i18n.str`The password is too short. Can't have less than 8 characters.`; + case TalerErrorCode.BANK_PASSWORD_TOO_LONG: + return i18n.str`The password is too long. Can't have more than 64 characters.`; + default: + assertUnreachable(fail); } - case TalerErrorCode.BANK_TAN_CHANNEL_NOT_SUPPORTED: - return i18n.str`Authentication channel is not supported.`; - case TalerErrorCode.BANK_NON_ADMIN_SET_CONVERSION_RATE_CLASS: - return i18n.str`Only the administrator can change the conversion rate.`; - case TalerErrorCode.BANK_CONVERSION_RATE_CLASS_UNKNOWN: - return i18n.str`The conversion rate class doesn't exist.`; - case TalerErrorCode.BANK_PASSWORD_TOO_SHORT: - return i18n.str`The password is too short. Can't have less than 8 characters.`; - case TalerErrorCode.BANK_PASSWORD_TOO_LONG: - return i18n.str`The password is too long. Can't have more than 64 characters.`; - default: - assertUnreachable(fail); - } - }; - - const repeatUpdate = update.lambda((ids: string[]) => { - return [update.args![0], update.args![1], update.args![2], ids]; - }); + }, + ); const url = bank.getRevenueAPI(account); const baseURL = url.href; @@ -174,21 +180,8 @@ export function ShowAccountDetails({ const payto = ac.tag === "error" || !ac.value.targetType ? undefined : ac.value; - if (mfa.pendingChallenge) { - return ( - <SolveMFAChallenges - currentChallenge={mfa.pendingChallenge} - description={i18n.str`Update account information.`} - onCancel={mfa.doCancelChallenge} - username={account} - onCompleted={repeatUpdate} - /> - ); - } - return ( <Fragment> - <LocalNotificationBanner notification={notification} /> {accountIsTheCurrentUser ? ( <ProfileNavigation current="details" @@ -241,14 +234,14 @@ export function ShowAccountDetails({ > <i18n.Translate>Cancel</i18n.Translate> </a> - <ButtonBetter + <Button submit name="update" class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" onClick={update} > <i18n.Translate>Update</i18n.Translate> - </ButtonBetter> + </Button> </div> </AccountForm> </div> diff --git a/packages/bank-ui/src/pages/account/UpdateAccountPassword.tsx b/packages/bank-ui/src/pages/account/UpdateAccountPassword.tsx @@ -16,28 +16,25 @@ import { AccessToken, assertUnreachable, + HttpStatusCode, TalerCorebankApi, + TalerErrorCode, } from "@gnu-taler/taler-util"; import { - ButtonBetter, - LocalNotificationBanner, + Button, RouteDefinition, ShowInputErrorLabel, - notifyInfo, useBankCoreApiContext, - useChallengeHandler, - useLocalNotificationBetter, + useNotificationContext, useTranslationContext, } from "@gnu-taler/web-util/browser"; -import { Fragment, VNode, h } from "preact"; +import { Fragment, h, VNode } from "preact"; import { useState } from "preact/hooks"; +import { useBankChallengeHandlerContext } from "../../context/challenge.js"; import { useSessionState } from "../../hooks/session.js"; import { undefinedIfEmpty } from "../../utils.js"; import { doAutoFocus } from "../PaytoWireTransferForm.js"; import { ProfileNavigation } from "../ProfileNavigation.js"; -import { SolveMFAChallenges } from "../SolveMFA.js"; -import { TalerErrorCode } from "@gnu-taler/taler-util"; -import { HttpStatusCode } from "@gnu-taler/taler-util"; const TALER_SCREEN_ID = 119; @@ -71,6 +68,7 @@ export function UpdateAccountPassword({ const { lib: { bank: api }, } = useBankCoreApiContext(); + const mfa = useBankChallengeHandlerContext(); const [current, setCurrent] = useState<string | undefined>(); const [password, setPassword] = useState<string | undefined>(); @@ -94,77 +92,72 @@ export function UpdateAccountPassword({ ? i18n.str`Repeated password doesn't match` : undefined, }); - const [notification, safeFunctionHandler] = useLocalNotificationBetter(); - const mfa = useChallengeHandler(); + const { actionHandler, showError } = useNotificationContext(); - const update = safeFunctionHandler( - i18n.str`update password`, + // i18n.str`update password`, + const update = actionHandler( ( + ct, + username: string, token: AccessToken, request: TalerCorebankApi.AccountPasswordChange, challengeIds: string[], ) => - api.updatePassword({ username: accountName, token }, request, { + api.updatePassword({ username, token }, request, { challengeIds, }), !password || !token ? undefined - : [ + : ([ + accountName, token, { old_password: current, new_password: password, }, [], - ], + ] as const), ); update.onSuccess = (success) => { - notifyInfo(i18n.str`Password changed`); + // notifyInfo(i18n.str`Password changed`); onUpdateSuccess(); }; - update.onFail = (fail) => { - switch (fail.case) { - case HttpStatusCode.Unauthorized: - return i18n.str`Not authorized to change the password, maybe the session is invalid.`; - case HttpStatusCode.NotFound: - return i18n.str`Account not found`; - case TalerErrorCode.BANK_NON_ADMIN_PATCH_MISSING_OLD_PASSWORD: - return 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 i18n.str`Your current password doesn't match, can't change to a new password.`; - case HttpStatusCode.Accepted: { - mfa.onChallengeRequired(fail.body); - return i18n.str`A second factor authentication is required.`; + update.onFail = showError( + i18n.str`Failed to update the password.`, + (fail, username) => { + switch (fail.case) { + case HttpStatusCode.Unauthorized: + return i18n.str`Not authorized to change the password, maybe the session is invalid.`; + case HttpStatusCode.NotFound: + return i18n.str`Account not found`; + case TalerErrorCode.BANK_NON_ADMIN_PATCH_MISSING_OLD_PASSWORD: + return 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 i18n.str`Your current password doesn't match, can't change to a new password.`; + case HttpStatusCode.Accepted: + mfa.onNewChallenge( + i18n.str`Password update`, + username, + fail.body, + update.lambda((prev, next) => + !prev ? undefined : [prev[0], prev[1], prev[2], next[0]], + ), + ); + return undefined; + case HttpStatusCode.Forbidden: + return i18n.str`You don't have the rights to change the password.`; + case TalerErrorCode.BANK_PASSWORD_TOO_SHORT: + return i18n.str`The password is too short. Can't have less than 8 characters.`; + case TalerErrorCode.BANK_PASSWORD_TOO_LONG: + return i18n.str`The password is too long. Can't have more than 64 characters.`; + default: + assertUnreachable(fail); } - case HttpStatusCode.Forbidden: - return i18n.str`You don't have the rights to change the password.`; - case TalerErrorCode.BANK_PASSWORD_TOO_SHORT: - return i18n.str`The password is too short. Can't have less than 8 characters.`; - case TalerErrorCode.BANK_PASSWORD_TOO_LONG: - return i18n.str`The password is too long. Can't have more than 64 characters.`; - default: - assertUnreachable(fail); - } - }; - const repeatUpdate = update.lambda((ids: string[]) => { - return [update.args![0], update.args![1], ids]; - }); - - if (mfa.pendingChallenge) { - return ( - <SolveMFAChallenges - currentChallenge={mfa.pendingChallenge} - description={i18n.str`Update account password.`} - username={accountName} - onCancel={mfa.doCancelChallenge} - onCompleted={repeatUpdate} - /> - ); - } + }, + ); return ( <Fragment> - <LocalNotificationBanner notification={notification} /> {accountIsTheCurrentUser ? ( <ProfileNavigation current="credentials" @@ -301,14 +294,14 @@ export function UpdateAccountPassword({ > <i18n.Translate>Cancel</i18n.Translate> </a> - <ButtonBetter + <Button submit name="change" class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" onClick={update} > <i18n.Translate>Change</i18n.Translate> - </ButtonBetter> + </Button> </div> </form> </div> diff --git a/packages/bank-ui/src/pages/admin/AccountList.tsx b/packages/bank-ui/src/pages/admin/AccountList.tsx @@ -55,7 +55,12 @@ export function AccountList({ return <Loading />; } if (result instanceof TalerError) { - return <ErrorLoading error={result} />; + return ( + <ErrorLoading + error={result} + title={i18n.str`Failed to load the account list.`} + /> + ); } switch (result.case) { case "ok": diff --git a/packages/bank-ui/src/pages/admin/AdminHome.tsx b/packages/bank-ui/src/pages/admin/AdminHome.tsx @@ -249,10 +249,20 @@ function Metrics({ const resp = useLastMonitorInfo(params.current, params.previous, metricType); if (!resp) return <Fragment />; if (resp instanceof TalerError) { - return <ErrorLoading error={resp} />; + return ( + <ErrorLoading + error={resp} + title={i18n.str`Failed to load the monitor info.`} + /> + ); } if (respInfo && respInfo instanceof TalerError) { - return <ErrorLoading error={respInfo} />; + return ( + <ErrorLoading + error={respInfo} + title={i18n.str`Failed to load cashout info.`} + /> + ); } if (respInfo && respInfo.type === "fail") { switch (respInfo.case) { diff --git a/packages/bank-ui/src/pages/admin/ConversionClassList.tsx b/packages/bank-ui/src/pages/admin/ConversionClassList.tsx @@ -65,7 +65,7 @@ export function ConversionClassList({ return <Loading />; } if (result instanceof TalerError) { - return <ErrorLoading error={result} />; + return <ErrorLoading error={result} title={i18n.str`Failed to load conversion rate.`}/>; } if (result.type !== "ok") { diff --git a/packages/bank-ui/src/pages/admin/CreateNewAccount.tsx b/packages/bank-ui/src/pages/admin/CreateNewAccount.tsx @@ -14,21 +14,17 @@ GNU Taler; see the file COPYING. If not, see <http: */ import { - AbsoluteTime, HttpStatusCode, TalerCorebankApi, TalerErrorCode, - TranslatedString, assertUnreachable, } from "@gnu-taler/taler-util"; import { Attention, - ButtonBetter, - LocalNotificationBanner, + Button, RouteDefinition, - notifyInfo, useBankCoreApiContext, - useLocalNotificationBetter, + useNotificationContext, useTranslationContext, } from "@gnu-taler/web-util/browser"; import { Fragment, VNode, h } from "preact"; @@ -57,54 +53,57 @@ export function CreateNewAccount({ TalerCorebankApi.RegisterAccountRequest | undefined >(); - const [notification, safeFunctionHandler] = useLocalNotificationBetter(); + const { actionHandler, showError } = useNotificationContext(); - const create = safeFunctionHandler( - i18n.str`create account`, - api.createAccount.bind(api), + // i18n.str`create account`, + const create = actionHandler( + (ct, t, ac) => api.createAccount(t, ac), !submitAccount || !token ? undefined - : [{ type: "bearer", token }, submitAccount], + : ([{ type: "bearer", token }, submitAccount] as const), ); create.onSuccess = (success, token, account) => { - notifyInfo(i18n.str`Account created with password "${account.password}".`); + // notifyInfo(i18n.str`Account created with password "${account.password}".`); onCreateSuccess(); }; - create.onFail = (fail) => { - switch (fail.case) { - case HttpStatusCode.BadRequest: - return i18n.str`Server replied that phone or email is invalid`; - case HttpStatusCode.Unauthorized: - return i18n.str`The rights to perform the operation are not sufficient`; - case TalerErrorCode.BANK_REGISTER_USERNAME_REUSE: - return i18n.str`Account username is already taken`; - case TalerErrorCode.BANK_REGISTER_PAYTO_URI_REUSE: - return i18n.str`Account ID is already taken`; - case TalerErrorCode.BANK_UNALLOWED_DEBIT: - return i18n.str`Bank ran out of bonus credit.`; - case TalerErrorCode.BANK_RESERVED_USERNAME_CONFLICT: - return i18n.str`Account username can't be used because is reserved`; - case TalerErrorCode.BANK_NON_ADMIN_PATCH_DEBT_LIMIT: - return i18n.str`Only an administrator is allowed to set the debt limit.`; - case TalerErrorCode.BANK_MISSING_TAN_INFO: - return i18n.str`No information for the selected authentication channel.`; - case TalerErrorCode.BANK_TAN_CHANNEL_NOT_SUPPORTED: - return i18n.str`Authentication channel is not supported.`; - case TalerErrorCode.BANK_NON_ADMIN_SET_TAN_CHANNEL: - return i18n.str`Only admin can create accounts with second factor authentication.`; - case TalerErrorCode.BANK_NON_ADMIN_SET_CONVERSION_RATE_CLASS: - return i18n.str`Only the administrator can change the conversion rate.`; - case TalerErrorCode.BANK_CONVERSION_RATE_CLASS_UNKNOWN: - return i18n.str`The conversion rate class doesn't exist.`; - case TalerErrorCode.BANK_PASSWORD_TOO_SHORT: - return i18n.str`The password is too short. Can't have less than 8 characters.`; - case TalerErrorCode.BANK_PASSWORD_TOO_LONG: - return i18n.str`The password is too long. Can't have more than 64 characters.`; - default: - assertUnreachable(fail); - } - }; + create.onFail = showError( + i18n.str`Failed to create a new account.`, + (fail) => { + switch (fail.case) { + case HttpStatusCode.BadRequest: + return i18n.str`Server replied that phone or email is invalid`; + case HttpStatusCode.Unauthorized: + return i18n.str`The rights to perform the operation are not sufficient`; + case TalerErrorCode.BANK_REGISTER_USERNAME_REUSE: + return i18n.str`Account username is already taken`; + case TalerErrorCode.BANK_REGISTER_PAYTO_URI_REUSE: + return i18n.str`Account ID is already taken`; + case TalerErrorCode.BANK_UNALLOWED_DEBIT: + return i18n.str`Bank ran out of bonus credit.`; + case TalerErrorCode.BANK_RESERVED_USERNAME_CONFLICT: + return i18n.str`Account username can't be used because is reserved`; + case TalerErrorCode.BANK_NON_ADMIN_PATCH_DEBT_LIMIT: + return i18n.str`Only an administrator is allowed to set the debt limit.`; + case TalerErrorCode.BANK_MISSING_TAN_INFO: + return i18n.str`No information for the selected authentication channel.`; + case TalerErrorCode.BANK_TAN_CHANNEL_NOT_SUPPORTED: + return i18n.str`Authentication channel is not supported.`; + case TalerErrorCode.BANK_NON_ADMIN_SET_TAN_CHANNEL: + return i18n.str`Only admin can create accounts with second factor authentication.`; + case TalerErrorCode.BANK_NON_ADMIN_SET_CONVERSION_RATE_CLASS: + return i18n.str`Only the administrator can change the conversion rate.`; + case TalerErrorCode.BANK_CONVERSION_RATE_CLASS_UNKNOWN: + return i18n.str`The conversion rate class doesn't exist.`; + case TalerErrorCode.BANK_PASSWORD_TOO_SHORT: + return i18n.str`The password is too short. Can't have less than 8 characters.`; + case TalerErrorCode.BANK_PASSWORD_TOO_LONG: + return i18n.str`The password is too long. Can't have more than 64 characters.`; + default: + assertUnreachable(fail); + } + }, + ); if (!(credentials.status === "loggedIn" && credentials.isUserAdministrator)) { return ( @@ -129,8 +128,6 @@ export function CreateNewAccount({ return ( <div class="grid grid-cols-1 gap-x-8 gap-y-8 pt-6 md:grid-cols-3 bg-gray-100 my-4 px-4 pb-4 rounded-lg"> - <LocalNotificationBanner notification={notification} /> - <div class="px-4 sm:px-0"> <h2 class="text-base font-semibold leading-7 text-gray-900"> <i18n.Translate>New bank account</i18n.Translate> @@ -151,14 +148,14 @@ export function CreateNewAccount({ > <i18n.Translate>Cancel</i18n.Translate> </a> - <ButtonBetter + <Button submit name="create" class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" onClick={create} > <i18n.Translate>Create</i18n.Translate> - </ButtonBetter> + </Button> </div> </AccountForm> </div> diff --git a/packages/bank-ui/src/pages/admin/DownloadStats.tsx b/packages/bank-ui/src/pages/admin/DownloadStats.tsx @@ -25,12 +25,11 @@ import { import { dummyHttpResponse } from "@gnu-taler/taler-util/http"; import { Attention, - ButtonBetter, - LocalNotificationBanner, + Button, RouteDefinition, useBankCoreApiContext, - useLocalNotificationBetter, - useTranslationContext, + useNotificationContext, + useTranslationContext } from "@gnu-taler/web-util/browser"; import { VNode, h } from "preact"; import { useState } from "preact/hooks"; @@ -80,11 +79,11 @@ export function DownloadStats({ routeCancel }: Props): VNode { const [lastStep, setLastStep] = useState<{ step: number; total: number }>(); const [downloaded, setDownloaded] = useState<string>(); const referenceDates = [new Date()]; - const [notification, safeFunctionHandler] = useLocalNotificationBetter(); + const { actionHandler, showError } = useNotificationContext(); - const download = safeFunctionHandler( - i18n.str`download statistics`, - async (token) => { + // i18n.str`download statistics`, + const download = actionHandler( + async (ct, token) => { setDownloaded(undefined); return fetchAllStatus( api, @@ -96,15 +95,12 @@ export function DownloadStats({ routeCancel }: Props): VNode { }, ); }, - lastStep !== undefined || !creds ? undefined : [creds.token], + lastStep !== undefined || !creds ? undefined : ([creds.token] as const), ); download.onSuccess = (success) => { setDownloaded(success); setLastStep(undefined); }; - download.onFail = (fail) => { - return undefined; - }; if (!creds) { return <i18n.Translate>only admin can download stats</i18n.Translate>; @@ -113,8 +109,6 @@ export function DownloadStats({ routeCancel }: Props): VNode { return ( <div> <div class="grid grid-cols-1 gap-x-8 gap-y-8 pt-6 md:grid-cols-3 bg-gray-100 my-4 px-4 pb-4 rounded-lg"> - <LocalNotificationBanner notification={notification} /> - <div class="px-4 sm:px-0"> <h2 class="text-base font-semibold leading-7 text-gray-900"> <i18n.Translate>Download bank stats</i18n.Translate> @@ -379,14 +373,14 @@ export function DownloadStats({ routeCancel }: Props): VNode { > <i18n.Translate>Cancel</i18n.Translate> </a> - <ButtonBetter + <Button submit name="download" class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" onClick={download} > <i18n.Translate>Download</i18n.Translate> - </ButtonBetter> + </Button> </div> </form> </div> diff --git a/packages/bank-ui/src/pages/admin/RemoveAccount.tsx b/packages/bank-ui/src/pages/admin/RemoveAccount.tsx @@ -22,28 +22,25 @@ import { } from "@gnu-taler/taler-util"; import { Attention, - ButtonBetter, + Button, ErrorLoading, Loading, - LocalNotificationBanner, RouteDefinition, ShowInputErrorLabel, - notifyInfo, useBankCoreApiContext, - useChallengeHandler, - useLocalNotificationBetter, + useNotificationContext, useTranslationContext, } from "@gnu-taler/web-util/browser"; import { Fragment, VNode, h } from "preact"; import { useState } from "preact/hooks"; +import { UserAndToken } from "@gnu-taler/taler-util"; +import { useBankChallengeHandlerContext } from "../../context/challenge.js"; import { useAccountDetails } from "../../hooks/account.js"; import { useSessionState } from "../../hooks/session.js"; import { undefinedIfEmpty } from "../../utils.js"; import { LoginForm } from "../LoginForm.js"; import { doAutoFocus } from "../PaytoWireTransferForm.js"; -import { SolveMFAChallenges } from "../SolveMFA.js"; -import { UserAndToken } from "@gnu-taler/taler-util"; const TALER_SCREEN_ID = 125; @@ -69,9 +66,8 @@ export function RemoveAccount({ const { lib: { bank: api }, } = useBankCoreApiContext(); - const [notification, safeFunctionHandler] = useLocalNotificationBetter(); - - const mfa = useChallengeHandler(); + const { actionHandler, showError } = useNotificationContext(); + const mfa = useBankChallengeHandlerContext(); if (!result) { return <Loading />; @@ -79,7 +75,10 @@ export function RemoveAccount({ if (result instanceof TalerError) { return ( <Fragment> - <ErrorLoading error={result} /> + <ErrorLoading + error={result} + title={i18n.str`Failed to load account details.`} + /> <LoginForm currentUser={account} /> </Fragment> ); @@ -132,58 +131,50 @@ export function RemoveAccount({ : undefined, }); - const deleteAccount = safeFunctionHandler( - i18n.str`delete account`, - (auth: UserAndToken, challengeIds: string[]) => + // i18n.str`delete account`, + const deleteAccount = actionHandler( + (ct, auth: UserAndToken, challengeIds?: string[]) => api.deleteAccount(auth, { challengeIds }), - !!errors || !token ? undefined : [{ username: account, token }, []], + !!errors || !token + ? undefined + : ([{ username: account, token }, undefined as string[] | undefined] as const), ); deleteAccount.onSuccess = (success) => { - notifyInfo(i18n.str`Account removed`); + // notifyInfo(i18n.str`Account removed`); onUpdateSuccess(); }; - deleteAccount.onFail = (fail) => { - switch (fail.case) { - case HttpStatusCode.Unauthorized: - return i18n.str`No enough permission to delete the account.`; - case HttpStatusCode.NotFound: - return i18n.str`The username was not found.`; - case TalerErrorCode.BANK_RESERVED_USERNAME_CONFLICT: - return i18n.str`Can't delete a reserved username.`; - case TalerErrorCode.BANK_ACCOUNT_BALANCE_NOT_ZERO: - return i18n.str`Can't delete an account with balance different than zero.`; - case HttpStatusCode.Accepted: { - mfa.onChallengeRequired(fail.body); - return i18n.str`A second factor authentication is required.`; + deleteAccount.onFail = showError( + i18n.str`Faild to delete the account.`, + (fail, creds) => { + switch (fail.case) { + case HttpStatusCode.Unauthorized: + return i18n.str`No enough permission to delete the account.`; + case HttpStatusCode.NotFound: + return i18n.str`The username was not found.`; + case TalerErrorCode.BANK_RESERVED_USERNAME_CONFLICT: + return i18n.str`Can't delete a reserved username.`; + case TalerErrorCode.BANK_ACCOUNT_BALANCE_NOT_ZERO: + return i18n.str`Can't delete an account with balance different than zero.`; + case HttpStatusCode.Accepted: + mfa.onNewChallenge( + i18n.str`Account deletion`, + creds.username, + fail.body, + deleteAccount.lambda((prev, next) => + !prev ? undefined : [prev[0], next[0]], + ), + ); + return undefined; + default: + assertUnreachable(fail); } - default: - assertUnreachable(fail); - } - }; - - const retryDeleteAccount = deleteAccount.lambda((ids: string[]) => [ - deleteAccount.args![0], - ids, - ]); - - if (mfa.pendingChallenge) { - return ( - <SolveMFAChallenges - currentChallenge={mfa.pendingChallenge} - description={i18n.str`Remove account.`} - username={account} - onCancel={mfa.doCancelChallenge} - onCompleted={retryDeleteAccount} - /> - ); - } + }, + ); return ( <div> - <LocalNotificationBanner notification={notification} /> - <Attention type="warning" title={i18n.str`You are going to remove the account`} @@ -252,14 +243,14 @@ export function RemoveAccount({ > <i18n.Translate>Cancel</i18n.Translate> </a> - <ButtonBetter + <Button submit name="delete" class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-red-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-600" onClick={deleteAccount} > <i18n.Translate>Delete</i18n.Translate> - </ButtonBetter> + </Button> </div> </form> </div> diff --git a/packages/bank-ui/src/pages/regional/ConversionConfig.tsx b/packages/bank-ui/src/pages/regional/ConversionConfig.tsx @@ -24,16 +24,15 @@ import { } from "@gnu-taler/taler-util"; import { Attention, - ButtonBetter, + Button, ErrorLoading, InternationalizationAPI, Loading, - LocalNotificationBanner, RenderAmount, RouteDefinition, ShowInputErrorLabel, useBankCoreApiContext, - useLocalNotificationBetter, + useNotificationContext, useTranslationContext, utils, } from "@gnu-taler/web-util/browser"; @@ -104,7 +103,12 @@ function useComponentState({ return <Loading />; } if (resp instanceof TalerError) { - return <ErrorLoading error={resp} />; + return ( + <ErrorLoading + error={resp} + title={i18n.str`Failed to load conversion rate information.`} + /> + ); } if (resp.type !== "ok") { @@ -130,7 +134,7 @@ function useComponentState({ lib: { conversion }, } = useBankCoreApiContext(); - const [notification, safeFunctionHandler] = useLocalNotificationBetter(); + const { actionHandler, showError } = useNotificationContext(); const initalState: FormValues<FormType> = { amount: "100", @@ -173,9 +177,9 @@ function useComponentState({ const in_fee = Amounts.parseOrThrow(info.conversion_rate.cashin_fee); const out_fee = Amounts.parseOrThrow(info.conversion_rate.cashout_fee); - const calculate = safeFunctionHandler( - i18n.str`calculate cashout fee`, - async (amount: AmountJson) => { + // i18n.str`calculate cashout fee`, + const calculate = actionHandler( + async (ct, amount: AmountJson) => { const respCashin = await calculateCashinFromDebit(amount, in_fee); if (respCashin.type === "fail") { return respCashin; @@ -191,27 +195,32 @@ function useComponentState({ const cashout = respCashout.body; return opFixedSuccess(dummyHttpResponse, { cashin, cashout }); }, - !in_amount || status.status === "fail" ? undefined : [in_amount], + !in_amount || status.status === "fail" + ? undefined + : ([in_amount] as const), ); calculate.onSuccess = (resp) => setCalc(resp); - calculate.onFail = (fail) => { - switch (fail.case) { - case HttpStatusCode.BadRequest: - return i18n.str`The server didn't understand the request.`; - case HttpStatusCode.Conflict: - return i18n.str`The amount is too small`; - case HttpStatusCode.NotImplemented: - return i18n.str`Conversion is not implemented.`; - case TalerErrorCode.GENERIC_PARAMETER_MISSING: - return i18n.str`At least debit or credit needs to be provided`; - case TalerErrorCode.GENERIC_PARAMETER_MALFORMED: - return i18n.str`The amount is malfored`; - case TalerErrorCode.GENERIC_CURRENCY_MISMATCH: - return i18n.str`The currency is not supported`; - default: - assertUnreachable(fail); - } - }; + calculate.onFail = showError( + i18n.str`Failed to calculate cashout fee.`, + (fail) => { + switch (fail.case) { + case HttpStatusCode.BadRequest: + return i18n.str`The server didn't understand the request.`; + case HttpStatusCode.Conflict: + return i18n.str`The amount is too small`; + case HttpStatusCode.NotImplemented: + return i18n.str`Conversion is not implemented.`; + case TalerErrorCode.GENERIC_PARAMETER_MISSING: + return i18n.str`At least debit or credit needs to be provided`; + case TalerErrorCode.GENERIC_PARAMETER_MALFORMED: + return i18n.str`The amount is malfored`; + case TalerErrorCode.GENERIC_CURRENCY_MISMATCH: + return i18n.str`The currency is not supported`; + default: + assertUnreachable(fail); + } + }, + ); useEffect(() => { calculate.call(); @@ -227,27 +236,33 @@ function useComponentState({ const cashinCalc = calculationResult?.cashin; const cashoutCalc = calculationResult?.cashout; - const update = safeFunctionHandler( - i18n.str`update conversion rate`, - conversion.updateConversionRate.bind(conversion), + // i18n.str`update conversion rate`, + const update = actionHandler( + (ct, s, c) => conversion.updateConversionRate(s, c), !creds || status.status === "fail" ? undefined - : [{ type: "bearer", token: creds.token }, status.result.conv], + : ([ + { type: "bearer", token: creds.token }, + status.result.conv, + ] as const), ); update.onSuccess = () => { setSection("detail"); }; - update.onFail = (fail) => { - switch (fail.case) { - case HttpStatusCode.Unauthorized: - return i18n.str`Wrong credentials`; - case HttpStatusCode.NotImplemented: - return i18n.str`Conversion is disabled`; - default: - assertUnreachable(fail); - } - }; + update.onFail = showError( + i18n.str`Failed to update the conversion rate.`, + (fail) => { + switch (fail.case) { + case HttpStatusCode.Unauthorized: + return i18n.str`Wrong credentials`; + case HttpStatusCode.NotImplemented: + return i18n.str`Conversion is disabled`; + default: + assertUnreachable(fail); + } + }, + ); const in_ratio = Number.parseFloat(info.conversion_rate.cashin_ratio); const out_ratio = Number.parseFloat(info.conversion_rate.cashout_ratio); @@ -266,7 +281,6 @@ function useComponentState({ routeConversionConfig={routeConversionConfig} /> - <LocalNotificationBanner notification={notification} /> <div class="grid grid-cols-1 gap-x-8 gap-y-8 pt-6 md:grid-cols-3 bg-gray-100 my-4 px-4 pb-4 rounded-lg"> <div class="px-4 sm:px-0"> <h2 class="text-base font-semibold leading-7 text-gray-900"> @@ -587,14 +601,14 @@ function useComponentState({ <i18n.Translate>Cancel</i18n.Translate> </a> {section == "cashin" || section == "cashout" ? ( - <ButtonBetter + <Button submit name="update conversion" class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" onClick={update} > <i18n.Translate>Update</i18n.Translate> - </ButtonBetter> + </Button> ) : ( <div /> )} diff --git a/packages/bank-ui/src/pages/regional/CreateCashout.tsx b/packages/bank-ui/src/pages/regional/CreateCashout.tsx @@ -27,17 +27,14 @@ import { } from "@gnu-taler/taler-util"; import { Attention, - ButtonBetter, + Button, ErrorLoading, Loading, - LocalNotificationBanner, RenderAmount, RouteDefinition, ShowInputErrorLabel, - notifyInfo, useBankCoreApiContext, - useChallengeHandler, - useLocalNotificationBetter, + useNotificationContext, useTranslationContext, } from "@gnu-taler/web-util/browser"; import { Fragment, VNode, h } from "preact"; @@ -49,6 +46,7 @@ import { opFixedSuccess, } from "@gnu-taler/taler-util"; import { dummyHttpResponse } from "@gnu-taler/taler-util/http"; +import { useBankChallengeHandlerContext } from "../../context/challenge.js"; import { useAccountDetails } from "../../hooks/account.js"; import { TransCalc, @@ -61,7 +59,6 @@ import { LoggedIn, useSessionState } from "../../hooks/session.js"; import { TanChannel, undefinedIfEmpty } from "../../utils.js"; import { LoginForm } from "../LoginForm.js"; import { InputAmount, doAutoFocus } from "../PaytoWireTransferForm.js"; -import { SolveMFAChallenges } from "../SolveMFA.js"; const TALER_SCREEN_ID = 127; @@ -121,7 +118,12 @@ export function CreateCashout({ if (!resultAccount) { return <Loading />; } else if (resultAccount instanceof TalerError) { - return <ErrorLoading error={resultAccount} />; + return ( + <ErrorLoading + error={resultAccount} + title={i18n.str`Failed to load account details.`} + /> + ); } else if (resultAccount.type === "fail") { switch (resultAccount.case) { case HttpStatusCode.Unauthorized: @@ -136,7 +138,12 @@ export function CreateCashout({ if (!conversionResp) { return <Loading />; } else if (conversionResp instanceof TalerError) { - return <ErrorLoading error={conversionResp} />; + return ( + <ErrorLoading + error={conversionResp} + title={i18n.str`Failed to load conversion rate information.`} + /> + ); } else if (conversionResp.type === "fail") { switch (conversionResp.case) { case HttpStatusCode.NotImplemented: { @@ -157,7 +164,12 @@ export function CreateCashout({ if (!rateResp) { return <Loading />; } else if (rateResp instanceof TalerError) { - return <ErrorLoading error={rateResp} />; + return ( + <ErrorLoading + error={rateResp} + title={i18n.str`Failed to load conversion rate information.`} + /> + ); } else if (rateResp.type === "fail") { switch (rateResp.case) { case HttpStatusCode.NotImplemented: { @@ -223,9 +235,9 @@ function CreateCashoutInternal({ estimateByDebit: calculateFromDebit, } = useCashoutEstimatorByUser(accountName); const [form, setForm] = useState<Partial<FormType>>({ isDebit: true }); - const [notification, safeFunctionHandler] = useLocalNotificationBetter(); + const { actionHandler, showError } = useNotificationContext(); + const mfa = useBankChallengeHandlerContext(); - const mfa = useChallengeHandler(); const { i18n } = useTranslationContext(); const { lib: { bank: api }, @@ -269,9 +281,9 @@ function CreateCashoutInternal({ : true; const notZero = Amounts.isNonZero(inputAmount); - const conversionCalculator = safeFunctionHandler( - i18n.str`calculate conversion fee`, - async (isDebit: boolean, input: AmountJson, fee: AmountJson) => { + // i18n.str`calculate conversion fee`, + const conversionCalculator = actionHandler( + async (ct, isDebit: boolean, input: AmountJson, fee: AmountJson) => { if (notZero && higerThanMin) { return isDebit ? calculateFromDebit(input, fee) @@ -283,24 +295,27 @@ function CreateCashoutInternal({ [form.isDebit ?? false, inputAmount, sellFee], ); conversionCalculator.onSuccess = (success) => setCalculation(success); - conversionCalculator.onFail = (fail) => { - switch (fail.case) { - case HttpStatusCode.BadRequest: - return i18n.str`The server didn't understand the request.`; - case HttpStatusCode.Conflict: - return i18n.str`The amount is too small`; - case HttpStatusCode.NotImplemented: - return i18n.str`Conversion is not implemented.`; - case TalerErrorCode.GENERIC_PARAMETER_MISSING: - return i18n.str`At least debit or credit needs to be provided`; - case TalerErrorCode.GENERIC_PARAMETER_MALFORMED: - return i18n.str`The amount is malfored`; - case TalerErrorCode.GENERIC_CURRENCY_MISMATCH: - return i18n.str`The currency is not supported`; - default: - assertUnreachable(fail); - } - }; + conversionCalculator.onFail = showError( + i18n.str`Failed to calculate the conversion fee.`, + (fail) => { + switch (fail.case) { + case HttpStatusCode.BadRequest: + return i18n.str`The server didn't understand the request.`; + case HttpStatusCode.Conflict: + return i18n.str`The amount is too small`; + case HttpStatusCode.NotImplemented: + return i18n.str`Conversion is not implemented.`; + case TalerErrorCode.GENERIC_PARAMETER_MISSING: + return i18n.str`At least debit or credit needs to be provided`; + case TalerErrorCode.GENERIC_PARAMETER_MALFORMED: + return i18n.str`The amount is malfored`; + case TalerErrorCode.GENERIC_CURRENCY_MISMATCH: + return i18n.str`The currency is not supported`; + default: + assertUnreachable(fail); + } + }, + ); useEffect(() => { conversionCalculator.call(); @@ -345,9 +360,15 @@ function CreateCashoutInternal({ const subject = form.subject; - const cashout = safeFunctionHandler( - i18n.str`create cashout`, - (calc: TransCalc, subject: string, challengeIds: string[]) => + // i18n.str`create cashout`, + const cashout = actionHandler( + ( + ct, + session: LoggedIn, + calc: TransCalc, + subject: string, + challengeIds?: string[], + ) => api.createCashout( session, { @@ -358,47 +379,52 @@ function CreateCashoutInternal({ }, { challengeIds }, ), - !!errors || !subject ? undefined : [calc, subject, []], + !!errors || !subject + ? undefined + : ([session, calc, subject, undefined as string[] | undefined] as const), ); cashout.onSuccess = (success) => { - notifyInfo(i18n.str`Cashout created`); + // notifyInfo(i18n.str`Cashout created`); onCashout(); }; - cashout.onFail = (fail) => { - switch (fail.case) { - case HttpStatusCode.Accepted: { - mfa.onChallengeRequired(fail.body); - return i18n.str`Second factor authentication required.`; - } - case HttpStatusCode.NotFound: - return i18n.str`Account not found`; - case TalerErrorCode.BANK_TRANSFER_REQUEST_UID_REUSED: - return i18n.str`Duplicated request detected, check if the operation succeeded or try again.`; - case TalerErrorCode.BANK_BAD_CONVERSION: - return i18n.str`The conversion rate was applied incorrectly`; - case TalerErrorCode.BANK_UNALLOWED_DEBIT: - return i18n.str`The account does not have sufficient funds`; - case HttpStatusCode.NotImplemented: - return i18n.str`Cashout is disabled`; - case TalerErrorCode.BANK_CONFIRM_INCOMPLETE: - return i18n.str`Missing cashout URI in the profile`; - case TalerErrorCode.BANK_CONVERSION_AMOUNT_TO_SMALL: - return i18n.str`The amount is below the minimum amount permitted.`; - case TalerErrorCode.BANK_TAN_CHANNEL_SCRIPT_FAILED: - return i18n.str`Sending the confirmation message failed, retry later or contact the administrator.`; - case TalerErrorCode.BANK_TAN_CHANNEL_NOT_SUPPORTED: { - return i18n.str`The server doesn't support the current TAN channel.`; + cashout.onFail = showError( + i18n.str`Failed to create the cashout.`, + (fail, session) => { + switch (fail.case) { + case HttpStatusCode.Accepted: + mfa.onNewChallenge( + i18n.str`Cashout`, + session.username, + fail.body, + cashout.lambda((prev, next) => + !prev ? undefined : [prev[0], prev[1], prev[2], next[0]], + ), + ); + return undefined; + case HttpStatusCode.NotFound: + return i18n.str`Account not found`; + case TalerErrorCode.BANK_TRANSFER_REQUEST_UID_REUSED: + return i18n.str`Duplicated request detected, check if the operation succeeded or try again.`; + case TalerErrorCode.BANK_BAD_CONVERSION: + return i18n.str`The conversion rate was applied incorrectly`; + case TalerErrorCode.BANK_UNALLOWED_DEBIT: + return i18n.str`The account does not have sufficient funds`; + case HttpStatusCode.NotImplemented: + return i18n.str`Cashout is disabled`; + case TalerErrorCode.BANK_CONFIRM_INCOMPLETE: + return i18n.str`Missing cashout URI in the profile`; + case TalerErrorCode.BANK_CONVERSION_AMOUNT_TO_SMALL: + return i18n.str`The amount is below the minimum amount permitted.`; + case TalerErrorCode.BANK_TAN_CHANNEL_SCRIPT_FAILED: + return i18n.str`Sending the confirmation message failed, retry later or contact the administrator.`; + case TalerErrorCode.BANK_TAN_CHANNEL_NOT_SUPPORTED: { + return i18n.str`The server doesn't support the current TAN channel.`; + } + default: + assertUnreachable(fail); } - default: - assertUnreachable(fail); - } - }; - - const retryCashout = cashout.lambda((ids: string[]) => [ - cashout.args![0], - cashout.args![1], - ids, - ]); + }, + ); const cashoutDisabled = !accountData.cashout_payto_uri; @@ -415,22 +441,8 @@ function CreateCashoutInternal({ ? undefined : cashoutAccount.value.params["receiver-name"]; - if (mfa.pendingChallenge) { - return ( - <SolveMFAChallenges - currentChallenge={mfa.pendingChallenge} - username={accountData.name} - description={i18n.str`Create cashout.`} - onCancel={mfa.doCancelChallenge} - onCompleted={retryCashout} - /> - ); - } - return ( <div> - <LocalNotificationBanner notification={notification} /> - <div class="grid grid-cols-1 gap-x-8 gap-y-8 pt-6 md:grid-cols-3 bg-gray-100 my-4 px-4 pb-4 rounded-lg"> <section class="mt-4 rounded-sm px-4 py-6 p-8 "> <h2 id="summary-heading" class="font-medium text-lg"> @@ -748,14 +760,14 @@ function CreateCashoutInternal({ > <i18n.Translate>Cancel</i18n.Translate> </a> - <ButtonBetter + <Button submit name="cashout" class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" onClick={cashout} > <i18n.Translate>Cashout</i18n.Translate> - </ButtonBetter> + </Button> </div> </form> </div> diff --git a/packages/bank-ui/src/pages/regional/ShowCashoutDetails.tsx b/packages/bank-ui/src/pages/regional/ShowCashoutDetails.tsx @@ -60,7 +60,7 @@ export function ShowCashoutDetails({ id, routeClose }: Props): VNode { return <Loading />; } if (result instanceof TalerError) { - return <ErrorLoading error={result} />; + return <ErrorLoading error={result} title={i18n.str`Failed to load cashout details.`}/>; } if (result.type === "fail") { switch (result.case) { @@ -89,7 +89,7 @@ export function ShowCashoutDetails({ id, routeClose }: Props): VNode { } if (info instanceof TalerError) { - return <ErrorLoading error={info} />; + return <ErrorLoading error={info} title={i18n.str`Failed to load conversion rate information.`} />; } if (info.type === "fail") { switch (info.case) { diff --git a/packages/bank-ui/tailwind.config.js b/packages/bank-ui/tailwind.config.js @@ -26,7 +26,244 @@ export default { ], }, theme: { - extend: {}, + extend: { + colors: { + // https://docs.taler.net/design-documents/066-wallet-color-scheme.html + + // PRIMARY + /** + * Main action color (e.g. filled buttons, tabs, icons) + */ + 'primary': '#0042b3', + /** + * Text/icons placed on top of primary + */ + 'onPrimary': '#ffffff', + /** + * Background for FABs, cards, filled fields + */ + 'primaryContainer': '#d3deff', + /** + * Foreground for primaryContainer + */ + 'onPrimaryContainer': '#00134a', + /** + * primary in dark mode + */ + 'darkPrimary': '#b4c5ff', + /** + * Text/icons on darkPrimary + */ + 'darkOnPrimary': '#002a78', + /** + * Container in dark mode + */ + 'darkPrimaryContainer': '#0042b3', + /** + * Foreground on container in dark + */ + 'darkOnPrimaryContainer': '#e5ebff', + + // SECONDARY + /** + * Secondary buttons, chips, and passive UI states + */ + 'secondary': '#586a88', + /** + * Foreground on secondary + */ + 'onSecondary': '#ffffff', + /** + * Background for secondary surfaces + */ + 'secondaryContainer': '#d9e3f9', + /** + * Foreground on secondaryContainer + */ + 'onSecondaryContainer': '#111c2b', + /** + * Secondary color in dark mode + */ + 'darkSecondary': '#a4c9ff', + /** + * Text/icons on darkPrimary + */ + 'darkOnSecondary': '#00315d', + /** + * Container in dark mode + */ + 'darkSecondaryContainer': '#72a3e5', + /** + * Foreground on container in dark + */ + 'darkOnSecondaryContainer': '#003869', + + // TERTIARY + /** + * Used for tags, emphasis markers + */ + 'tertiary': '#338af0', + /** + * Text/icons on tertiary + */ + 'onTertiary': '#ffffff', + /** + * Input field backgrounds, selected indicators + */ + 'tertiaryContainer': '#d1e4ff', + /** + * Text/icons on tertiaryContainer + */ + 'onTertiaryContainer': '#001c39', + /** + * Accent color in dark mode + */ + 'darkTertiary': '#8dd1e5', + /** + * Foreground in dark + */ + 'darkOnTertiary': '#003641', + /** + * Container fill in dark + */ + 'darkTertiaryContainer': '#166577', + /** + * Text/icons on dark container + */ + 'darkOnTertiaryContainer': '#9ce0f5', + + // ERROR + /** + * Main error color for messages or outlines + */ + 'error': '#b3261e', + /** + * Text/icons on error surfaces + */ + 'onError': '#ffffff', + /** + * + */ + 'errorContainer': '#f9dedc', + /** + * + */ + 'onErrorContainer': '#410e0b', + /** + * + */ + 'darkError': '#ffb4aa', + /** + * + */ + 'darkOnError': '#690003', + /** + * + */ + 'darkErrorContainer': '#b3261e', + /** + * + */ + 'darkOnErrorContainer': '#ffcbc4', + + // SUCCESS + /** + * + */ + 'success': '#337a40', + /** + * + */ + 'onSuccess': '#ffffff', + /** + * + */ + 'successContainer': '#2e8534', + /** + * + */ + 'onSuccessContainer': '#f7fff1', + /** + * + */ + 'darkSuccess': '#337a40', + /** + * + */ + 'darkOnSuccess': '#ffffff', + /** + * + */ + 'darkSuccessContainer': '#1d3522', + /** + * + */ + 'darkOnSuccessContainer': '#eaf6ec', + + // WARNING + /** + * Alert banners, passive warnings + */ + 'warning': '#f99c06', + /** + * + */ + 'onWarning': '#000000', + /** + * + */ + 'warningContainer': '#fdedd3', + /** + * + */ + 'onWarningContainer': '#6b4706', + /** + * + */ + 'darkWarning': '#f99c06', + /** + * + */ + 'darkOnWarning': '#000000', + /** + * + */ + 'darkWarningContainer': '#664200', + /** + * + */ + 'darkOnWarningContainer': '#fdedd3', + + // BACKGROUND + /** + * App-wide background color + */ + 'background': '#fdfdff', + /** + * + */ + 'onBackground': '#1a1c1f', + /** + * Background in dark mode + */ + 'darkBackground': '#11131a', + /** + * + */ + 'darkOnBackground': '#e2e2eb', + + // OUTLINE + /** + * Used for input borders, field outlines + */ + 'outline': '#767880', + /** + * Decorative borders, dividers + */ + 'outlineVariant': '#c4c6d0', + }, + + }, }, plugins: [tw_form], };