taler-typescript-core

Wallet core logic and WebUIs for various components
Log | Files | Refs | Submodules | README | LICENSE

commit a402cc1a2e3983ed083ae32db6ae1fd4de955f0e
parent 48ac110ec6b6805a6d2e48035e20435bd6e9cdac
Author: Florian Dold <dold@taler.net>
Date:   Tue, 25 Aug 2026 11:29:49 +0200

bank web UI: redesign account, transfer, and withdrawal flows

Diffstat:
Mpackages/libeufin-bank-webui/README.md | 13++++++-------
Mpackages/libeufin-bank-webui/package.json | 1-
Mpackages/libeufin-bank-webui/src/Routing.tsx | 445++++++++++++++++++++++++++++++++++++++++++-------------------------------------
Mpackages/libeufin-bank-webui/src/app.tsx | 85+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------
Apackages/libeufin-bank-webui/src/assets/taler-logo-light.svg | 1+
Apackages/libeufin-bank-webui/src/components/AbortWithdrawalDialog.tsx | 125+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/libeufin-bank-webui/src/components/AdminNavigation.stories.tsx | 35+++++++++++++++++++++++++++++++++++
Apackages/libeufin-bank-webui/src/components/AdminNavigation.tsx | 97+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/libeufin-bank-webui/src/components/Cashouts/views.tsx | 152++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------
Apackages/libeufin-bank-webui/src/components/DemoBanner.stories.tsx | 28++++++++++++++++++++++++++++
Apackages/libeufin-bank-webui/src/components/DemoBanner.test.ts | 52++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/libeufin-bank-webui/src/components/DemoBanner.tsx | 152+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/libeufin-bank-webui/src/components/OperationError.tsx | 36++++++++++++++++++++++++++++++++++++
Dpackages/libeufin-bank-webui/src/components/QR.tsx | 38--------------------------------------
Mpackages/libeufin-bank-webui/src/components/Transactions/index.ts | 23+++++++++--------------
Mpackages/libeufin-bank-webui/src/components/Transactions/state.ts | 21++++++++++++++++++---
Mpackages/libeufin-bank-webui/src/components/Transactions/stories.tsx | 56+++++++++++++++++++++++++++++++++++++++++---------------
Mpackages/libeufin-bank-webui/src/components/Transactions/views.tsx | 434++++++++++++++++++++++++++++++++++++++++---------------------------------------
Mpackages/libeufin-bank-webui/src/components/index.examples.ts | 2++
Apackages/libeufin-bank-webui/src/developer-settings.test.ts | 97+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/libeufin-bank-webui/src/developer-settings.ts | 94+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/libeufin-bank-webui/src/hooks/account.ts | 48++++++++++++++++++++++++++++++++----------------
Apackages/libeufin-bank-webui/src/hooks/bank-state.test.ts | 60++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/libeufin-bank-webui/src/hooks/bank-state.ts | 23+++++++++++++++++++++++
Apackages/libeufin-bank-webui/src/hooks/preferences.test.ts | 50++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/libeufin-bank-webui/src/hooks/preferences.ts | 13+++++--------
Mpackages/libeufin-bank-webui/src/hooks/regional.ts | 38+++++++++++++++++++++++++++-----------
Mpackages/libeufin-bank-webui/src/pages/AccountPage/index.ts | 64+++++++++++++---------------------------------------------------
Mpackages/libeufin-bank-webui/src/pages/AccountPage/state.ts | 53++++++++++++-----------------------------------------
Mpackages/libeufin-bank-webui/src/pages/AccountPage/stories.tsx | 21++++++++-------------
Mpackages/libeufin-bank-webui/src/pages/AccountPage/views.tsx | 226+++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------------
Apackages/libeufin-bank-webui/src/pages/ActiveWithdrawal.tsx | 322+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/libeufin-bank-webui/src/pages/AuthForm.tsx | 164+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/libeufin-bank-webui/src/pages/Authentication.stories.tsx | 40++++++++++++++++++++++++++++++++++------
Mpackages/libeufin-bank-webui/src/pages/BankFrame.stories.tsx | 25++++++++++++++++++-------
Mpackages/libeufin-bank-webui/src/pages/BankFrame.tsx | 504+++++++++++++++++++++++++++++++++++++++++++++++++------------------------------
Mpackages/libeufin-bank-webui/src/pages/ConversionRateClassDetails.tsx | 518++++++++++++++++++++++++++++++++-----------------------------------------------
Apackages/libeufin-bank-webui/src/pages/DeveloperSettings.stories.tsx | 41+++++++++++++++++++++++++++++++++++++++++
Apackages/libeufin-bank-webui/src/pages/DeveloperSettings.tsx | 259+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/libeufin-bank-webui/src/pages/LoginForm.tsx | 605+++++++++++++++++++++++++++++++++++++++++++++++++------------------------------
Mpackages/libeufin-bank-webui/src/pages/NewConversionRateClass.tsx | 71+++++++++++++++++++++++++++++++++++++++++++++--------------------------
Mpackages/libeufin-bank-webui/src/pages/OperationState/OperationState.test.ts | 198+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------
Mpackages/libeufin-bank-webui/src/pages/OperationState/index.ts | 14+++++++++++++-
Mpackages/libeufin-bank-webui/src/pages/OperationState/state.ts | 82++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------
Mpackages/libeufin-bank-webui/src/pages/OperationState/stories.tsx | 196++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------
Mpackages/libeufin-bank-webui/src/pages/OperationState/views.tsx | 1383++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------------
Mpackages/libeufin-bank-webui/src/pages/PaymentOptions.stories.tsx | 11-----------
Mpackages/libeufin-bank-webui/src/pages/PaymentOptions.tsx | 337++++++++++++++++++++++++++++++++++---------------------------------------------
Mpackages/libeufin-bank-webui/src/pages/PaytoWireTransferForm.stories.tsx | 14+++++++++++++-
Mpackages/libeufin-bank-webui/src/pages/PaytoWireTransferForm.tsx | 577+++++++++++++++++++++++++++++++++++--------------------------------------------
Apackages/libeufin-bank-webui/src/pages/ProfileNavigation.stories.tsx | 54++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/libeufin-bank-webui/src/pages/ProfileNavigation.tsx | 244+++++++++++++++++++++----------------------------------------------------------
Mpackages/libeufin-bank-webui/src/pages/PublicHistoriesPage.tsx | 93+++++++++++++++++++++++++++++++++++++------------------------------------------
Mpackages/libeufin-bank-webui/src/pages/RegistrationPage.tsx | 676+++++++++++++++++++++++++++++++++++++++++--------------------------------------
Mpackages/libeufin-bank-webui/src/pages/SolveMFA.tsx | 205+++++++++++++++++++++++++++++++++++++++++++++++--------------------------------
Mpackages/libeufin-bank-webui/src/pages/WalletWithdrawForm.tsx | 554++++++++++++++++++++++++++++++++++++++++++++++++-------------------------------
Apackages/libeufin-bank-webui/src/pages/WalletWithdrawal.stories.tsx | 64++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/libeufin-bank-webui/src/pages/WalletWithdrawal.tsx | 94+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/libeufin-bank-webui/src/pages/WireTransfer.tsx | 50++++++++++++++++++++++++++++++--------------------
Mpackages/libeufin-bank-webui/src/pages/WithdrawalOperationPage.tsx | 22++++++++++++++++++++--
Apackages/libeufin-bank-webui/src/pages/WithdrawalProgress.tsx | 78++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/libeufin-bank-webui/src/pages/account/CashoutListForAccount.tsx | 78++++++++++++++++++++++++++++--------------------------------------------------
Apackages/libeufin-bank-webui/src/pages/account/MerchantIntegration.stories.tsx | 50++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/libeufin-bank-webui/src/pages/account/MerchantIntegration.tsx | 220+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/libeufin-bank-webui/src/pages/account/ReadOnlyDetails.tsx | 64++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/libeufin-bank-webui/src/pages/account/ShowAccountDetails.tsx | 410++++++++++++++++++++-----------------------------------------------------------
Mpackages/libeufin-bank-webui/src/pages/account/UpdateAccountPassword.tsx | 112++++++++++++++++++++++++++++++++++++++++---------------------------------------
Mpackages/libeufin-bank-webui/src/pages/admin/AccountForm.stories.tsx | 9+++++++++
Mpackages/libeufin-bank-webui/src/pages/admin/AccountForm.tsx | 249++++++++++++++++++++++++++++++++++++++++---------------------------------------
Mpackages/libeufin-bank-webui/src/pages/admin/AccountList.tsx | 89++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------
Apackages/libeufin-bank-webui/src/pages/admin/AdminAccountSummary.tsx | 75+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/libeufin-bank-webui/src/pages/admin/AdminHome.tsx | 163+++++++++++++++++++++++++++++--------------------------------------------------
Mpackages/libeufin-bank-webui/src/pages/admin/ConversionClassList.tsx | 101++++++++++++++++++++++++++++++++++++++++++++++++-------------------------------
Mpackages/libeufin-bank-webui/src/pages/admin/ConversionRateClassForm.tsx | 46+++++++++++++++++++++++-----------------------
Mpackages/libeufin-bank-webui/src/pages/admin/CreateNewAccount.tsx | 89++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------------
Mpackages/libeufin-bank-webui/src/pages/admin/DownloadStats.tsx | 199+++++++++++++++++++++++++++++++++++++++++++------------------------------------
Mpackages/libeufin-bank-webui/src/pages/admin/RemoveAccount.tsx | 87++++++++++++++++++++++++++++++++++++++++++++++---------------------------------
Apackages/libeufin-bank-webui/src/pages/auth-validation.test.ts | 63+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/libeufin-bank-webui/src/pages/auth-validation.ts | 43+++++++++++++++++++++++++++++++++++++++++++
Mpackages/libeufin-bank-webui/src/pages/index.stories.tsx | 4++++
Apackages/libeufin-bank-webui/src/pages/regional/CashoutCreatePage.tsx | 67+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/libeufin-bank-webui/src/pages/regional/ConversionConfig.tsx | 716+++++++++++++++++++++++++++++++++++++++----------------------------------------
Mpackages/libeufin-bank-webui/src/pages/regional/CreateCashout.tsx | 252+++++++++++++++++++++++++++++++++++--------------------------------------------
Mpackages/libeufin-bank-webui/src/pages/regional/ShowCashoutDetails.tsx | 145+++++++++++++++++++++++++++++++++++++------------------------------------------
Mpackages/libeufin-bank-webui/src/pages/rnd.ts | 2893+------------------------------------------------------------------------------
Apackages/libeufin-bank-webui/src/pages/withdrawal-amount.ts | 44++++++++++++++++++++++++++++++++++++++++++++
Mpackages/libeufin-bank-webui/src/settings.json | 9+++++----
Mpackages/libeufin-bank-webui/src/settings.ts | 15+++++++++------
Apackages/libeufin-bank-webui/src/stories-context.tsx | 179+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/libeufin-bank-webui/src/stories.test.ts | 118++++---------------------------------------------------------------------------
Mpackages/libeufin-bank-webui/src/stories.tsx | 3+++
Mpackages/libeufin-bank-webui/tailwind.config.js | 227+++++++++++++++++++++++++++++++++++++++++++------------------------------------
Mpackages/taler-merchant-webui/package.json | 2+-
Mpackages/taler-merchant-webui/src/screens/screens.test.tsx | 36+++++++++++++++++-------------------
Mpackages/taler-merchant-webui/src/style.css | 41-----------------------------------------
Dpackages/taler-merchant-webui/src/ui/TalerQrCode.test.ts | 7-------
Mpackages/taler-merchant-webui/src/ui/TalerQrCode.tsx | 219+++++++------------------------------------------------------------------------
Mpackages/taler-merchant-webui/tailwind.config.js | 5++++-
Mpackages/taler-merchant-webui/tsconfig.json | 2+-
Mpackages/taler-util/src/payto.test.ts | 10++++++++++
Mpackages/taler-util/src/payto.ts | 2+-
Mpackages/web-util/package.json | 1+
Mpackages/web-util/src/components/CopyButton.tsx | 4++++
Mpackages/web-util/src/components/Footer.tsx | 108+++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------------
Mpackages/web-util/src/components/Header.tsx | 66++++++++++++++++++++++++++++++++++++------------------------------
Mpackages/web-util/src/components/LangSelector.tsx | 53+++++++++++++++++++++++++++++++++++++++++++++++------
Apackages/web-util/src/components/NotificationBanner.test.tsx | 189+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/web-util/src/components/NotificationBanner.tsx | 115++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Apackages/web-util/src/components/TalerQrCode.test.ts | 86+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/web-util/src/components/TalerQrCode.tsx | 368+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/web-util/src/components/index.ts | 1+
Mpackages/web-util/src/context/navigation.ts | 8+++++---
Mpackages/web-util/src/context/notification.ts | 1+
Mpackages/web-util/src/hooks/useNotifications.ts | 12+++++++++++-
Mpackages/web-util/src/stories-utils.test.tsx | 72+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mpackages/web-util/src/stories-utils.tsx | 6+++---
Mpackages/web-util/src/utils/buildPaginatedResult.ts | 24++++++++++--------------
Mpnpm-lock.yaml | 12++++++------
118 files changed, 9891 insertions(+), 8456 deletions(-)

diff --git a/packages/libeufin-bank-webui/README.md b/packages/libeufin-bank-webui/README.md @@ -14,15 +14,14 @@ staged installations. ## Testing -By default, the bank-ui will expect the backend to be in `window.origin` but that can be overridden using the `settings.json` file or by session in the localStorage. - -``` -localStorage.setItem("bank-base-url", OTHER_URL); -``` +By default, the bank UI expects the backend at `window.origin`. Open `#/dev` +to override the Core Bank API base URL for the current browser. ## Customizing Per-Deployment Settings -To customize per-deployment settings, make sure that the -`settings.json` file is served alongside the UI. +To customize per-deployment settings, serve `settings.json` alongside the UI. +The optional `showPublicAccounts` boolean controls whether public accounts are +linked from the bank navigation and footer. It defaults to `false`; the public +accounts route remains directly reachable when the links are hidden. For more information about the values check the file `settings.ts` in the src folder. diff --git a/packages/libeufin-bank-webui/package.json b/packages/libeufin-bank-webui/package.json @@ -23,7 +23,6 @@ "@gnu-taler/web-util": "workspace:*", "date-fns": "2.29.3", "preact": "10.11.3", - "qrcode-generator": "^1.4.4", "swr": "2.0.3" }, "devDependencies": { diff --git a/packages/libeufin-bank-webui/src/Routing.tsx b/packages/libeufin-bank-webui/src/Routing.tsx @@ -16,30 +16,21 @@ import { urlPattern, - useBankCoreApiContext, useCurrentLocation, + useBankCoreApiContext, useNavigationContext, - useNotificationContext, - useNotifiedOperation, useTranslationContext, } from "@gnu-taler/web-util/browser"; -import { Fragment, VNode, h } from "preact"; +import { VNode, h } from "preact"; +import { useState } from "preact/hooks"; -import { - AbsoluteTime, - AccessToken, - HttpStatusCode, - TalerErrorCode, - TokenRequest, - assertUnreachable, - createRFC8959AccessTokenEncoded, -} from "@gnu-taler/taler-util"; -import { useBankChallengeHandlerContext } from "./context/challenge.js"; +import { assertUnreachable } from "@gnu-taler/taler-util"; import { useRefreshSessionBeforeExpires, useSessionState, } from "./hooks/session.js"; import { CashoutListForAccount } from "./pages/account/CashoutListForAccount.js"; +import { MerchantIntegration } from "./pages/account/MerchantIntegration.js"; import { ShowAccountDetails } from "./pages/account/ShowAccountDetails.js"; import { UpdateAccountPassword } from "./pages/account/UpdateAccountPassword.js"; import { AccountPage } from "./pages/AccountPage/index.js"; @@ -49,15 +40,26 @@ 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 { LoginForm } 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 { CashoutCreatePage } from "./pages/regional/CashoutCreatePage.js"; import { ShowCashoutDetails } from "./pages/regional/ShowCashoutDetails.js"; import { RegistrationPage } from "./pages/RegistrationPage.js"; import { WireTransfer } from "./pages/WireTransfer.js"; import { WithdrawalOperationPage } from "./pages/WithdrawalOperationPage.js"; +import { ActiveWithdrawal } from "./pages/ActiveWithdrawal.js"; +import { useSettingsContext } from "./context/settings.js"; +import { shouldShowPublicAccounts } from "./developer-settings.js"; +import { + AdminNavigation, + AdminPrimarySection, +} from "./components/AdminNavigation.js"; +import { Transactions } from "./components/Transactions/index.js"; +import { AccountList } from "./pages/admin/AccountList.js"; +import { ConversionClassList } from "./pages/admin/ConversionClassList.js"; +import { WalletWithdrawal } from "./pages/WalletWithdrawal.js"; const TALER_SCREEN_ID = 100; @@ -68,22 +70,11 @@ export function Routing(): VNode { if (session.state.status === "loggedIn") { const { isUserAdministrator, username } = session.state; - return ( - <BankFrame - account={username} - routeAccountDetails={privatePages.myAccountDetails} - > - <PrivateRouting username={username} isAdmin={isUserAdministrator} /> - </BankFrame> - ); + return <PrivateRouting username={username} isAdmin={isUserAdministrator} />; } return ( - <BankFrame> - <PublicRounting - onLoggedUser={(username, token, expiration) => { - session.logIn({ username, token, expiration }); - }} - /> + <BankFrame publicAccountsUrl={publicPages.publicAccounts.url({})}> + <PublicRounting /> </BankFrame> ); } @@ -99,94 +90,26 @@ const publicPages = { ), }; -function PublicRounting({ - onLoggedUser, -}: { - onLoggedUser: ( - username: string, - token: AccessToken, - expiration: AbsoluteTime, - ) => void; -}): VNode { +function PublicRounting(): VNode { const { i18n } = useTranslationContext(); const location = useCurrentLocation(publicPages); const { navigateTo } = useNavigationContext(); - - const { config, lib } = useBankCoreApiContext(); - const { showError } = useNotificationContext(); - - const mfa = useBankChallengeHandlerContext(); - - const tokenRequest = { - scope: "readwrite", - duration: SESSION_DURATION, - refreshable: true, - } as TokenRequest; - - // i18n.str`login`, - const login = useNotifiedOperation< - Awaited<ReturnType<typeof lib.bank.createAccessToken>>, - [string, string, string[]] - >( - (ct, username: string, password: string, challengeIds: string[]) => - lib.bank.createAccessToken( - username, - { type: "basic", username, password }, - tokenRequest, - { challengeIds }, - ), - { - onSuccess: (success, username) => { - mfa.cancel(); - onLoggedUser( - username, - createRFC8959AccessTokenEncoded(success.access_token), - AbsoluteTime.fromProtocolTimestamp(success.expiration), - ); - }, - onFail: showError( - i18n.str`Failed to login.`, - (fail, username, password) => { - switch (fail.case) { - case HttpStatusCode.Accepted: - mfa.onNewChallenge( - i18n.str`Identity verification.`, - username, - fail.body, - { - running: login.running, - cancel: login.cancel, - run: (challengeIds) => - login.run(username, password, challengeIds), - }, - ); - return undefined; - case HttpStatusCode.Unauthorized: - return i18n.str`Wrong credentials for "${username}"`; - case TalerErrorCode.GENERIC_FORBIDDEN: - return i18n.str`You do not have permission to access this account.`; - case TalerErrorCode.BANK_ACCOUNT_LOCKED: - return i18n.str`This account is locked. If you have an active session you can change the password or contact the administrator.`; - case HttpStatusCode.NotFound: - return i18n.str`Account not found`; - default: - assertUnreachable(fail); - } - }, - ), - }, - ); + const [registeredUsername, setRegisteredUsername] = useState<string>(); switch (location.name) { case "root": case "login": { return ( - <Fragment> - <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`Welcome to ${config.bank_name}!`}</h2> - </div> - <LoginForm routeRegister={publicPages.register} /> - </Fragment> + <LoginForm + currentUser={registeredUsername} + onSuccess={() => navigateTo(privatePages.home.url({}))} + registrationNotice={ + registeredUsername + ? i18n.str`Your account was created. Sign in to continue.` + : undefined + } + routeRegister={publicPages.register} + /> ); } case "publicAccounts": { @@ -203,14 +126,14 @@ function PublicRounting({ } case "register": { return ( - <Fragment> - <RegistrationPage - onRegistrationSuccesful={(usr, pwd) => { - login.run(usr, pwd, []); - }} - routeCancel={publicPages.login} - /> - </Fragment> + <RegistrationPage + routeLogin={publicPages.login} + onAutoLoginSuccess={() => navigateTo(privatePages.home.url({}))} + onAutoLoginFailure={(username) => { + setRegisteredUsername(username); + navigateTo(publicPages.login.url({})); + }} + /> ); } case undefined: { @@ -238,6 +161,13 @@ const privatePages = { amount?: string; }>(/^\/account\/wire-transfer$/, () => "#/account/wire-transfer"), home: urlPattern(/^\/account$/, () => "#/account"), + transactions: urlPattern(/^\/transactions$/, () => "#/transactions"), + adminAccounts: urlPattern(/^\/admin\/accounts$/, () => "#/admin/accounts"), + adminActivity: urlPattern(/^\/admin\/activity$/, () => "#/admin/activity"), + adminConversion: urlPattern( + /^\/admin\/conversion$/, + () => "#/admin/conversion", + ), cashoutCreate: urlPattern(/\/new-cashout/, () => "#/new-cashout"), cashoutDetails: urlPattern<{ cid: string }>( /\/cashout\/(?<cid>[a-zA-Z0-9]+)/, @@ -265,6 +195,10 @@ const privatePages = { () => "#/delete-my-account", ), myAccountDetails: urlPattern(/\/my-profile/, () => "#/my-profile"), + myAccountMerchantIntegration: urlPattern( + /^\/my-merchant-integration$/, + () => "#/my-merchant-integration", + ), myAccountPassword: urlPattern(/\/my-password/, () => "#/my-password"), myAccountCashouts: urlPattern(/\/my-cashouts/, () => "#/my-cashouts"), conversionConfig: urlPattern(/\/conversion$/, () => "#/conversion"), @@ -309,6 +243,92 @@ function PrivateRouting({ username: string; isAdmin: boolean; }): VNode { + const location = useCurrentLocation(privatePages); + const { config } = useBankCoreApiContext(); + const settings = useSettingsContext(); + const showPublicAccounts = shouldShowPublicAccounts( + settings.showPublicAccounts ?? false, + settings.showPublicAccountsOverride, + ); + const adminCurrent = getAdminPrimarySection(location.name); + const openOperationId = + location.name === "operationDetails" || location.name === "startOperation" + ? location.values.wopid + : undefined; + return ( + <BankFrame + account={username} + publicAccountsUrl={privatePages.publicAccountList.url({})} + navigation={ + isAdmin ? ( + <AdminNavigation + current={adminCurrent} + routeDashboard={privatePages.home} + routeAccounts={privatePages.adminAccounts} + routeActivity={privatePages.adminActivity} + routeConversion={privatePages.adminConversion} + routeMyAccount={privatePages.myAccountDetails} + showConversion={config.allow_conversion ?? false} + /> + ) : undefined + } + > + <ActiveWithdrawal + username={username} + openOperationId={openOperationId} + routeOperation={privatePages.startOperation} + routeClose={privatePages.home} + > + <PrivatePageRouting + username={username} + isAdmin={isAdmin} + showPublicAccounts={showPublicAccounts} + /> + </ActiveWithdrawal> + </BankFrame> + ); +} + +function getAdminPrimarySection( + route: keyof typeof privatePages | undefined, +): AdminPrimarySection | undefined { + if ( + route === "adminAccounts" || + route === "accountCreate" || + route === "accountDetails" || + route === "accountChangePassword" || + route === "accountDelete" || + route === "accountCashouts" || + route === "publicAccountList" + ) + return "accounts"; + if (route === "adminActivity") return "activity"; + if ( + route === "adminConversion" || + route === "conversionConfig" || + route === "conversionRateClassCreate" || + route === "conversionRateClassDetails" + ) + return "conversion"; + if ( + route === "myAccountDetails" || + route === "myAccountMerchantIntegration" || + route === "myAccountPassword" || + route === "myAccountDelete" + ) + return "my-account"; + return route === undefined ? undefined : "dashboard"; +} + +function PrivatePageRouting({ + username, + isAdmin, + showPublicAccounts, +}: { + username: string; + isAdmin: boolean; + showPublicAccounts: boolean; +}): VNode { const { navigateTo } = useNavigationContext(); const { i18n } = useTranslationContext(); const location = useCurrentLocation(privatePages); @@ -319,6 +339,7 @@ function PrivateRouting({ <WithdrawalOperationPage operationId={location.values.wopid} onOperationAborted={() => navigateTo(privatePages.home.url({}))} + onContinueLater={() => navigateTo(privatePages.home.url({}))} routeClose={privatePages.home} /> ); @@ -328,6 +349,7 @@ function PrivateRouting({ <WithdrawalOperationPage operationId={location.values.wopid} onOperationAborted={() => navigateTo(privatePages.home.url({}))} + onContinueLater={() => navigateTo(privatePages.home.url({}))} routeClose={privatePages.home} /> ); @@ -341,8 +363,8 @@ function PrivateRouting({ case "accountCreate": { return ( <CreateNewAccount - routeCancel={privatePages.home} - onCreateSuccess={() => navigateTo(privatePages.home.url({}))} + routeCancel={privatePages.adminAccounts} + onCreateSuccess={() => navigateTo(privatePages.adminAccounts.url({}))} /> ); } @@ -350,28 +372,28 @@ function PrivateRouting({ return ( <ShowAccountDetails account={location.values.account} - onUpdateSuccess={() => navigateTo(privatePages.home.url({}))} - routeMyAccountCashout={privatePages.myAccountCashouts} + onUpdateSuccess={() => navigateTo(privatePages.adminAccounts.url({}))} routeMyAccountDelete={privatePages.myAccountDelete} routeMyAccountDetails={privatePages.myAccountDetails} + routeMyAccountMerchantIntegration={ + privatePages.myAccountMerchantIntegration + } routeMyAccountPassword={privatePages.myAccountPassword} - routeConversionConfig={privatePages.conversionConfig} - routeClose={privatePages.home} + routeClose={privatePages.adminAccounts} /> ); } case "accountChangePassword": { return ( <UpdateAccountPassword - focus account={location.values.account} - onUpdateSuccess={() => navigateTo(privatePages.home.url({}))} - routeMyAccountCashout={privatePages.myAccountCashouts} - routeMyAccountDelete={privatePages.myAccountDelete} + onUpdateSuccess={() => navigateTo(privatePages.adminAccounts.url({}))} routeMyAccountDetails={privatePages.myAccountDetails} + routeMyAccountMerchantIntegration={ + privatePages.myAccountMerchantIntegration + } routeMyAccountPassword={privatePages.myAccountPassword} - routeConversionConfig={privatePages.conversionConfig} - routeClose={privatePages.home} + routeClose={privatePages.adminAccounts} /> ); } @@ -379,8 +401,8 @@ function PrivateRouting({ return ( <RemoveAccount account={location.values.account} - onUpdateSuccess={() => navigateTo(privatePages.home.url({}))} - routeCancel={privatePages.home} + onUpdateSuccess={() => navigateTo(privatePages.adminAccounts.url({}))} + routeCancel={privatePages.adminAccounts} /> ); } @@ -389,13 +411,8 @@ function PrivateRouting({ <CashoutListForAccount account={location.values.account} routeCashoutDetails={privatePages.cashoutDetails} - routeClose={privatePages.home} - routeMyAccountCashout={privatePages.myAccountCashouts} - routeMyAccountDelete={privatePages.myAccountDelete} - routeMyAccountDetails={privatePages.myAccountDetails} - routeMyAccountPassword={privatePages.myAccountPassword} - routeConversionConfig={privatePages.conversionConfig} - onCashout={() => navigateTo(privatePages.home.url({}))} + routeBack={privatePages.adminAccounts} + backLabel={i18n.str`Back to accounts`} /> ); } @@ -413,26 +430,39 @@ function PrivateRouting({ <ShowAccountDetails account={username} onUpdateSuccess={() => navigateTo(privatePages.home.url({}))} - routeMyAccountCashout={privatePages.myAccountCashouts} - routeConversionConfig={privatePages.conversionConfig} routeMyAccountDelete={privatePages.myAccountDelete} routeMyAccountDetails={privatePages.myAccountDetails} + routeMyAccountMerchantIntegration={ + privatePages.myAccountMerchantIntegration + } routeMyAccountPassword={privatePages.myAccountPassword} routeClose={privatePages.home} /> ); } + case "myAccountMerchantIntegration": { + return ( + <MerchantIntegration + account={username} + routeOverview={privatePages.home} + routeMyAccountDetails={privatePages.myAccountDetails} + routeMyAccountMerchantIntegration={ + privatePages.myAccountMerchantIntegration + } + routeMyAccountPassword={privatePages.myAccountPassword} + /> + ); + } case "myAccountPassword": { return ( <UpdateAccountPassword - focus account={username} onUpdateSuccess={() => navigateTo(privatePages.home.url({}))} - routeMyAccountCashout={privatePages.myAccountCashouts} - routeMyAccountDelete={privatePages.myAccountDelete} routeMyAccountDetails={privatePages.myAccountDetails} + routeMyAccountMerchantIntegration={ + privatePages.myAccountMerchantIntegration + } routeMyAccountPassword={privatePages.myAccountPassword} - routeConversionConfig={privatePages.conversionConfig} routeClose={privatePages.home} /> ); @@ -442,13 +472,8 @@ function PrivateRouting({ <CashoutListForAccount account={username} routeCashoutDetails={privatePages.cashoutDetails} - routeMyAccountCashout={privatePages.myAccountCashouts} - routeMyAccountDelete={privatePages.myAccountDelete} - routeMyAccountDetails={privatePages.myAccountDetails} - routeMyAccountPassword={privatePages.myAccountPassword} - routeConversionConfig={privatePages.conversionConfig} - onCashout={() => navigateTo(privatePages.home.url({}))} - routeClose={privatePages.home} + routeBack={privatePages.home} + backLabel={i18n.str`Back to overview`} /> ); } @@ -457,46 +482,70 @@ function PrivateRouting({ if (isAdmin) { return ( <AdminHome - routeCreateAccount={privatePages.accountCreate} - routeRemoveAccount={privatePages.accountDelete} - routeShowAccount={privatePages.accountDetails} - routeShowCashoutsAccount={privatePages.accountCashouts} - routeUpdatePasswordAccount={privatePages.accountChangePassword} - routeCreateWireTransfer={privatePages.wireTranserCreate} + routeCreateWireTransfer={privatePages.homeWireTransfer} routeDownloadStats={privatePages.statsDownload} - routeCreateConversionRateClass={ - privatePages.conversionRateClassCreate - } - routeShowConversionRateClass={ - privatePages.conversionRateClassDetails - } + routeActivity={privatePages.adminActivity} /> ); } return ( <AccountPage account={username} - tab={undefined} - routeCreateWireTransfer={privatePages.wireTranserCreate} - routePublicAccounts={privatePages.publicAccountList} + routeAccountDetails={privatePages.myAccountDetails} routeOperationDetails={privatePages.startOperation} routeChargeWallet={privatePages.homeChargeWallet} routeWireTransfer={privatePages.homeWireTransfer} - routeCashout={privatePages.myAccountCashouts} - routeClose={privatePages.home} - onClose={() => navigateTo(privatePages.home.url({}))} - onOperationCreated={(wopid) => - navigateTo(privatePages.startOperation.url({ wopid })) - } + routeCashout={privatePages.cashoutCreate} + routeTransactions={privatePages.transactions} + /> + ); + } + case "transactions": { + return ( + <Transactions + account={username} + variant="history" + routeBack={privatePages.home} + /> + ); + } + case "adminActivity": { + return ( + <Transactions + account="admin" + variant="history" + title={i18n.str`Admin account activity`} + /> + ); + } + case "adminAccounts": { + return ( + <AccountList + routeCreate={privatePages.accountCreate} + routeRemoveAccount={privatePages.accountDelete} + routeShowAccount={privatePages.accountDetails} + routeUpdatePasswordAccount={privatePages.accountChangePassword} + routePublicAccounts={privatePages.publicAccountList} + showPublicAccounts={showPublicAccounts} + /> + ); + } + case "adminConversion": { + return ( + <ConversionClassList + routeCreate={privatePages.conversionRateClassCreate} + routeShowDetails={privatePages.conversionRateClassDetails} + routeDefault={privatePages.conversionConfig} /> ); } case "cashoutCreate": { return ( - <CreateCashout + <CashoutCreatePage account={username} onCashout={() => navigateTo(privatePages.home.url({}))} routeClose={privatePages.home} + routeHistory={privatePages.myAccountCashouts} /> ); } @@ -521,17 +570,9 @@ function PrivateRouting({ } case "homeChargeWallet": { return ( - <AccountPage + <WalletWithdrawal account={username} - tab="charge-wallet" - routeChargeWallet={privatePages.homeChargeWallet} - routeWireTransfer={privatePages.homeWireTransfer} - routeCreateWireTransfer={privatePages.wireTranserCreate} - routePublicAccounts={privatePages.publicAccountList} - routeOperationDetails={privatePages.startOperation} - routeCashout={privatePages.myAccountCashouts} - routeClose={privatePages.home} - onClose={() => navigateTo(privatePages.home.url({}))} + routeCancel={privatePages.home} onOperationCreated={(wopid) => navigateTo(privatePages.startOperation.url({ wopid })) } @@ -541,34 +582,18 @@ function PrivateRouting({ case "conversionConfig": { return ( <ConversionConfig - routeMyAccountCashout={privatePages.myAccountCashouts} - routeMyAccountDelete={privatePages.myAccountDelete} - routeMyAccountDetails={privatePages.myAccountDetails} - routeMyAccountPassword={privatePages.myAccountPassword} - routeConversionConfig={privatePages.conversionConfig} - routeCancel={privatePages.home} + routeCancel={privatePages.adminConversion} onUpdateSuccess={() => { - navigateTo(privatePages.home.url({})); + navigateTo(privatePages.adminConversion.url({})); }} /> ); } case "homeWireTransfer": { return ( - <AccountPage - account={username} - tab="wire-transfer" - routeChargeWallet={privatePages.homeChargeWallet} - routeWireTransfer={privatePages.homeWireTransfer} - routeCreateWireTransfer={privatePages.wireTranserCreate} - routePublicAccounts={privatePages.publicAccountList} - routeOperationDetails={privatePages.startOperation} - routeCashout={privatePages.myAccountCashouts} - routeClose={privatePages.home} - onClose={() => navigateTo(privatePages.home.url({}))} - onOperationCreated={(wopid) => - navigateTo(privatePages.startOperation.url({ wopid })) - } + <WireTransfer + routeCancel={privatePages.home} + onSuccess={() => navigateTo(privatePages.home.url({}))} /> ); } @@ -582,7 +607,7 @@ function PrivateRouting({ }), ) } - routeCancel={privatePages.home} + routeCancel={privatePages.adminConversion} /> ); } @@ -594,9 +619,9 @@ function PrivateRouting({ return ( <ConversionRateClassDetails classId={id} - routeCancel={privatePages.home} + routeCancel={privatePages.adminConversion} onClassDeleted={() => { - navigateTo(privatePages.home.url({})); + navigateTo(privatePages.adminConversion.url({})); }} /> ); @@ -621,14 +646,14 @@ function NotFound({ const { i18n } = useTranslationContext(); return ( <section class="mx-auto max-w-xl py-12 text-center"> - <h1 class="text-2xl font-bold text-gray-900"> + <h1 class="text-2xl font-bold text-onBackground"> <i18n.Translate>Page not found</i18n.Translate> </h1> <p class="mt-3 text-gray-600"> <i18n.Translate>The requested bank page does not exist.</i18n.Translate> </p> <a - class="mt-6 inline-block rounded-md bg-indigo-600 px-4 py-2 font-semibold text-white" + class="mt-6 inline-block rounded-md bg-primary px-4 py-2 font-semibold text-onPrimary" href={home} > {homeLabel} diff --git a/packages/libeufin-bank-webui/src/app.tsx b/packages/libeufin-bank-webui/src/app.tsx @@ -30,6 +30,8 @@ import { NotificationProvider, TalerWalletIntegrationBrowserProvider, TranslationProvider, + urlPattern, + useCurrentLocation, } from "@gnu-taler/web-util/browser"; import { h } from "preact"; import { useEffect, useState } from "preact/hooks"; @@ -53,22 +55,55 @@ import { BankFrame } from "./pages/BankFrame.js"; import { UiSettings, fetchSettings } from "./settings.js"; import { BankChallengeHandlerProvider } from "./context/challenge.js"; import { SolveChallengeDialog } from "./pages/SolveMFA.js"; +import { + DeveloperOverrides, + readDeveloperOverrides, + writeDeveloperOverrides, +} from "./developer-settings.js"; +import { DeveloperSettings } from "./pages/DeveloperSettings.js"; const WITH_LOCAL_STORAGE_CACHE = false; export function App() { const [settings, setSettings] = useState<UiSettings>(); + const [developerOverrides, setDeveloperOverrides] = + useState<DeveloperOverrides>(() => + readDeveloperOverrides( + typeof localStorage === "undefined" ? undefined : localStorage, + ), + ); useEffect(() => { fetchSettings(setSettings); }, []); if (!settings) return <Loading />; - const baseUrl = getInitialBackendBaseURL(settings.backendBaseURL); + const effectiveSettings: UiSettings = { + ...settings, + showDemoBannerOverride: developerOverrides.showDemoBanner, + showPublicAccountsOverride: developerOverrides.showPublicAccounts, + }; + const baseUrl = getInitialBackendBaseURL( + settings.backendBaseURL, + developerOverrides.corebankApiBaseUrl, + ); + + function updateDeveloperOverrides(next: DeveloperOverrides): void { + writeDeveloperOverrides(localStorage, next); + setDeveloperOverrides(next); + } + return ( - <SettingsProvider value={settings}> + <SettingsProvider value={effectiveSettings}> <TranslationProvider source={strings}> <NotificationProvider> - <SubApp baseUrl={baseUrl} /> + <BrowserHashNavigationProvider> + <SubApp + baseUrl={baseUrl} + settings={settings} + developerOverrides={developerOverrides} + onUpdateDeveloperOverrides={updateDeveloperOverrides} + /> + </BrowserHashNavigationProvider> </NotificationProvider> </TranslationProvider> </SettingsProvider> @@ -92,11 +127,8 @@ function localStorageProvider(): Map<unknown, unknown> { function getInitialBackendBaseURL( backendFromSettings: string | undefined, + overrideUrl: string | undefined, ): string { - const overrideUrl = - typeof localStorage !== "undefined" - ? localStorage.getItem("corebank-api-base-url") - : undefined; let result: string; if (!overrideUrl) { @@ -204,7 +236,36 @@ const evictConversionSwrCache: CacheEvictor<TalerBankConversionCacheEviction> = }, }; -function SubApp({ baseUrl }: { baseUrl: string }) { +const topLevelPages = { + developerSettings: urlPattern(/^\/dev$/, () => "#/dev"), +}; + +function SubApp({ + baseUrl, + settings, + developerOverrides, + onUpdateDeveloperOverrides, +}: { + baseUrl: string; + settings: UiSettings; + developerOverrides: DeveloperOverrides; + onUpdateDeveloperOverrides(overrides: DeveloperOverrides): void; +}) { + const location = useCurrentLocation(topLevelPages); + + if (location.name === "developerSettings") { + return ( + <DeveloperSettings + configuredBackendBaseUrl={settings.backendBaseURL} + configuredShowDemoBanner={settings.showDemoDescription ?? false} + configuredShowPublicAccounts={settings.showPublicAccounts ?? false} + overrides={developerOverrides} + onApply={onUpdateDeveloperOverrides} + onClear={() => onUpdateDeveloperOverrides({})} + /> + ); + } + return ( <BankApiProvider baseUrl={new URL("/", baseUrl)} @@ -241,11 +302,9 @@ function SubApp({ baseUrl }: { baseUrl: string }) { > <BankChallengeHandlerProvider> <TalerWalletIntegrationBrowserProvider> - <BrowserHashNavigationProvider> - <SolveChallengeDialog> - <Routing /> - </SolveChallengeDialog> - </BrowserHashNavigationProvider> + <SolveChallengeDialog> + <Routing /> + </SolveChallengeDialog> </TalerWalletIntegrationBrowserProvider> </BankChallengeHandlerProvider> </SWRConfig> diff --git a/packages/libeufin-bank-webui/src/assets/taler-logo-light.svg b/packages/libeufin-bank-webui/src/assets/taler-logo-light.svg @@ -0,0 +1 @@ +<svg width="1000" xmlns="http://www.w3.org/2000/svg" height="447.761" viewBox="0 0 1000 447.761" fill="none" version="1.1"><g><path d="M431.155,5.578C508.716,5.578,576.077,52.350,610.066,121.010L581.123,121.010C549.685,67.778,494.274,32.428,431.155,32.428C333.192,32.428,253.777,117.563,253.777,222.580C253.777,273.982,272.810,320.614,303.726,354.834C297.047,360.411,289.937,365.420,282.455,369.793C249.112,331.112,228.731,279.403,228.731,222.580C228.731,102.734,319.359,5.578,431.155,5.578ZZM609.523,325.253C575.362,393.314,508.301,439.583,431.155,439.583C425.918,439.583,420.727,439.369,415.589,438.951C430.774,431.184,445.010,421.635,458.069,410.556C509.417,402.176,553.521,370.190,580.466,325.253Z" fill="#0042b3" fill-rule="evenodd"/><path d="M319.465,5.578C324.701,5.578,329.892,5.792,335.030,6.210C319.845,13.977,305.609,23.525,292.551,34.605C207.361,48.507,142.087,127.371,142.087,222.580C142.087,293.548,178.359,355.426,232.106,388.097C224.178,389.427,216.052,390.122,207.774,390.122C201.607,390.122,195.528,389.726,189.548,388.981C145.229,349.174,117.040,289.411,117.040,222.580C117.040,102.734,207.669,5.578,319.465,5.578ZZM346.379,410.556C397.727,402.176,441.832,370.188,468.776,325.251L497.834,325.251C463.673,393.312,396.612,439.583,319.465,439.583C314.227,439.583,309.037,439.369,303.899,438.951C319.083,431.183,333.321,421.636,346.379,410.556ZZM469.432,121.010C453.813,94.569,432.280,72.539,406.823,57.063C414.751,55.734,422.877,55.039,431.155,55.039C437.321,55.039,443.401,55.434,449.381,56.180C469.362,74.126,486.064,96.131,498.382,121.010Z" fill="#0042b3" fill-rule="evenodd"/><path d="M207.774,5.578C213.069,5.578,218.315,5.799,223.508,6.226C208.352,13.982,194.139,23.512,181.103,34.568C95.794,48.361,30.397,127.281,30.397,222.580C30.397,327.598,109.812,412.732,207.774,412.732C270.459,412.732,325.547,377.871,357.096,325.251L386.144,325.251C351.983,393.312,284.922,439.583,207.774,439.583C95.979,439.583,5.350,342.427,5.350,222.580C5.350,102.734,95.979,5.578,207.774,5.578ZZM357.738,121.010C351.211,109.962,343.654,99.681,335.206,90.331C341.885,84.754,348.994,79.742,356.476,75.369C368.282,89.066,378.460,104.397,386.687,121.010Z" fill="#0042b3" fill-rule="evenodd"/><path d="M378.783,171.189L424.360,171.189L424.360,146.099L307.749,146.099L307.749,171.189L353.327,171.189L353.327,300.160L378.783,300.160Z" fill="#000"/><path d="M460.933,262.967L528.888,262.967L543.466,300.161L570.156,300.161L506.921,145.000L483.518,145.000L420.284,300.161L446.152,300.161ZM519.650,238.975L470.174,238.975L494.809,177.351Z" fill="#000"/><path d="M615.952,146.100L593.164,146.100L593.164,300.161L695.390,300.161L695.390,275.732C668.911,275.732,642.431,275.732,615.952,275.732Z" fill="#000"/><path d="M828.220,146.100L721.873,146.100L721.873,300.161L829.247,300.161L829.247,275.732L746.920,275.732L746.920,234.574L818.981,234.574L818.981,210.146L746.920,210.146L746.920,170.530L828.220,170.530Z" fill="#000"/><path d="M951.196,196.391C951.196,204.349,948.527,210.696,943.155,215.390C937.817,220.123,930.597,222.469,921.529,222.469L884.472,222.469L884.472,170.530L921.324,170.530C930.802,170.530,938.158,172.694,943.361,177.058C948.596,181.387,951.196,187.843,951.196,196.391ZZM981.375,300.161L942.573,241.837C947.637,240.370,952.257,238.281,956.432,235.565C960.605,232.851,964.199,229.549,967.209,225.662C970.221,221.773,972.582,217.298,974.292,212.237C976.003,207.175,976.858,201.415,976.858,194.959C976.858,187.476,975.627,180.691,973.163,174.601C970.699,168.512,967.175,163.376,962.591,159.194C958.005,155.013,952.394,151.785,945.756,149.510C939.117,147.235,931.692,146.100,923.480,146.100L859.425,146.100L859.425,300.161L884.472,300.161L884.472,246.460L916.191,246.460L951.606,300.161Z" fill="#000"/></g></svg> diff --git a/packages/libeufin-bank-webui/src/components/AbortWithdrawalDialog.tsx b/packages/libeufin-bank-webui/src/components/AbortWithdrawalDialog.tsx @@ -0,0 +1,125 @@ +/* + This file is part of GNU Taler + (C) 2026 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. +*/ + +import { + AsyncButton, + useTranslationContext, +} from "@gnu-taler/web-util/browser"; +import { RefObject } from "preact"; +import { VNode, h } from "preact"; +import { useEffect, useRef } from "preact/hooks"; +import { OperationError } from "./OperationError.js"; + +export interface WithdrawalDialogError { + title: string; + description: string; +} + +export function AbortWithdrawalDialog({ + running, + disabled, + error, + returnFocus, + onKeep, + onAbort, +}: { + running: boolean; + disabled: boolean; + error?: WithdrawalDialogError; + returnFocus?: RefObject<HTMLElement>; + onKeep(): void; + onAbort(): void | Promise<void>; +}): VNode { + const { i18n } = useTranslationContext(); + const dialogRef = useRef<HTMLDialogElement>(null); + const titleRef = useRef<HTMLHeadingElement>(null); + useEffect(() => { + const dialog = dialogRef.current; + if (!dialog) return; + const focusTarget = returnFocus?.current; + const preventCancel = (event: Event): void => event.preventDefault(); + dialog.addEventListener("cancel", preventCancel); + let frame: number | undefined; + const openWhenConnected = (): void => { + if (!dialog.isConnected) { + frame = requestAnimationFrame(openWhenConnected); + return; + } + if (!dialog.open) dialog.showModal(); + titleRef.current?.focus(); + }; + frame = requestAnimationFrame(openWhenConnected); + return () => { + if (frame !== undefined) cancelAnimationFrame(frame); + dialog.removeEventListener("cancel", preventCancel); + if (dialog.open) dialog.close(); + focusTarget?.focus(); + }; + }, [returnFocus]); + + return ( + <dialog + ref={dialogRef} + aria-modal="true" + aria-labelledby="abort-withdrawal-title" + aria-describedby="abort-withdrawal-description" + class="fixed inset-0 z-20 size-auto max-h-none max-w-none overflow-y-auto bg-transparent p-4 backdrop:bg-secondary/45" + > + <div class="flex min-h-full items-center justify-center"> + <section class="w-full max-w-md rounded-xl bg-white p-6 text-onBackground shadow-xl"> + <h2 + ref={titleRef} + tabIndex={-1} + id="abort-withdrawal-title" + class="text-lg font-semibold outline-none" + > + <i18n.Translate>Abort this withdrawal?</i18n.Translate> + </h2> + <p + id="abort-withdrawal-description" + class="mt-3 text-sm text-gray-600" + > + <i18n.Translate> + This stops the withdrawal. No money will be transferred, and this + operation cannot be resumed. + </i18n.Translate> + </p> + {error ? ( + <div + class="mt-4 rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-800" + role="alert" + > + <div class="font-semibold">{error.title}</div> + <p class="mt-1">{error.description}</p> + </div> + ) : undefined} + <OperationError class="mt-4" /> + <div class="mt-6 flex items-center justify-end gap-3"> + <button + type="button" + class="rounded-md px-3 py-2 text-sm font-semibold text-onBackground hover:bg-gray-100 disabled:cursor-default disabled:opacity-50" + disabled={running} + onClick={onKeep} + > + <i18n.Translate>Keep withdrawal</i18n.Translate> + </button> + <AsyncButton + name="abort withdrawal" + class="rounded-md bg-red-700 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-800 disabled:cursor-default disabled:opacity-50" + disabled={disabled} + onClick={onAbort} + > + <i18n.Translate>Abort withdrawal</i18n.Translate> + </AsyncButton> + </div> + </section> + </div> + </dialog> + ); +} diff --git a/packages/libeufin-bank-webui/src/components/AdminNavigation.stories.tsx b/packages/libeufin-bank-webui/src/components/AdminNavigation.stories.tsx @@ -0,0 +1,35 @@ +/* + This file is part of GNU Taler + (C) 2026 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. +*/ + +import { urlPattern } from "@gnu-taler/web-util/browser"; +import * as tests from "@gnu-taler/web-util/testing"; +import { AdminNavigation } from "./AdminNavigation.js"; + +export default { title: "admin navigation" }; +const route = urlPattern(/.*/, () => "#"); + +export const Accounts = tests.createExample(AdminNavigation, { + current: "accounts", + routeDashboard: route, + routeAccounts: route, + routeActivity: route, + routeConversion: route, + routeMyAccount: route, + showConversion: true, +}); + +export const WithoutConversion = tests.createExample(AdminNavigation, { + current: "dashboard", + routeDashboard: route, + routeAccounts: route, + routeActivity: route, + routeConversion: route, + routeMyAccount: route, + showConversion: false, +}); diff --git a/packages/libeufin-bank-webui/src/components/AdminNavigation.tsx b/packages/libeufin-bank-webui/src/components/AdminNavigation.tsx @@ -0,0 +1,97 @@ +/* + This file is part of GNU Taler + (C) 2026 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. +*/ + +import { + RouteDefinition, + useTranslationContext, +} from "@gnu-taler/web-util/browser"; +import { VNode, h } from "preact"; + +export type AdminPrimarySection = + | "dashboard" + | "accounts" + | "activity" + | "conversion" + | "my-account"; + +export function AdminNavigation({ + current, + routeDashboard, + routeAccounts, + routeActivity, + routeConversion, + routeMyAccount, + showConversion, +}: { + current?: AdminPrimarySection; + routeDashboard: RouteDefinition; + routeAccounts: RouteDefinition; + routeActivity: RouteDefinition; + routeConversion: RouteDefinition; + routeMyAccount: RouteDefinition; + showConversion: boolean; +}): VNode { + const { i18n } = useTranslationContext(); + const links = [ + { + id: "dashboard" as const, + label: i18n.str`Dashboard`, + route: routeDashboard, + }, + { + id: "accounts" as const, + label: i18n.str`Accounts`, + route: routeAccounts, + }, + { + id: "activity" as const, + label: i18n.str`Admin activity`, + route: routeActivity, + }, + ...(showConversion + ? [ + { + id: "conversion" as const, + label: i18n.str`Conversion`, + route: routeConversion, + }, + ] + : []), + { + id: "my-account" as const, + label: i18n.str`My account`, + route: routeMyAccount, + }, + ]; + + return ( + <nav + aria-label={i18n.str`Bank administration`} + class="mb-6 overflow-x-auto border-b border-outlineVariant" + > + <ul class="flex min-w-max gap-6"> + {links.map((link) => { + const selected = current === link.id; + return ( + <li key={link.id}> + <a + href={link.route.url({})} + aria-current={selected ? "page" : undefined} + data-selected={selected} + class="block border-b-2 border-transparent px-1 pb-3 text-sm font-semibold text-secondary hover:border-primary/40 hover:text-primaryDark data-[selected=true]:border-primary data-[selected=true]:text-primaryDark" + > + {link.label} + </a> + </li> + ); + })} + </ul> + </nav> + ); +} diff --git a/packages/libeufin-bank-webui/src/components/Cashouts/views.tsx b/packages/libeufin-bank-webui/src/components/Cashouts/views.tsx @@ -64,7 +64,13 @@ export function ReadyView({ const { i18n, dateLocale } = useTranslationContext(); const conversionResp = useConversionInfo(); - if (!cashouts.length && failures.length === 0) return <div />; + if (!cashouts.length && failures.length === 0) { + return ( + <div class="mt-6 rounded-lg border border-onBackground/10 bg-white p-6 text-sm text-gray-600"> + <i18n.Translate>No cashouts have been created yet.</i18n.Translate> + </div> + ); + } const txByDate = cashouts.reduce( (prev, cur) => { const d = @@ -111,7 +117,7 @@ export function ReadyView({ conversionResp.body; return ( - <div class="px-4 mt-4"> + <div class="mt-6"> {failures.length > 0 && ( <Attention type="danger" @@ -132,33 +138,100 @@ export function ReadyView({ </button> </Attention> )} - <div class="sm:flex sm:items-center"> - <div class="sm:flex-auto"> - <h1 class="text-base font-semibold leading-6 text-gray-900"> - <i18n.Translate>Latest cashouts</i18n.Translate> - </h1> - </div> + <div class="space-y-4 sm:hidden"> + {Object.entries(txByDate).map(([date, txs]) => ( + <section key={date} aria-label={date}> + <h2 class="mb-2 text-xs font-semibold uppercase tracking-wide text-gray-500"> + {date} + </h2> + <div class="space-y-3"> + {txs.map((item) => ( + <article + key={item.id} + class="rounded-lg border border-onBackground/10 bg-white p-4 shadow-sm" + > + <div class="flex items-start justify-between gap-4"> + <div> + <div class="font-medium text-onBackground"> + {item.subject} + </div> + <div class="mt-1 text-xs text-gray-500"> + <Time + format="HH:mm:ss" + timestamp={AbsoluteTime.fromProtocolTimestamp( + item.creation_time, + )} + /> + </div> + </div> + <a + name="cashout details" + class="shrink-0 text-sm font-semibold text-brand hover:underline" + href={routeCashoutDetails.url({ + cid: String(item.id), + })} + > + <i18n.Translate>View details</i18n.Translate> + </a> + </div> + <dl class="mt-4 grid grid-cols-2 gap-3 border-t border-onBackground/10 pt-3 text-sm"> + <div> + <dt class="text-xs text-gray-500"> + <i18n.Translate>Debited</i18n.Translate> + </dt> + <dd class="mt-1 font-medium text-red-600"> + <RenderAmount + value={Amounts.parseOrThrow(item.amount_debit)} + negative + withSign + spec={regional_currency_specification} + /> + </dd> + </div> + <div> + <dt class="text-xs text-gray-500"> + <i18n.Translate>Transferred</i18n.Translate> + </dt> + <dd class="mt-1 font-medium text-green-600"> + <RenderAmount + value={Amounts.parseOrThrow(item.amount_credit)} + spec={fiat_currency_specification!} + /> + </dd> + </div> + </dl> + </article> + ))} + </div> + </section> + ))} </div> - <div class="-mx-4 mt-5 ring-1 ring-gray-300 sm:mx-0 rounded-lg min-w-fit bg-white"> + + <div class="hidden overflow-x-auto rounded-lg border border-onBackground/10 bg-white sm:block"> <table class="min-w-full divide-y divide-gray-300"> <thead> <tr> <th scope="col" - class=" pl-2 py-3.5 text-left text-sm font-semibold text-gray-900" + class="pl-3 py-3.5 text-left text-sm font-semibold text-onBackground" >{i18n.str`Created`}</th> <th scope="col" - class="hidden sm:table-cell pl-2 py-3.5 text-left text-sm font-semibold text-gray-900" - >{i18n.str`Total debit`}</th> + class="px-3 py-3.5 text-left text-sm font-semibold text-onBackground" + >{i18n.str`Debited`}</th> <th scope="col" - class="hidden sm:table-cell pl-2 py-3.5 text-left text-sm font-semibold text-gray-900" - >{i18n.str`Total credit`}</th> + class="px-3 py-3.5 text-left text-sm font-semibold text-onBackground" + >{i18n.str`Transferred`}</th> <th scope="col" - class="hidden sm:table-cell pl-2 py-3.5 text-left text-sm font-semibold text-gray-900" - >{i18n.str`Subject`}</th> + class="px-3 py-3.5 text-left text-sm font-semibold text-onBackground" + >{i18n.str`Transfer reference`}</th> + <th scope="col" class="relative py-3.5 pl-3 pr-4"> + <span class="sr-only"> + <i18n.Translate>Actions</i18n.Translate> + </span> + </th> </tr> </thead> <tbody> @@ -167,9 +240,9 @@ export function ReadyView({ <Fragment key={date}> <tr class="border-t border-gray-200"> <th - colSpan={6} + colSpan={5} scope="colgroup" - class="bg-gray-50 py-2 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 sm:pl-3" + class="bg-gray-50 py-2 pl-4 pr-3 text-left text-sm font-semibold text-onBackground sm:pl-3" > {date} </th> @@ -178,40 +251,45 @@ export function ReadyView({ return ( <tr key={item.id} - class="border-b border-gray-200 hover:bg-gray-200 last:border-none" + class="border-b border-gray-200 last:border-none" > - <td class="relative py-2 pl-2 pr-2 text-sm "> - <a - name="cashout details" - class="font-medium text-indigo-700 underline" - href={routeCashoutDetails.url({ - cid: String(item.id), - })} - > - <Time - format="HH:mm:ss" - timestamp={AbsoluteTime.fromProtocolTimestamp( - item.creation_time, - )} - /> - </a> + <td class="whitespace-nowrap py-3 pl-3 pr-2 text-sm text-onBackground"> + <Time + format="HH:mm:ss" + timestamp={AbsoluteTime.fromProtocolTimestamp( + item.creation_time, + )} + /> </td> - <td class="hidden sm:table-cell px-3 py-3.5 text-sm text-red-600 cursor-pointer"> + <td class="whitespace-nowrap px-3 py-3 text-sm text-red-600"> <RenderAmount value={Amounts.parseOrThrow(item.amount_debit)} + negative + withSign spec={regional_currency_specification} /> </td> - <td class="hidden sm:table-cell px-3 py-3.5 text-sm text-green-600 cursor-pointer"> + <td class="whitespace-nowrap px-3 py-3 text-sm text-green-600"> <RenderAmount value={Amounts.parseOrThrow(item.amount_credit)} spec={fiat_currency_specification!} /> </td> - <td class="hidden sm:table-cell px-3 py-3.5 text-sm text-gray-500 break-all min-w-md"> + <td class="max-w-xs break-words px-3 py-3 text-sm text-gray-600"> {item.subject} </td> + <td class="whitespace-nowrap py-3 pl-3 pr-4 text-right text-sm"> + <a + name="cashout details" + class="font-semibold text-brand hover:underline" + href={routeCashoutDetails.url({ + cid: String(item.id), + })} + > + <i18n.Translate>View details</i18n.Translate> + </a> + </td> </tr> ); })} diff --git a/packages/libeufin-bank-webui/src/components/DemoBanner.stories.tsx b/packages/libeufin-bank-webui/src/components/DemoBanner.stories.tsx @@ -0,0 +1,28 @@ +/* + This file is part of GNU Taler + (C) 2026 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. +*/ + +import * as tests from "@gnu-taler/web-util/testing"; +import { DemoBanner } from "./DemoBanner.js"; + +export default { + title: "demo banner", +}; + +export const Bank = tests.createExample(DemoBanner, { + sites: { + Landing: "https://test.taler.net/", + Bank: "https://bank.test.taler.net/", + "Essay Shop": "https://shop.test.taler.net/", + Donations: "https://donations.test.taler.net/", + }, +}); diff --git a/packages/libeufin-bank-webui/src/components/DemoBanner.test.ts b/packages/libeufin-bank-webui/src/components/DemoBanner.test.ts @@ -0,0 +1,52 @@ +/* + This file is part of GNU Taler + (C) 2026 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 assert from "node:assert/strict"; +import test from "node:test"; +import { buildDemoNavigation } from "./DemoBanner.js"; + +test("demo navigation follows the merchant demo order", () => { + assert.deepEqual( + buildDemoNavigation({ + Donations: "https://donations.example/", + Bank: "https://bank.example/", + Introduction: "https://intro.example/", + "Essay Shop": "https://shop.example/", + }), + [ + { name: "Introduction", url: "https://intro.example/" }, + { name: "Bank", url: "https://bank.example/" }, + { name: "Essay Shop", url: "https://shop.example/" }, + { name: "Donations", url: "https://donations.example/" }, + ], + ); +}); + +test("demo navigation accepts legacy site names without exposing extra items", () => { + assert.deepEqual( + buildDemoNavigation({ + Exchange: "https://exchange.example/", + Bank: "https://bank.example/", + Merchant: "https://merchant.example/", + }), + [ + { name: "Introduction", url: "#" }, + { name: "Bank", url: "https://bank.example/" }, + { name: "Essay Shop", url: "https://merchant.example/" }, + { name: "Donations", url: "#" }, + ], + ); +}); diff --git a/packages/libeufin-bank-webui/src/components/DemoBanner.tsx b/packages/libeufin-bank-webui/src/components/DemoBanner.tsx @@ -0,0 +1,152 @@ +/* + This file is part of GNU Taler + (C) 2026 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. +*/ + +import { + LangSelector, + useTranslationContext, +} from "@gnu-taler/web-util/browser"; +import { Fragment, VNode, h } from "preact"; +import talerLogo from "../assets/taler-logo-light.svg"; + +export type DemoNavigationItem = { + name: "Introduction" | "Bank" | "Essay Shop" | "Donations"; + url: string; +}; + +export function buildDemoNavigation( + configuredSites: Record<string, string>, +): DemoNavigationItem[] { + const sites = new Map( + Object.entries(configuredSites).map(([name, url]) => [ + name.toLowerCase(), + url, + ]), + ); + const find = (...names: string[]): string => { + for (const name of names) { + const url = sites.get(name); + if (url) return url; + } + return "#"; + }; + return [ + { name: "Introduction", url: find("introduction", "landing") }, + { name: "Bank", url: find("bank") }, + { name: "Essay Shop", url: find("essay shop", "blog", "merchant") }, + { name: "Donations", url: find("donations") }, + ]; +} + +function ExternalLinkIcon(): VNode { + return ( + <svg + class="ml-[0.4rem] inline-block h-[0.82rem] w-[0.82rem] flex-none fill-current stroke-current stroke-[0.5] [paint-order:stroke_fill]" + aria-hidden="true" + focusable="false" + viewBox="0 0 16 16" + > + <path + fill-rule="evenodd" + d="M8.636 3.5a.5.5 0 0 0-.5-.5H1.5A1.5 1.5 0 0 0 0 4.5v10A1.5 1.5 0 0 0 1.5 16h10a1.5 1.5 0 0 0 1.5-1.5V7.864a.5.5 0 0 0-1 0V14.5a.5.5 0 0 1-.5.5h-10a.5.5 0 0 1-.5-.5v-10a.5.5 0 0 1 .5-.5h6.636a.5.5 0 0 0 .5-.5" + /> + <path + fill-rule="evenodd" + d="M16 .5a.5.5 0 0 0-.5-.5h-5a.5.5 0 0 0 0 1h3.793L6.146 9.146a.5.5 0 1 0 .708.708L15 1.707V5.5a.5.5 0 0 0 1 0z" + /> + </svg> + ); +} + +function SiteLabel({ name }: { name: DemoNavigationItem["name"] }): VNode { + const { i18n } = useTranslationContext(); + switch (name) { + case "Introduction": + return <i18n.Translate>Introduction</i18n.Translate>; + case "Bank": + return <i18n.Translate>Bank</i18n.Translate>; + case "Essay Shop": + return <i18n.Translate>Essay Shop</i18n.Translate>; + case "Donations": + return <i18n.Translate>Donations</i18n.Translate>; + } +} + +export function DemoBanner({ + sites, +}: { + sites: Record<string, string>; +}): VNode { + const { i18n } = useTranslationContext(); + const navigation = buildDemoNavigation(sites); + return ( + <Fragment> + <header class="border-t-[0.2rem] border-brand bg-brandMuted text-onBackground"> + <div class="mx-auto flex min-h-[6.25rem] w-[calc(100%-2rem)] max-w-[68rem] items-center py-4 max-md:min-h-[5.4rem] max-[544px]:min-h-[4.8rem]"> + <div class="flex min-w-0 items-center gap-6 max-[544px]:gap-4"> + <a + class="inline-flex flex-none items-center" + href="https://taler.net/" + name="GNU Taler" + > + <img + class="h-auto w-[clamp(5.25rem,9vw,6.25rem)] max-[544px]:w-[4.75rem]" + src={talerLogo} + alt={i18n.str`GNU Taler logo`} + /> + </a> + <div class="min-w-0"> + <h1 class="m-0 text-[clamp(1.55rem,3vw,2rem)] font-[680] leading-[1.2] tracking-tight max-[544px]:text-[1.4rem]"> + <a class="text-inherit no-underline" href="#/"> + <i18n.Translate>Bank</i18n.Translate> + </a> + </h1> + <p class="mt-1 mb-0 max-w-3xl text-[0.95rem] text-secondary max-md:text-[0.88rem] max-[544px]:hidden"> + <i18n.Translate> + Withdraw toy currency into your GNU Taler wallet. + </i18n.Translate> + </p> + </div> + </div> + </div> + </header> + <div class="sticky top-0 z-30 border-b border-outlineVariant bg-brandMuted text-onBackground"> + <div class="mx-auto flex min-h-[3.15rem] w-[calc(100%-2rem)] max-w-[68rem] items-stretch justify-between gap-4 max-md:items-start max-md:gap-1 max-md:py-[0.15rem]"> + <nav + class="flex items-stretch gap-[1.4rem] max-md:grid max-md:min-w-0 max-md:flex-1 max-md:grid-cols-2 max-md:gap-x-4 max-md:gap-y-0" + aria-label={i18n.str`Demo pages`} + > + {navigation.map(({ name, url }) => { + const active = name.toLowerCase() === "bank"; + return ( + <a + key={name} + href={url} + aria-current={active ? "page" : undefined} + class={`inline-flex min-h-11 items-center justify-center border-b-[0.15rem] px-0 font-semibold text-onBackground no-underline hover:text-brand max-md:min-h-9 max-md:justify-start max-md:text-[0.9rem] ${ + active ? "border-brand text-brand" : "border-transparent" + }`} + > + <SiteLabel name={name} /> + {name.toLowerCase() === "bank" ? ( + <ExternalLinkIcon /> + ) : undefined} + </a> + ); + })} + </nav> + <LangSelector type="plain" /> + </div> + </div> + </Fragment> + ); +} diff --git a/packages/libeufin-bank-webui/src/components/OperationError.tsx b/packages/libeufin-bank-webui/src/components/OperationError.tsx @@ -0,0 +1,36 @@ +/* + This file is part of GNU Taler + (C) 2026 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 { + ToastBanner, + useNotificationContext, +} from "@gnu-taler/web-util/browser"; +import { Fragment, VNode, h } from "preact"; + +export function OperationError({ + class: className = "", +}: { + class?: string; +}): VNode { + const { notification } = useNotificationContext(); + if (!notification.some((item) => item.message.type === "error")) { + return <Fragment />; + } + return ( + <div class={className}> + <ToastBanner compact messageType="error" /> + </div> + ); +} diff --git a/packages/libeufin-bank-webui/src/components/QR.tsx b/packages/libeufin-bank-webui/src/components/QR.tsx @@ -1,38 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2022-2024 Taler Systems S.A. - - GNU Taler is free software; you can redistribute it and/or modify it under the - terms of the GNU General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along with - GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> - */ - -import { h, VNode } from "preact"; -import { useEffect, useRef } from "preact/hooks"; -import qrcode from "qrcode-generator"; - -export function QR({ text }: { text: string }): VNode { - const divRef = useRef<HTMLDivElement>(null); - useEffect(() => { - const qr = qrcode(0, "L"); - qr.addData(text); - qr.make(); - if (divRef.current) - divRef.current.innerHTML = qr.createSvgTag({ - scalable: true, - }); - }, [text]); - - return ( - <div class="flex flex-col "> - <div class="mx-auto w-full" ref={divRef} /> - </div> - ); -} diff --git a/packages/libeufin-bank-webui/src/components/Transactions/index.ts b/packages/libeufin-bank-webui/src/components/Transactions/index.ts @@ -29,13 +29,10 @@ import { FailedView, ReadyView } from "./views.js"; export interface Props { account: string; - routeCreateWireTransfer: - | RouteDefinition<{ - account?: string; - subject?: string; - amount?: string; - }> - | undefined; + variant?: "recent" | "history"; + title?: TranslatedString; + routeFullHistory?: RouteDefinition; + routeBack?: RouteDefinition; anonymous?: boolean; } @@ -71,16 +68,14 @@ export namespace State { export interface Ready extends BaseInfo { status: "ready"; error: undefined; - routeCreateWireTransfer: - | RouteDefinition<{ - account?: string; - subject?: string; - amount?: string; - }> - | undefined; + variant: "recent" | "history"; + title: TranslatedString; + routeFullHistory?: RouteDefinition; + routeBack?: RouteDefinition; transactions: Transaction[]; onGoStart?: () => void; onGoNext?: () => void; + onGoPrevious?: () => void; } } diff --git a/packages/libeufin-bank-webui/src/components/Transactions/state.ts b/packages/libeufin-bank-webui/src/components/Transactions/state.ts @@ -30,10 +30,17 @@ import { useTranslationContext } from "@gnu-taler/web-util/browser"; export function useComponentState({ account, - routeCreateWireTransfer, + variant = "history", + title, + routeFullHistory, + routeBack, anonymous, }: Props): State { - const result = useTransactions(account, undefined, anonymous); + const result = useTransactions( + account, + anonymous, + variant === "recent" ? 5 : 20, + ); const { i18n } = useTranslationContext(); if (!result) { return { @@ -83,9 +90,17 @@ export function useComponentState({ return { status: "ready", error: undefined, - routeCreateWireTransfer, + variant, + title: + title ?? + (variant === "recent" + ? i18n.str`Recent activity` + : i18n.str`Transactions`), + routeFullHistory, + routeBack, transactions, onGoNext: result.loadNext, onGoStart: result.loadFirst, + onGoPrevious: result.loadPrev, }; } diff --git a/packages/libeufin-bank-webui/src/components/Transactions/stories.tsx b/packages/libeufin-bank-webui/src/components/Transactions/stories.tsx @@ -21,25 +21,51 @@ import * as tests from "@gnu-taler/web-util/testing"; import { ReadyView } from "./views.js"; -import { AbsoluteTime } from "@gnu-taler/taler-util"; +import { AbsoluteTime, TranslatedString } from "@gnu-taler/taler-util"; +import { urlPattern } from "@gnu-taler/web-util/browser"; export default { title: "transaction list", }; -export const Ready = tests.createExample(ReadyView, { - transactions: [ - { - id: 1, - amount: { - currency: "USD", - fraction: 0, - value: 1, - }, - counterpart: "ASD", - negative: false, - subject: "Some", - when: AbsoluteTime.now(), +const transactions = [ + { + id: 1, + amount: { + currency: "USD", + fraction: 0, + value: 1, }, - ], + counterpart: "exchange @ bank.test.taler.net", + negative: false, + subject: "Some", + when: AbsoluteTime.now(), + }, + { + id: 2, + amount: { currency: "USD", fraction: 0, value: 4 }, + counterpart: "Bob", + negative: true, + subject: "Lunch", + when: AbsoluteTime.now(), + }, +]; + +export const Recent = tests.createExample(ReadyView, { + status: "ready", + error: undefined, + variant: "recent", + title: "Recent activity" as TranslatedString, + routeFullHistory: urlPattern(/.*/, () => "#/transactions"), + transactions, +}); + +export const History = tests.createExample(ReadyView, { + status: "ready", + error: undefined, + variant: "history", + title: "Transactions" as TranslatedString, + routeBack: urlPattern(/.*/, () => "#/account"), + transactions, + onGoNext: () => undefined, }); diff --git a/packages/libeufin-bank-webui/src/components/Transactions/views.tsx b/packages/libeufin-bank-webui/src/components/Transactions/views.tsx @@ -1,18 +1,11 @@ /* This file is part of GNU Taler - (C) 2022-2024 Taler Systems S.A. + (C) 2022-2024, 2026 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 { Attention, @@ -21,9 +14,9 @@ import { useBankCoreApiContext, useTranslationContext, } from "@gnu-taler/web-util/browser"; -import { format } from "date-fns"; +import { format, isToday, isYesterday } from "date-fns"; import { Fragment, VNode, h } from "preact"; -import { State } from "./index.js"; +import { State, Transaction } from "./index.js"; export function FailedView({ title, onRetry }: State.Failed): VNode { const { i18n } = useTranslationContext(); @@ -36,232 +29,243 @@ export function FailedView({ title, onRetry }: State.Failed): VNode { ); } +function dayLabel( + transaction: Transaction, + locale: Locale, + today: string, + yesterday: string, +): string { + if (transaction.when.t_ms === "never") return ""; + const date = new Date(transaction.when.t_ms); + if (isToday(date)) return today; + if (isYesterday(date)) return yesterday; + return format(date, "P", { locale }); +} + export function ReadyView({ transactions, - routeCreateWireTransfer, + variant, + title, + routeFullHistory, + routeBack, onGoNext, - onGoStart, + onGoPrevious, }: State.Ready): VNode { const { i18n, dateLocale } = useTranslationContext(); const { config } = useBankCoreApiContext(); + const groups = transactions.reduce<Record<string, Transaction[]>>( + (result, transaction) => { + const key = dayLabel( + transaction, + dateLocale, + i18n.str`Today`, + i18n.str`Yesterday`, + ); + (result[key] ??= []).push(transaction); + return result; + }, + {}, + ); - if (!transactions.length) { - return ( - <div class="px-4 mt-4"> - <div class="sm:flex sm:items-center"> - <div class="sm:flex-auto"> - <h1 class="text-base font-semibold leading-6 text-gray-900"> - <i18n.Translate>Transactions history</i18n.Translate> - </h1> - </div> - </div> - - <Attention type="low" title={i18n.str`No transactions yet.`}> - <i18n.Translate> - You can make a transfer or a withdrawal to your wallet. - </i18n.Translate> - </Attention> - </div> + function TransactionAmount({ + transaction, + }: { + transaction: Transaction; + }): VNode { + return transaction.amount ? ( + <span class="whitespace-nowrap text-sm font-semibold"> + <RenderAmount + value={transaction.amount} + negative={transaction.negative} + withColor + withSign + spec={config.currency_specification} + /> + </span> + ) : ( + <span class="text-sm text-secondary"> + <i18n.Translate>Invalid value</i18n.Translate> + </span> ); } - const txByDate = transactions.reduce( - (prev, cur) => { - const d = - cur.when.t_ms === "never" - ? "" - : format(cur.when.t_ms, "dd/MM/yyyy", { locale: dateLocale }); - if (!prev[d]) { - prev[d] = []; - } - prev[d].push(cur); - return prev; - }, - {} as Record<string, typeof transactions>, - ); return ( - <div class="px-4 mt-8"> - <div class="sm:flex sm:items-center"> - <div class="sm:flex-auto"> - <h1 class="text-base font-semibold leading-6 text-gray-900"> - <i18n.Translate>Transactions history</i18n.Translate> - </h1> - </div> + <section class="mt-8" aria-labelledby={`${variant}-activity-heading`}> + {variant === "history" && routeBack ? ( + <a + class="mb-3 inline-flex items-center gap-2 text-sm font-semibold text-primaryDark hover:underline focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary" + href={routeBack.url({})} + > + <svg + class="h-4 w-4" + fill="none" + viewBox="0 0 24 24" + stroke-width="2" + stroke="currentColor" + aria-hidden="true" + > + <path + stroke-linecap="round" + stroke-linejoin="round" + d="M10.5 19.5 3 12m0 0 7.5-7.5M3 12h18" + /> + </svg> + <i18n.Translate>Back to overview</i18n.Translate> + </a> + ) : undefined} + <div class="flex items-center justify-between gap-4"> + <h2 + id={`${variant}-activity-heading`} + class="text-lg font-semibold text-onBackground" + > + {title} + </h2> + {variant === "recent" && routeFullHistory ? ( + <a + class="text-sm font-semibold text-primaryDark hover:underline" + href={routeFullHistory.url({})} + > + <i18n.Translate>View all transactions</i18n.Translate> + </a> + ) : undefined} </div> - <div class="-mx-4 mt-5 ring-1 ring-gray-300 sm:mx-0 rounded-lg min-w-fit bg-white"> - <table class="min-w-full divide-y divide-gray-300"> - <thead> - <tr> - <th - scope="col" - class="pl-2 py-3.5 text-left text-sm font-semibold text-gray-900 " - >{i18n.str`Date`}</th> - <th - scope="col" - class="hidden sm:table-cell pl-2 py-3.5 text-left text-sm font-semibold text-gray-900 " - >{i18n.str`Amount`}</th> - <th - scope="col" - class="hidden sm:table-cell pl-2 py-3.5 text-left text-sm font-semibold text-gray-900 " - >{i18n.str`Counterparty`}</th> - <th - scope="col" - class="hidden sm:table-cell pl-2 py-3.5 text-left text-sm font-semibold text-gray-900 " - >{i18n.str`Subject`}</th> - </tr> - </thead> - <tbody> - {Object.entries(txByDate).map(([date, txs]) => { - return ( - <Fragment key={date}> - <tr class="border-t border-gray-200"> - <th - colSpan={4} - scope="colgroup" - class="bg-gray-50 py-2 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 sm:pl-3" - > - {date} - </th> - </tr> - {txs.map((item) => { - return ( - <tr - key={item.id} - class="border-b border-gray-200 last:border-none" - > - <td class="relative py-2 pl-2 pr-2 text-sm "> - <div class="font-medium text-gray-900"> - <Time - format="HH:mm:ss" - timestamp={item.when} - // relative={Duration.fromSpec({ days: 1 })} - /> - </div> - <dl class="font-normal sm:hidden"> - <dt class="sr-only sm:hidden"> - <i18n.Translate>Amount</i18n.Translate> - </dt> - <dd class="mt-1 truncate text-gray-700"> - {item.negative - ? i18n.str`sent` - : i18n.str`received`}{" "} - {item.amount ? ( - <span - data-negative={ - item.negative ? "true" : "false" - } - class="data-[negative=false]:text-green-600 data-[negative=true]:text-red-600" - > - <RenderAmount - value={item.amount} - spec={config.currency_specification} - /> - </span> - ) : ( - <span class="text-[grey]"> - &lt;{i18n.str`Invalid value`}&gt; - </span> - )} - </dd> - - <dt class="sr-only sm:hidden"> - <i18n.Translate>Counterparty</i18n.Translate> - </dt> - <dd class="mt-1 truncate text-gray-500 sm:hidden"> - {item.negative ? i18n.str`to` : i18n.str`from`}{" "} - {!routeCreateWireTransfer ? ( - item.counterpart - ) : ( - <a - name={`transfer to ${item.counterpart}`} - href={routeCreateWireTransfer.url({ - account: item.counterpart, - })} - class="text-indigo-600 hover:text-indigo-900" - > - {item.counterpart} - </a> - )} - </dd> - <dd class="mt-1 text-gray-500 sm:hidden"> - <pre class="break-words w-56 whitespace-break-spaces p-2 rounded-md mx-auto my-2 bg-gray-100"> - {item.subject} - </pre> - </dd> - </dl> - </td> - <td - data-negative={item.negative ? "true" : "false"} - class="hidden sm:table-cell px-3 py-3.5 text-sm text-gray-500 " - > - {item.amount ? ( - <RenderAmount - value={item.amount} - negative={item.negative} - withColor - withSign - spec={config.currency_specification} - /> - ) : ( - <span class="text-[grey]"> - &lt; - {i18n.str`Invalid value`}&gt; - </span> - )} - </td> - <td class="hidden sm:table-cell px-3 py-3.5 text-sm text-gray-500"> - {!routeCreateWireTransfer ? ( - item.counterpart - ) : ( - <a - name={`wire transfer to ${item.counterpart}`} - href={routeCreateWireTransfer.url({ - account: item.counterpart, - })} - class="text-indigo-600 hover:text-indigo-900" - > - {item.counterpart} - </a> - )} - </td> - <td class="hidden sm:table-cell px-3 py-3.5 text-sm text-gray-500 break-all min-w-md"> - {item.subject} - </td> - </tr> - ); - })} - </Fragment> - ); - })} - </tbody> - </table> - <nav - class="flex items-center justify-between border-t border-gray-200 bg-white px-4 py-3 sm:px-6 rounded-lg" - aria-label={i18n.str`Pagination`} - > - <div class="flex flex-1 justify-between sm:justify-end"> + {!transactions.length ? ( + <div class="mt-4"> + <Attention type="low" title={i18n.str`No transactions yet.`}> + <i18n.Translate> + Your account activity will appear here. + </i18n.Translate> + </Attention> + </div> + ) : variant === "recent" ? ( + <div class="mt-4 overflow-hidden rounded-lg border border-outlineVariant bg-white"> + {Object.entries(groups).map(([date, items]) => ( + <Fragment key={date}> + <h3 class="border-b border-outlineVariant bg-gray-50 px-4 py-2 text-xs font-semibold uppercase tracking-wide text-secondary"> + {date} + </h3> + <ul class="divide-y divide-outlineVariant"> + {items.map((item) => ( + <li + key={item.id} + class="grid gap-1 px-4 py-3 sm:grid-cols-[5rem_1fr_auto] sm:items-center sm:gap-4" + > + <span class="text-sm text-secondary"> + <Time format="HH:mm" timestamp={item.when} /> + </span> + <div class="min-w-0"> + <p class="truncate text-sm font-semibold text-onBackground"> + {item.counterpart} + </p> + <p class="truncate text-sm text-secondary"> + {item.subject} + </p> + </div> + <TransactionAmount transaction={item} /> + </li> + ))} + </ul> + </Fragment> + ))} + </div> + ) : ( + <Fragment> + <div class="mt-4 hidden overflow-hidden rounded-lg border border-outlineVariant sm:block"> + <table class="min-w-full divide-y divide-outlineVariant"> + <thead class="bg-gray-50"> + <tr> + <th + class="px-4 py-3 text-left text-sm font-semibold" + scope="col" + > + <i18n.Translate>Date</i18n.Translate> + </th> + <th + class="px-4 py-3 text-left text-sm font-semibold" + scope="col" + > + <i18n.Translate>Counterparty</i18n.Translate> + </th> + <th + class="px-4 py-3 text-left text-sm font-semibold" + scope="col" + > + <i18n.Translate>Subject</i18n.Translate> + </th> + <th + class="px-4 py-3 text-right text-sm font-semibold" + scope="col" + > + <i18n.Translate>Amount</i18n.Translate> + </th> + </tr> + </thead> + <tbody class="divide-y divide-outlineVariant bg-white"> + {transactions.map((item) => ( + <tr key={item.id}> + <td class="whitespace-nowrap px-4 py-3 text-sm text-secondary"> + {item.when.t_ms === "never" + ? "" + : format(item.when.t_ms, "P", { + locale: dateLocale, + })}{" "} + <Time format="HH:mm" timestamp={item.when} /> + </td> + <td class="px-4 py-3 text-sm font-medium"> + {item.counterpart} + </td> + <td class="max-w-md truncate px-4 py-3 text-sm text-secondary"> + {item.subject} + </td> + <td class="whitespace-nowrap px-4 py-3 text-right"> + <TransactionAmount transaction={item} /> + </td> + </tr> + ))} + </tbody> + </table> + </div> + <ul class="mt-4 divide-y divide-outlineVariant overflow-hidden rounded-lg border border-outlineVariant sm:hidden"> + {transactions.map((item) => ( + <li key={item.id} class="space-y-1 bg-white px-4 py-3"> + <div class="flex justify-between gap-3"> + <p class="font-semibold">{item.counterpart}</p> + <TransactionAmount transaction={item} /> + </div> + <p class="text-sm text-secondary">{item.subject}</p> + <p class="text-xs text-secondary"> + {item.when.t_ms === "never" + ? "" + : format(item.when.t_ms, "P", { locale: dateLocale })}{" "} + <Time format="HH:mm" timestamp={item.when} /> + </p> + </li> + ))} + </ul> + <nav + class="mt-4 flex justify-between" + aria-label={i18n.str`Pagination`} + > <button type="button" - name="first page" - class="relative disabled:bg-gray-100 disabled:text-gray-500 inline-flex items-center rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus-visible:outline-offset-0" - disabled={!onGoStart} - onClick={onGoStart} + disabled={!onGoPrevious} + onClick={onGoPrevious} + class="rounded-md border border-outlineVariant px-3 py-2 text-sm font-semibold disabled:opacity-40" > - <i18n.Translate>First page</i18n.Translate> + <i18n.Translate>Newer</i18n.Translate> </button> <button type="button" - name="next page" - class="relative disabled:bg-gray-100 disabled:text-gray-500 ml-3 inline-flex items-center rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus-visible:outline-offset-0" disabled={!onGoNext} onClick={onGoNext} + class="rounded-md border border-outlineVariant px-3 py-2 text-sm font-semibold disabled:opacity-40" > - <i18n.Translate>Next</i18n.Translate> + <i18n.Translate>Older</i18n.Translate> </button> - </div> - </nav> - </div> - </div> + </nav> + </Fragment> + )} + </section> ); } diff --git a/packages/libeufin-bank-webui/src/components/index.examples.ts b/packages/libeufin-bank-webui/src/components/index.examples.ts @@ -17,3 +17,5 @@ export * as tx from "./Transactions/stories.js"; export * as cashouts from "./Cashouts/stories.js"; export * as retryableError from "./RetryableError.stories.js"; +export * as demoBanner from "./DemoBanner.stories.js"; +export * as adminNavigation from "./AdminNavigation.stories.js"; diff --git a/packages/libeufin-bank-webui/src/developer-settings.test.ts b/packages/libeufin-bank-webui/src/developer-settings.test.ts @@ -0,0 +1,97 @@ +/* + This file is part of GNU Taler + (C) 2026 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. +*/ + +import assert from "node:assert"; +import { describe, it } from "node:test"; +import { + COREBANK_API_BASE_URL_OVERRIDE_KEY, + DEMO_BANNER_OVERRIDE_KEY, + PUBLIC_ACCOUNTS_OVERRIDE_KEY, + readDeveloperOverrides, + shouldShowDemoBanner, + shouldShowPublicAccounts, + writeDeveloperOverrides, +} from "./developer-settings.js"; + +function memoryStorage(initial: Record<string, string> = {}) { + const values = new Map(Object.entries(initial)); + return { + getItem(key: string): string | null { + return values.get(key) ?? null; + }, + setItem(key: string, value: string): void { + values.set(key, value); + }, + removeItem(key: string): void { + values.delete(key); + }, + }; +} + +describe("developer settings", () => { + it("reads valid overrides and ignores invalid toggle values", () => { + assert.deepEqual(readDeveloperOverrides(undefined), {}); + assert.deepEqual( + readDeveloperOverrides( + memoryStorage({ + [COREBANK_API_BASE_URL_OVERRIDE_KEY]: " https://bank.example/ ", + [DEMO_BANNER_OVERRIDE_KEY]: "true", + [PUBLIC_ACCOUNTS_OVERRIDE_KEY]: "false", + }), + ), + { + corebankApiBaseUrl: "https://bank.example/", + showDemoBanner: true, + showPublicAccounts: false, + }, + ); + assert.deepEqual( + readDeveloperOverrides( + memoryStorage({ + [DEMO_BANNER_OVERRIDE_KEY]: "invalid", + [PUBLIC_ACCOUNTS_OVERRIDE_KEY]: "invalid", + }), + ), + {}, + ); + }); + + it("writes and clears all overrides", () => { + const storage = memoryStorage(); + writeDeveloperOverrides(storage, { + corebankApiBaseUrl: "https://bank.example/", + showDemoBanner: false, + showPublicAccounts: true, + }); + assert.deepEqual(readDeveloperOverrides(storage), { + corebankApiBaseUrl: "https://bank.example/", + showDemoBanner: false, + showPublicAccounts: true, + }); + + writeDeveloperOverrides(storage, {}); + assert.deepEqual(readDeveloperOverrides(storage), {}); + }); + + it("lets the developer override take precedence over configuration and preferences", () => { + assert.equal(shouldShowDemoBanner(true, false, undefined), true); + assert.equal(shouldShowDemoBanner(true, true, undefined), false); + assert.equal(shouldShowDemoBanner(false, false, undefined), false); + assert.equal(shouldShowDemoBanner(false, true, true), true); + assert.equal(shouldShowDemoBanner(true, false, false), false); + assert.equal(shouldShowPublicAccounts(false, undefined), false); + assert.equal(shouldShowPublicAccounts(true, undefined), true); + assert.equal(shouldShowPublicAccounts(false, true), true); + assert.equal(shouldShowPublicAccounts(true, false), false); + }); +}); diff --git a/packages/libeufin-bank-webui/src/developer-settings.ts b/packages/libeufin-bank-webui/src/developer-settings.ts @@ -0,0 +1,94 @@ +/* + This file is part of GNU Taler + (C) 2026 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. +*/ + +export const COREBANK_API_BASE_URL_OVERRIDE_KEY = "corebank-api-base-url"; +export const DEMO_BANNER_OVERRIDE_KEY = "bank-demo-banner"; +export const PUBLIC_ACCOUNTS_OVERRIDE_KEY = "bank-public-accounts"; + +export interface DeveloperOverrides { + corebankApiBaseUrl?: string; + showDemoBanner?: boolean; + showPublicAccounts?: boolean; +} + +type ReadableStorage = Pick<Storage, "getItem">; +type WritableStorage = Pick<Storage, "setItem" | "removeItem">; + +export function readDeveloperOverrides( + storage: ReadableStorage | undefined, +): DeveloperOverrides { + if (!storage) return {}; + const corebankApiBaseUrl = + storage.getItem(COREBANK_API_BASE_URL_OVERRIDE_KEY)?.trim() || undefined; + const showDemoBannerValue = storage.getItem(DEMO_BANNER_OVERRIDE_KEY); + const showDemoBanner = + showDemoBannerValue === "true" + ? true + : showDemoBannerValue === "false" + ? false + : undefined; + const showPublicAccountsValue = storage.getItem(PUBLIC_ACCOUNTS_OVERRIDE_KEY); + const showPublicAccounts = + showPublicAccountsValue === "true" + ? true + : showPublicAccountsValue === "false" + ? false + : undefined; + return { + ...(corebankApiBaseUrl ? { corebankApiBaseUrl } : {}), + ...(showDemoBanner === undefined ? {} : { showDemoBanner }), + ...(showPublicAccounts === undefined ? {} : { showPublicAccounts }), + }; +} + +export function writeDeveloperOverrides( + storage: WritableStorage, + overrides: DeveloperOverrides, +): void { + if (overrides.corebankApiBaseUrl) { + storage.setItem( + COREBANK_API_BASE_URL_OVERRIDE_KEY, + overrides.corebankApiBaseUrl, + ); + } else { + storage.removeItem(COREBANK_API_BASE_URL_OVERRIDE_KEY); + } + if (overrides.showDemoBanner === undefined) { + storage.removeItem(DEMO_BANNER_OVERRIDE_KEY); + } else { + storage.setItem(DEMO_BANNER_OVERRIDE_KEY, String(overrides.showDemoBanner)); + } + if (overrides.showPublicAccounts === undefined) { + storage.removeItem(PUBLIC_ACCOUNTS_OVERRIDE_KEY); + } else { + storage.setItem( + PUBLIC_ACCOUNTS_OVERRIDE_KEY, + String(overrides.showPublicAccounts), + ); + } +} + +export function shouldShowDemoBanner( + configured: boolean, + hiddenByPreference: boolean, + override: boolean | undefined, +): boolean { + return override ?? (configured && !hiddenByPreference); +} + +export function shouldShowPublicAccounts( + configured: boolean, + override: boolean | undefined, +): boolean { + return override ?? configured; +} diff --git a/packages/libeufin-bank-webui/src/hooks/account.ts b/packages/libeufin-bank-webui/src/hooks/account.ts @@ -31,7 +31,9 @@ import { dummyHttpResponse } from "@gnu-taler/taler-util/http"; import { LONG_POLL_DELAY, PaginatedResult, + buildPaginatedResult as buildBidirectionalPaginatedResult, useBankCoreApiContext, + useListPointer, useLongPolling, } from "@gnu-taler/web-util/browser"; import _useSWR, { mutate, SWRHook } from "swr"; @@ -80,20 +82,21 @@ export function revalidateWithdrawalDetails() { ); } -export function useWithdrawalDetails(wid: string) { +export function useWithdrawalDetails(wid: string | undefined) { const { lib: { bank: api }, } = useBankCoreApiContext(); type Res = undefined | Awaited<ReturnType<typeof api.getWithdrawalById>>; const result = useLongPolling( - async (ct, res): Promise<Res> => { + (ct, res): Promise<Res> | undefined => { + if (!wid) return undefined; const old_state = !res || res instanceof TalerError || res.type === "fail" ? undefined : res.body.status; - return await api.getWithdrawalById(wid, { + return api.getWithdrawalById(wid, { old_state, timeoutMs: !old_state ? undefined : LONG_POLL_DELAY, ct, @@ -260,10 +263,16 @@ export function revalidateTransactions() { ); } +function transactionRowId( + transaction: TalerCorebankApi.BankAccountTransactionInfo, +): string { + return String(transaction.row_id); +} + export function useTransactions( account: string, - initial?: number, anonymous = false, + pageSize = 20, ): | TalerError<{ requestUrl: string; @@ -279,22 +288,29 @@ export function useTransactions( ? undefined : credentials.token; - const [offset, setOffset] = useState<number | undefined>(initial); + const [pointer, setPointer] = + useListPointer<TalerCorebankApi.BankAccountTransactionInfo>( + transactionRowId, + ); - useEffect(() => setOffset(initial), [account, initial]); + useEffect( + () => setPointer(undefined, "dec"), + [account, anonymous, pageSize, setPointer], + ); const { lib: { bank: api }, } = useBankCoreApiContext(); - async function fetcher([username, token, txid]: [ + async function fetcher([username, token, offset, order]: [ string, AccessToken | undefined, - number | undefined, + string | undefined, + "asc" | "dec", ]) { const params = { - limit: PAGINATED_LIST_REQUEST, - offset: txid ? String(txid) : undefined, - order: "dec" as const, + limit: pageSize + 1, + offset, + order, }; return token === undefined ? api.getPublicTransactions(username, params) @@ -306,7 +322,7 @@ export function useTransactions( TalerHttpError >( anonymous || token !== undefined - ? [account, token, offset, "getTransactions"] + ? [account, token, pointer.id, pointer.order, pageSize, "getTransactions"] : null, fetcher, { @@ -324,10 +340,10 @@ export function useTransactions( if (data === undefined) return undefined; if (data.type !== "ok") return data; - return buildPaginatedResult( + return buildBidirectionalPaginatedResult( data.body.transactions, - offset, - setOffset, - (d) => d.row_id, + pointer, + setPointer, + pageSize + 1, ); } diff --git a/packages/libeufin-bank-webui/src/hooks/bank-state.test.ts b/packages/libeufin-bank-webui/src/hooks/bank-state.test.ts @@ -0,0 +1,60 @@ +/* + This file is part of GNU Taler + (C) 2026 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 assert from "node:assert"; +import { describe, it } from "node:test"; +import { codecForBankState } from "./bank-state.js"; + +describe("persisted bank withdrawal state", () => { + it("keeps a legacy operation ID available for migration", () => { + assert.deepEqual( + codecForBankState().decode({ + currentWithdrawalOperationId: "legacy-withdrawal", + }), + { + currentWithdrawalOperationId: "legacy-withdrawal", + activeWithdrawal: undefined, + currentChallenge: undefined, + }, + ); + }); + + it("records the account, backend, and explicit confirmation deferral", () => { + const activeWithdrawal = { + operationId: "withdrawal-1", + username: "alice", + backendBaseUrl: "https://bank.example/", + confirmationDeferred: true, + }; + assert.deepEqual(codecForBankState().decode({ activeWithdrawal }), { + currentWithdrawalOperationId: undefined, + activeWithdrawal, + currentChallenge: undefined, + }); + }); + + it("rejects an incomplete active withdrawal record", () => { + assert.throws(() => + codecForBankState().decode({ + activeWithdrawal: { + operationId: "withdrawal-1", + username: "alice", + backendBaseUrl: "https://bank.example/", + }, + }), + ); + }); +}); diff --git a/packages/libeufin-bank-webui/src/hooks/bank-state.ts b/packages/libeufin-bank-webui/src/hooks/bank-state.ts @@ -23,6 +23,7 @@ import { buildCodecForUnion, codecForAbsoluteTime, codecForAny, + codecForBoolean, codecForConstString, codecForString, codecOptional, @@ -171,20 +172,39 @@ const codecForChallenge = (): Codec<ChallengeInProgess> => .alternative("login", codecForLoginChallenge()) .build("ChallengeInProgess"); +export interface ActiveWithdrawal { + operationId: string; + username: string; + backendBaseUrl: string; + confirmationDeferred: boolean; +} + +const codecForActiveWithdrawal = (): Codec<ActiveWithdrawal> => + buildCodecForObject<ActiveWithdrawal>() + .property("operationId", codecForString()) + .property("username", codecForString()) + .property("backendBaseUrl", codecForString()) + .property("confirmationDeferred", codecForBoolean()) + .build("ActiveWithdrawal"); + interface BankState { // Optional: decoded with codecOptional, so an absent field is missing. + /** @deprecated Migrated to activeWithdrawal after the next authenticated render. */ currentWithdrawalOperationId?: string; + activeWithdrawal?: ActiveWithdrawal; currentChallenge?: ChallengeInProgess; } export const codecForBankState = (): Codec<BankState> => buildCodecForObject<BankState>() .property("currentWithdrawalOperationId", codecOptional(codecForString())) + .property("activeWithdrawal", codecOptional(codecForActiveWithdrawal())) .property("currentChallenge", codecOptional(codecForChallenge())) .build("BankState"); const defaultBankState: BankState = { currentWithdrawalOperationId: undefined, + activeWithdrawal: undefined, currentChallenge: undefined, }; @@ -207,6 +227,9 @@ export function useBankState(): [ function updateField<T extends keyof BankState>(k: T, v: BankState[T]) { const newValue = { ...value, [k]: v }; + if (k === "activeWithdrawal") { + newValue.currentWithdrawalOperationId = undefined; + } update(newValue); } function reset() { diff --git a/packages/libeufin-bank-webui/src/hooks/preferences.test.ts b/packages/libeufin-bank-webui/src/hooks/preferences.test.ts @@ -0,0 +1,50 @@ +/* + This file is part of GNU Taler + (C) 2026 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 assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { + codecForPreferences, + getAllBooleanPreferences, +} from "./preferences.js"; + +describe("bank interface preferences", () => { + const currentPreferences = { + hideDemo: true, + showInstallWallet: true, + fastWithdrawalForm: false, + }; + + it("does not require the removed withdrawal confirmation preference", () => { + assert.deepEqual( + codecForPreferences().decode(currentPreferences), + currentPreferences, + ); + }); + + it("accepts legacy storage without exposing the preference in settings", () => { + assert.doesNotThrow(() => + codecForPreferences().decode({ + ...currentPreferences, + showWithdrawalSuccess: false, + }), + ); + assert.deepEqual(getAllBooleanPreferences({}), [ + "showInstallWallet", + "fastWithdrawalForm", + ]); + }); +}); diff --git a/packages/libeufin-bank-webui/src/hooks/preferences.ts b/packages/libeufin-bank-webui/src/hooks/preferences.ts @@ -29,7 +29,6 @@ import { UiSettings } from "../settings.js"; import { codecOptionalDefault } from "@gnu-taler/taler-util"; interface Preferences { - showWithdrawalSuccess: boolean; hideDemo: boolean; showInstallWallet: boolean; fastWithdrawalForm: boolean; @@ -38,14 +37,12 @@ interface Preferences { export const codecForPreferences = (): Codec<Preferences> => buildCodecForObject<Preferences>() .allowExtra() - .property("showWithdrawalSuccess", codecForBoolean()) .property("hideDemo", codecOptionalDefault(codecForBoolean(), false)) .property("showInstallWallet", codecForBoolean()) .property("fastWithdrawalForm", codecForBoolean()) .build("Preferences"); const defaultPreferences: Preferences = { - showWithdrawalSuccess: true, hideDemo: true, showInstallWallet: true, fastWithdrawalForm: false, @@ -79,15 +76,17 @@ export function usePreferences(): [ export function getAllBooleanPreferences( settings: UiSettings, ): Array<keyof Preferences> { - if (settings.showDemoDescription) { + if ( + settings.showDemoBannerOverride === undefined && + settings.showDemoDescription + ) { return [ "hideDemo", "showInstallWallet", - "showWithdrawalSuccess", "fastWithdrawalForm", ]; } - return ["showInstallWallet", "showWithdrawalSuccess", "fastWithdrawalForm"]; + return ["showInstallWallet", "fastWithdrawalForm"]; } export function getLabelForPreferences( @@ -95,8 +94,6 @@ export function getLabelForPreferences( i18n: ReturnType<typeof useTranslationContext>["i18n"], ): TranslatedString { switch (k) { - case "showWithdrawalSuccess": - return i18n.str`Show withdrawal confirmation`; case "fastWithdrawalForm": return i18n.str`Withdraw without setting amount`; case "hideDemo": diff --git a/packages/libeufin-bank-webui/src/hooks/regional.ts b/packages/libeufin-bank-webui/src/hooks/regional.ts @@ -34,7 +34,11 @@ import { opFixedSuccess, } from "@gnu-taler/taler-util"; import { dummyHttpResponse } from "@gnu-taler/taler-util/http"; -import { useBankCoreApiContext } from "@gnu-taler/web-util/browser"; +import { + buildPaginatedResult as buildBidirectionalPaginatedResult, + useBankCoreApiContext, + useListPointer, +} from "@gnu-taler/web-util/browser"; import { useEffect, useState } from "preact/hooks"; import _useSWR, { SWRHook, mutate } from "swr"; import { PAGINATED_LIST_REQUEST } from "../utils.js"; @@ -298,7 +302,11 @@ export async function revalidateBusinessAccounts() { { revalidate: true }, ); } -export function useBusinessAccounts() { +function accountRowId(account: TalerCorebankApi.AccountMinimalData): string { + return String(account.row_id ?? 0); +} + +export function useBusinessAccounts(filterName?: string) { const { state: credentials } = useSessionState(); const token = credentials.status !== "loggedIn" ? undefined : credentials.token; @@ -306,20 +314,28 @@ export function useBusinessAccounts() { lib: { bank: api }, } = useBankCoreApiContext(); - const [offset, setOffset] = useState<number | undefined>(); + const [pointer, setPointer] = + useListPointer<TalerCorebankApi.AccountMinimalData>(accountRowId); + useEffect(() => setPointer(undefined, "dec"), [filterName, setPointer]); - function fetcher([token, aid]: [AccessToken, number]) { + function fetcher([token, offset, order, account]: [ + AccessToken, + string | undefined, + "asc" | "dec", + string | undefined, + ]) { return api.listAccounts(token, { limit: PAGINATED_LIST_REQUEST, - offset: aid ? String(aid) : undefined, - order: "asc", + offset, + order, + account, }); } const { data, error } = useSWR< TalerCoreBankResultByMethod<"listAccounts">, TalerHttpError - >([token, offset ?? 0, "listAccounts"], fetcher, { + >([token, pointer.id, pointer.order, filterName, "listAccounts"], fetcher, { refreshInterval: 0, refreshWhenHidden: false, revalidateOnFocus: false, @@ -336,11 +352,11 @@ export function useBusinessAccounts() { if (data.type !== "ok") return data; //TODO: row_id should not be optional - return buildPaginatedResult( + return buildBidirectionalPaginatedResult( data.body.accounts, - offset, - setOffset, - (d) => d.row_id ?? 0, + pointer, + setPointer, + PAGINATED_LIST_REQUEST, ); } diff --git a/packages/libeufin-bank-webui/src/pages/AccountPage/index.ts b/packages/libeufin-bank-webui/src/pages/AccountPage/index.ts @@ -15,9 +15,7 @@ */ import { - AbsoluteTime, AmountJson, - TalerCorebankApi, TalerError, TranslatedString, } from "@gnu-taler/taler-util"; @@ -27,29 +25,16 @@ import { VNode } from "preact"; import { LoginForm } from "../LoginForm.js"; import { useComponentState } from "./state.js"; import { RetryableError } from "../../components/RetryableError.js"; -import { InvalidIbanView, ReadyView } from "./views.js"; -import { IntAmountJson } from "../regional/CreateCashout.js"; +import { ReadyView } from "./views.js"; export interface Props { account: string; - onOperationCreated: (wopid: string) => void; - onClose: () => void; - tab: "charge-wallet" | "wire-transfer" | undefined; - routeClose: RouteDefinition; routeCashout: RouteDefinition; routeChargeWallet: RouteDefinition; - routeWireTransfer: RouteDefinition<{ - account?: string; - subject?: string; - amount?: string; - }>; - routePublicAccounts: RouteDefinition; - routeCreateWireTransfer: RouteDefinition<{ - account?: string; - subject?: string; - amount?: string; - }>; + routeWireTransfer: RouteDefinition; + routeTransactions: RouteDefinition; + routeAccountDetails: RouteDefinition; routeOperationDetails: RouteDefinition<{ wopid: string }>; } @@ -57,7 +42,6 @@ export type State = | State.Loading | State.LoadingError | State.Ready - | State.InvalidIban | State.UserNotFound; export namespace State { @@ -81,34 +65,21 @@ export namespace State { status: "ready"; error: undefined; account: string; - tab: "charge-wallet" | "wire-transfer" | undefined; - limit: IntAmountJson; + accountPaytoUri: string; + accountLegalName: string; + accountLoginName: string; + accountBankHost?: string; balance: AmountJson; - - onOperationCreated: (wopid: string) => void; - onClose: () => void; - routeClose: RouteDefinition; + balanceIsDebit: boolean; routeCashout: RouteDefinition; routeChargeWallet: RouteDefinition; - routePublicAccounts: RouteDefinition; - routeWireTransfer: RouteDefinition<{ - account?: string; - subject?: string; - amount?: string; - }>; - routeCreateWireTransfer: RouteDefinition<{ - account?: string; - subject?: string; - amount?: string; - }>; + routeWireTransfer: RouteDefinition; + routeTransactions: RouteDefinition; + routeAccountDetails: RouteDefinition; + showRecentActivity?: boolean; routeOperationDetails: RouteDefinition<{ wopid: string }>; } - export interface InvalidIban { - status: "invalid-iban"; - error: TalerCorebankApi.AccountData; - } - export interface UserNotFound { status: "login"; reason: "not-found" | "forbidden"; @@ -116,18 +87,9 @@ export namespace State { } } -export interface Transaction { - negative: boolean; - counterpart: string; - when: AbsoluteTime; - amount: AmountJson | undefined; - subject: string; -} - const viewMapping: utils.StateViewMap<State> = { loading: Loading, login: LoginForm, - "invalid-iban": InvalidIbanView, "loading-error": RetryableError, ready: ReadyView, }; diff --git a/packages/libeufin-bank-webui/src/pages/AccountPage/state.ts b/packages/libeufin-bank-webui/src/pages/AccountPage/state.ts @@ -17,7 +17,6 @@ import { Amounts, HttpStatusCode, - PaytoType, Paytos, TalerError, assertUnreachable, @@ -26,22 +25,17 @@ import { revalidateAccountDetails, 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, - tab, routeChargeWallet, - routeCreateWireTransfer, - routePublicAccounts, routeOperationDetails, routeWireTransfer, routeCashout, - onOperationCreated, - onClose, - routeClose, + routeTransactions, + routeAccountDetails, }: Props): State { const result = useAccountDetails(account); const { i18n } = useTranslationContext(); @@ -83,48 +77,25 @@ export function useComponentState({ const { body: data } = result; - const balance = Amounts.parseOrThrow(data.balance.amount); - - const debitThreshold = Amounts.parseOrThrow(data.debit_threshold); - const payto = Paytos.fromString(data.payto_uri); - - if ( - payto.tag === "error" || - !payto.value.targetType || - (payto.value.targetType !== PaytoType.IBAN && - payto.value.targetType !== PaytoType.TalerBank) - ) { - return { - status: "invalid-iban", - error: data, - }; - } - const balanceIsDebit = data.balance.credit_debit_indicator == "debit"; - const limit = IntAmounts.toIntAmount(balance, balanceIsDebit).increment( - debitThreshold, - ).result; - - const positiveBalance = balanceIsDebit - ? Amounts.zeroOfAmount(balance) - : balance; + const parsedPayto = Paytos.fromString(data.payto_uri); + const payto = parsedPayto.tag === "ok" ? parsedPayto.value : undefined; return { status: "ready", - onOperationCreated, error: undefined, - tab, routeCashout, routeOperationDetails, - routeCreateWireTransfer, - routePublicAccounts, - - onClose, - routeClose, routeChargeWallet, routeWireTransfer, + routeTransactions, + routeAccountDetails, account, - limit, - balance: positiveBalance, + accountPaytoUri: data.payto_uri, + accountLegalName: Paytos.getAccountHolder(payto) ?? data.name, + accountLoginName: Paytos.getAccountNumber(payto) ?? account, + accountBankHost: Paytos.getBankHost(payto), + balance: Amounts.parseOrThrow(data.balance.amount), + balanceIsDebit, }; } diff --git a/packages/libeufin-bank-webui/src/pages/AccountPage/stories.tsx b/packages/libeufin-bank-webui/src/pages/AccountPage/stories.tsx @@ -31,22 +31,17 @@ const route = urlPattern<any>(/.*/, () => "#"); export const Ready = tests.createExample(ReadyView, { account: "alice", - tab: undefined, - limit: { - currency: "ASR", - value: 10, - fraction: 0, - negative: false, - saturated: false, - }, + accountPaytoUri: "payto://x-taler-bank/bank.example/alice", + accountLegalName: "Alice Example", + accountLoginName: "alice", + accountBankHost: "bank.example", balance: { currency: "ASR", value: 10, fraction: 0 }, - onOperationCreated: () => undefined, - onClose: () => undefined, - routeClose: route, + balanceIsDebit: false, routeCashout: route, routeChargeWallet: route, - routePublicAccounts: route, routeWireTransfer: route, - routeCreateWireTransfer: route, + routeTransactions: route, + routeAccountDetails: route, routeOperationDetails: route, + showRecentActivity: false, }); diff --git a/packages/libeufin-bank-webui/src/pages/AccountPage/views.tsx b/packages/libeufin-bank-webui/src/pages/AccountPage/views.tsx @@ -14,97 +14,197 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ -import { Attention, useTranslationContext } from "@gnu-taler/web-util/browser"; +import { + CopyButton, + RenderAmount, + useBankCoreApiContext, + useTranslationContext, +} from "@gnu-taler/web-util/browser"; import { Fragment, VNode, h } from "preact"; +import { useEffect, useRef, useState } from "preact/hooks"; import { Transactions } from "../../components/Transactions/index.js"; -import { usePreferences } from "../../hooks/preferences.js"; import { PaymentOptions } from "../PaymentOptions.js"; import { State } from "./index.js"; -import { RouteDefinition } from "@gnu-taler/web-util/browser"; -import { useSettingsContext } from "../../context/settings.js"; -export function InvalidIbanView({ error }: State.InvalidIban) { - return ( - <div> - Payto from server is not valid &quot; - {error.payto_uri}&quot; - </div> - ); -} - -const IS_PUBLIC_ACCOUNT_ENABLED = false; - -function ShowDemoInfo({ - routePublicAccounts, +function AccountAddressMenu({ + accountPaytoUri, + accountLegalName, + accountLoginName, + accountBankHost, }: { - routePublicAccounts: RouteDefinition; + accountPaytoUri: string; + accountLegalName: string; + accountLoginName: string; + accountBankHost?: string; }): VNode { const { i18n } = useTranslationContext(); - const settings = useSettingsContext(); - const [preferences, updatePreferences] = usePreferences(); - if (!settings.showDemoDescription || preferences.hideDemo) - return <Fragment />; + const [open, setOpen] = useState(false); + const containerRef = useRef<HTMLDivElement>(null); + const triggerRef = useRef<HTMLButtonElement>(null); + const shortAddress = accountBankHost + ? `${accountLoginName} @ ${accountBankHost}` + : accountLoginName; + + useEffect(() => { + if (!open) return; + + const closeOnOutsideClick = (event: PointerEvent): void => { + if (!containerRef.current?.contains(event.target as Node)) { + setOpen(false); + } + }; + const closeOnEscape = (event: KeyboardEvent): void => { + if (event.key !== "Escape") return; + setOpen(false); + triggerRef.current?.focus(); + }; + + document.addEventListener("pointerdown", closeOnOutsideClick); + document.addEventListener("keydown", closeOnEscape); + return () => { + document.removeEventListener("pointerdown", closeOnOutsideClick); + document.removeEventListener("keydown", closeOnEscape); + }; + }, [open]); + return ( - <Attention - title={i18n.str`This is a demo`} - onClose={() => { - updatePreferences("hideDemo", true); - }} - > - {IS_PUBLIC_ACCOUNT_ENABLED ? ( - <i18n.Translate> - This part of the demo shows how a bank that supports Taler directly - would work. In addition to using your own bank account, you can also - see the transaction history of some{" "} - <a name="public account" href={routePublicAccounts.url({})}> - Public Accounts - </a> - . - </i18n.Translate> - ) : ( - <i18n.Translate> - Here you will be able to see how a bank that supports Taler directly - would work. - </i18n.Translate> - )} - </Attention> + <div class="relative" ref={containerRef}> + <p class="text-sm font-semibold text-secondary"> + <i18n.Translate>Account address</i18n.Translate> + </p> + <p + class="mt-2 truncate text-sm font-medium text-onBackground" + title={accountLegalName} + > + {accountLegalName} + </p> + <button + ref={triggerRef} + type="button" + class="mt-0.5 inline-flex max-w-full items-center gap-1 text-left text-sm text-secondary hover:underline hover:underline-offset-2 focus-visible:rounded-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary" + aria-expanded={open} + aria-controls="account-address-actions" + onClick={() => setOpen((value) => !value)} + title={shortAddress} + > + <span class="truncate">{shortAddress}</span> + <svg + class={`h-3.5 w-3.5 shrink-0 text-secondary transition-transform ${open ? "rotate-180" : ""}`} + fill="none" + viewBox="0 0 24 24" + stroke-width="2" + stroke="currentColor" + aria-hidden="true" + > + <path + stroke-linecap="round" + stroke-linejoin="round" + d="m6 9 6 6 6-6" + /> + </svg> + </button> + {open ? ( + <div + id="account-address-actions" + class="absolute left-0 z-20 mt-1 min-w-56 rounded-lg border border-outlineVariant bg-white p-1 shadow-lg" + > + <CopyButton + class="flex w-full items-center gap-2 rounded-md px-3 py-2 text-left text-sm text-onBackground hover:bg-primary/10 hover:text-primaryDark focus-visible:outline focus-visible:outline-2 focus-visible:outline-primary" + getContent={() => accountPaytoUri} + onCopyComplete={() => setOpen(false)} + > + <i18n.Translate>Copy account address</i18n.Translate> + </CopyButton> + </div> + ) : undefined} + </div> ); } export function ReadyView({ - tab, account, + accountPaytoUri, + accountLegalName, + accountLoginName, + accountBankHost, routeChargeWallet, routeWireTransfer, - limit, balance, + balanceIsDebit, routeCashout, - routeCreateWireTransfer, - routePublicAccounts, routeOperationDetails, - onClose, - routeClose, - onOperationCreated, + routeTransactions, + routeAccountDetails, + showRecentActivity = true, }: State.Ready): VNode { + const { config } = useBankCoreApiContext(); + const { i18n } = useTranslationContext(); return ( <Fragment> - <ShowDemoInfo routePublicAccounts={routePublicAccounts} /> + <section + class="rounded-xl border border-secondary/20 bg-secondaryContainer px-5 py-5 sm:px-6" + aria-labelledby="current-balance-title" + > + <div class="grid gap-5 sm:grid-cols-[minmax(12rem,0.8fr)_minmax(0,1.2fr)] sm:items-center"> + <div> + <p + id="current-balance-title" + class="text-sm font-semibold text-secondary" + > + <i18n.Translate>Current balance</i18n.Translate> + </p> + <div class="mt-1 text-3xl font-bold tracking-tight text-brand sm:text-4xl"> + <RenderAmount + value={balance} + negative={balanceIsDebit} + withSign + spec={config.currency_specification} + /> + </div> + <a + class="mt-3 inline-flex items-center gap-1 text-sm font-semibold text-primaryDark hover:underline focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary" + href={routeAccountDetails.url({})} + > + <i18n.Translate>Go to account details</i18n.Translate> + <svg + class="h-4 w-4" + fill="none" + viewBox="0 0 24 24" + stroke-width="2" + stroke="currentColor" + aria-hidden="true" + > + <path + stroke-linecap="round" + stroke-linejoin="round" + d="m9 18 6-6-6-6" + /> + </svg> + </a> + </div> + <div class="min-w-0 border-t border-primary/20 pt-4 sm:border-l sm:border-t-0 sm:pl-6 sm:pt-0"> + <AccountAddressMenu + accountPaytoUri={accountPaytoUri} + accountLegalName={accountLegalName} + accountLoginName={accountLoginName} + accountBankHost={accountBankHost} + /> + </div> + </div> + </section> <PaymentOptions - tab={tab} routeOperationDetails={routeOperationDetails} routeCashout={routeCashout} routeChargeWallet={routeChargeWallet} routeWireTransfer={routeWireTransfer} - limit={limit} - balance={balance} - routeClose={routeClose} - onClose={onClose} - onOperationCreated={onOperationCreated} - /> - <Transactions - account={account} - routeCreateWireTransfer={routeCreateWireTransfer} /> + {showRecentActivity ? ( + <Transactions + account={account} + variant="recent" + routeFullHistory={routeTransactions} + /> + ) : undefined} </Fragment> ); } diff --git a/packages/libeufin-bank-webui/src/pages/ActiveWithdrawal.tsx b/packages/libeufin-bank-webui/src/pages/ActiveWithdrawal.tsx @@ -0,0 +1,322 @@ +/* + This file is part of GNU Taler + (C) 2026 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 { + HostPortPath, + HttpStatusCode, + TalerError, + assertUnreachable, +} from "@gnu-taler/taler-util"; +import { + RouteDefinition, + useBankCoreApiContext, + useNotificationContext, + useNavigationContext, + useNotifiedOperation, + useTranslationContext, +} from "@gnu-taler/web-util/browser"; +import { ComponentChildren, Fragment, VNode, h } from "preact"; +import { useEffect, useRef, useState } from "preact/hooks"; +import { useWithdrawalDetails } from "../hooks/account.js"; +import { useBankState } from "../hooks/bank-state.js"; +import { LoggedIn, useSessionState } from "../hooks/session.js"; +import { NeedConfirmationView } from "./OperationState/views.js"; +import { AbortWithdrawalDialog } from "../components/AbortWithdrawalDialog.js"; +import { + buildWithdrawalOperationState, + isTerminalWithdrawalResult, +} from "./OperationState/state.js"; + +interface ActiveWithdrawalProps { + username: string; + openOperationId?: string; + routeOperation: RouteDefinition<{ wopid: string }>; + routeClose: RouteDefinition; + children: ComponentChildren; +} + +export function ActiveWithdrawal({ + username, + openOperationId, + routeOperation, + routeClose, + children, +}: ActiveWithdrawalProps): VNode { + const { i18n } = useTranslationContext(); + const { navigateTo } = useNavigationContext(); + const [bankState, updateBankState] = useBankState(); + const { + url: backendUrl, + lib: { bank }, + } = useBankCoreApiContext(); + const active = bankState.activeWithdrawal; + const activeMatches = + active?.username === username && active.backendBaseUrl === backendUrl.href; + const operationId = activeMatches ? active.operationId : undefined; + const operationRouteOpen = openOperationId !== undefined; + const routeHasConflict = + activeMatches && + openOperationId !== undefined && + openOperationId !== operationId; + const watchedOperationId = operationRouteOpen ? undefined : operationId; + const polledOperationId = useRef(watchedOperationId); + const polledResult = useWithdrawalDetails(watchedOperationId); + const result = + polledOperationId.current === watchedOperationId ? polledResult : undefined; + polledOperationId.current = watchedOperationId; + + useEffect(() => { + if (active || !bankState.currentWithdrawalOperationId) return; + updateBankState("activeWithdrawal", { + operationId: bankState.currentWithdrawalOperationId, + username, + backendBaseUrl: backendUrl.href, + confirmationDeferred: false, + }); + }, [ + active, + backendUrl.href, + bankState.currentWithdrawalOperationId, + updateBankState, + username, + ]); + + useEffect(() => { + if (!active || activeMatches) return; + updateBankState("activeWithdrawal", undefined); + }, [active, activeMatches, updateBankState]); + + useEffect(() => { + if ( + active || + bankState.currentWithdrawalOperationId || + openOperationId === undefined + ) { + return; + } + updateBankState("activeWithdrawal", { + operationId: openOperationId, + username, + backendBaseUrl: backendUrl.href, + confirmationDeferred: false, + }); + }, [ + active, + backendUrl.href, + bankState.currentWithdrawalOperationId, + openOperationId, + updateBankState, + username, + ]); + + useEffect(() => { + if (!routeHasConflict || !operationId) return; + navigateTo(routeOperation.url({ wopid: operationId })); + }, [navigateTo, operationId, routeHasConflict, routeOperation]); + + useEffect(() => { + if (!activeMatches || !result || !isTerminalWithdrawalResult(result)) { + return; + } + updateBankState("activeWithdrawal", undefined); + navigateTo(routeClose.url({})); + }, [activeMatches, navigateTo, result, routeClose, updateBankState]); + + const clear = (): void => updateBankState("activeWithdrawal", undefined); + const finish = (): void => { + clear(); + navigateTo(routeClose.url({})); + }; + const defer = (): void => { + if (!active) return; + updateBankState("activeWithdrawal", { + ...active, + confirmationDeferred: true, + }); + }; + + let selectedDialog: VNode | undefined; + let status: "checking" | "pending" | "selected" | "error" = "checking"; + if (result instanceof TalerError) { + status = "error"; + } else if (result?.type === "ok") { + status = + result.body.status === "selected" + ? "selected" + : result.body.status === "pending" + ? "pending" + : "checking"; + if ( + status === "selected" && + active && + !active.confirmationDeferred && + !operationRouteOpen + ) { + const state = buildWithdrawalOperationState({ + result, + operationId: active.operationId, + bankIntegrationApiBaseUrl: bank.getIntegrationAPI() + .href as HostPortPath, + routeClose, + onAbort: finish, + onContinueLater: defer, + loadingErrorTitle: i18n.str`Failed to load withdrawal details.`, + onRetry: () => undefined, + }); + if (state.status === "need-confirmation") { + selectedDialog = <NeedConfirmationView {...state} />; + } + } + } + + const terminal = result !== undefined && isTerminalWithdrawalResult(result); + const showNotice = !!operationId && !operationRouteOpen && !terminal; + + return ( + <Fragment> + {showNotice ? ( + <ActiveWithdrawalNotice + operationId={operationId} + status={status} + routeOperation={routeOperation} + onReview={() => { + if (!active) return; + updateBankState("activeWithdrawal", { + ...active, + confirmationDeferred: false, + }); + }} + onAborted={finish} + /> + ) : undefined} + {routeHasConflict ? undefined : children} + {selectedDialog} + </Fragment> + ); +} + +export function ActiveWithdrawalNotice({ + operationId, + status, + routeOperation, + onReview, + onAborted, +}: { + operationId: string; + status: "checking" | "pending" | "selected" | "error"; + routeOperation: RouteDefinition<{ wopid: string }>; + onReview(): void; + onAborted(): void; +}): VNode { + const { i18n } = useTranslationContext(); + const { showError } = useNotificationContext(); + const [confirmAbort, setConfirmAbort] = useState(false); + const abortTriggerRef = useRef<HTMLButtonElement>(null); + const { state: credentials } = useSessionState(); + const creds = credentials.status === "loggedIn" ? credentials : undefined; + const { + lib: { bank }, + } = useBankCoreApiContext(); + + const abort = useNotifiedOperation< + Awaited<ReturnType<typeof bank.abortWithdrawalById>>, + [LoggedIn] + >((ct, auth: LoggedIn) => bank.abortWithdrawalById(auth, operationId), { + onSuccess: () => { + setConfirmAbort(false); + onAborted(); + }, + 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 title = + status === "selected" + ? i18n.str`Withdrawal ready for confirmation` + : status === "pending" + ? i18n.str`Withdrawal waiting for your wallet` + : status === "error" + ? i18n.str`The pending withdrawal could not be checked` + : i18n.str`Checking the pending withdrawal…`; + const action = + status === "selected" + ? i18n.str`Review withdrawal` + : status === "error" + ? i18n.str`Retry` + : i18n.str`Resume withdrawal`; + const needsAttention = status === "selected"; + + return ( + <Fragment> + <section + class={`mb-4 flex flex-col gap-3 rounded-lg border px-4 py-3 text-sm sm:flex-row sm:items-center sm:justify-between ${ + needsAttention + ? "border-warning/60 bg-warningContainer text-onWarningContainer" + : "border-primary/30 bg-primary/10 text-onBackground" + }`} + aria-labelledby="active-withdrawal-title" + role={needsAttention ? "alert" : "status"} + > + <div> + <h2 id="active-withdrawal-title" class="font-semibold text-inherit"> + {title} + </h2> + <p class="mt-1 text-inherit opacity-80"> + <i18n.Translate> + Finish or abort this withdrawal before starting another one. + </i18n.Translate> + </p> + </div> + <div class="flex flex-wrap items-center gap-3"> + <button + ref={abortTriggerRef} + type="button" + class="text-sm font-semibold text-red-700 hover:text-red-800" + onClick={() => setConfirmAbort(true)} + > + <i18n.Translate>Abort withdrawal</i18n.Translate> + </button> + <a + href={routeOperation.url({ wopid: operationId })} + class="rounded-md bg-primary px-3 py-2 text-sm font-semibold text-onPrimary shadow-sm hover:bg-primaryHover hover:text-white" + onClick={onReview} + > + {action} + </a> + </div> + </section> + {confirmAbort ? ( + <AbortWithdrawalDialog + running={abort.running} + disabled={!creds} + returnFocus={abortTriggerRef} + onKeep={() => setConfirmAbort(false)} + onAbort={() => abort.run(creds!)} + /> + ) : undefined} + </Fragment> + ); +} diff --git a/packages/libeufin-bank-webui/src/pages/AuthForm.tsx b/packages/libeufin-bank-webui/src/pages/AuthForm.tsx @@ -0,0 +1,164 @@ +/* + This file is part of GNU Taler + (C) 2026 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. +*/ + +import { TranslatedString } from "@gnu-taler/taler-util"; +import { useTranslationContext } from "@gnu-taler/web-util/browser"; +import { ComponentChildren, Ref, VNode, h } from "preact"; +import { useState } from "preact/hooks"; +import { PASSWORD_MAX_LENGTH } from "./auth-validation.js"; + +export function AuthCard({ + title, + subtitle, + children, + footer, +}: { + title: TranslatedString; + subtitle?: TranslatedString; + children: ComponentChildren; + footer?: ComponentChildren; +}): VNode { + return ( + <div class="mx-auto w-full max-w-md py-2 sm:py-6"> + <div class="text-center"> + <h1 class="text-2xl font-bold leading-9 tracking-tight text-onBackground"> + {title} + </h1> + {subtitle ? ( + <p class="mt-2 text-sm leading-6 text-gray-600">{subtitle}</p> + ) : undefined} + </div> + <div class="mt-6 rounded-lg border border-gray-200 bg-white px-5 py-6 shadow-sm sm:px-8"> + {children} + </div> + {footer ? ( + <div class="mt-5 text-center text-sm text-gray-600">{footer}</div> + ) : undefined} + </div> + ); +} + +export function AuthAlert({ + type, + children, +}: { + type: "error" | "success" | "warning"; + children: ComponentChildren; +}): VNode { + const classes = + type === "error" + ? "border-red-200 bg-red-50 text-red-800" + : type === "success" + ? "border-green-200 bg-green-50 text-green-800" + : "border-amber-200 bg-amber-50 text-amber-900"; + return ( + <div + class={`mb-5 rounded-md border px-3 py-2 text-sm ${classes}`} + role={type === "error" ? "alert" : "status"} + > + {children} + </div> + ); +} + +export function FieldError({ + id, + message, + visible, +}: { + id: string; + message?: TranslatedString; + visible: boolean; +}): VNode { + return ( + <p id={id} class="mt-1 min-h-5 text-sm text-red-700"> + {visible ? message : undefined} + </p> + ); +} + +export function PasswordField({ + id, + name, + label, + value, + error, + showError, + autoComplete, + inputRef, + enterKeyHint, + onInput, + onBlur, +}: { + id: string; + name: string; + label: TranslatedString; + value: string; + error?: TranslatedString; + showError: boolean; + autoComplete: "current-password" | "new-password"; + inputRef?: Ref<HTMLInputElement>; + enterKeyHint?: "next" | "go" | "done"; + onInput(value: string): void; + onBlur(): void; +}): VNode { + const { i18n } = useTranslationContext(); + const [visible, setVisible] = useState(false); + const errorId = `${id}-error`; + const invalid = showError && !!error; + + return ( + <div> + <label + htmlFor={id} + class="block text-sm font-medium leading-6 text-onBackground" + > + {label} + </label> + <div class="relative mt-2"> + <input + ref={inputRef} + type={visible ? "text" : "password"} + name={name} + id={id} + autocomplete={autoComplete} + maxLength={PASSWORD_MAX_LENGTH} + class="block w-full rounded-md border-0 py-1.5 pr-16 text-onBackground shadow-sm ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-primary sm:text-sm sm:leading-6" + enterkeyhint={enterKeyHint} + value={value} + required + aria-invalid={invalid} + aria-describedby={invalid ? errorId : undefined} + onInput={(event) => onInput(event.currentTarget.value)} + onBlur={onBlur} + /> + <button + type="button" + class="absolute inset-y-0 right-0 px-3 text-xs font-semibold text-primaryDark hover:text-onPrimaryContainer focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary" + aria-pressed={visible} + aria-label={ + visible ? i18n.str`Hide password` : i18n.str`Show password` + } + onClick={() => setVisible((current) => !current)} + > + {visible ? ( + <i18n.Translate>Hide</i18n.Translate> + ) : ( + <i18n.Translate>Show</i18n.Translate> + )} + </button> + </div> + <FieldError id={errorId} message={error} visible={showError} /> + </div> + ); +} diff --git a/packages/libeufin-bank-webui/src/pages/Authentication.stories.tsx b/packages/libeufin-bank-webui/src/pages/Authentication.stories.tsx @@ -11,18 +11,46 @@ A PARTICULAR PURPOSE. See the GNU General Public License for more details. */ +import { TranslatedString } from "@gnu-taler/taler-util"; import { urlPattern } from "@gnu-taler/web-util/browser"; import * as tests from "@gnu-taler/web-util/testing"; -import { LoginForm } from "./LoginForm.js"; -import { RegistrationPage } from "./RegistrationPage.js"; +import { LoginFormView } from "./LoginForm.js"; +import { RegistrationFormView } from "./RegistrationPage.js"; export default { title: "authentication forms", }; -export const Login = tests.createExample(LoginForm, {}); +const routeLogin = urlPattern(/.*/, () => "#/login"); +const routeRegister = urlPattern(/.*/, () => "#/register"); -export const Registration = tests.createExample(RegistrationPage, { - routeCancel: urlPattern(/.*/, () => "#"), - onRegistrationSuccesful: () => undefined, +export const Login = tests.createExample(LoginFormView, { + bankName: "Taler Bank", + registrationsAllowed: true, + routeRegister, + onSubmit: () => undefined, +}); + +export const LoginError = tests.createExample(LoginFormView, { + bankName: "Taler Bank", + registrationsAllowed: true, + routeRegister, + initialUsername: "alice", + operationError: "The password is incorrect." as TranslatedString, + onSubmit: () => undefined, +}); + +export const Reauthentication = tests.createExample(LoginFormView, { + bankName: "Taler Bank", + initialUsername: "alice", + fixedUser: true, + reauthentication: true, + sessionExpired: true, + onSubmit: () => undefined, + onSignOut: () => undefined, +}); + +export const Registration = tests.createExample(RegistrationFormView, { + routeLogin, + onSubmit: () => undefined, }); diff --git a/packages/libeufin-bank-webui/src/pages/BankFrame.stories.tsx b/packages/libeufin-bank-webui/src/pages/BankFrame.stories.tsx @@ -19,17 +19,28 @@ * @author Sebastian Javier Marchano (sebasjm) */ -import { RouteDefinition } from "@gnu-taler/web-util/browser"; import * as tests from "@gnu-taler/web-util/testing"; -import { ComponentChildren } from "preact"; import { BankFrame } from "./BankFrame.js"; export default { title: "bank frame", }; -export const Ready: tests.ExampleItemSetup<{ - account?: string; - routeAccountDetails?: RouteDefinition; - children: ComponentChildren; -}> = tests.createExample(BankFrame, {}); +export const Ready = tests.createExample(BankFrame, {}); + +export const DemoLayout = tests.createExample( + BankFrame, + {}, + { + settings: { + showDemoDescription: true, + showDemoBannerOverride: true, + topNavSites: { + Introduction: "https://test.taler.net/", + Bank: "https://bank.test.taler.net/", + "Essay Shop": "https://shop.test.taler.net/", + Donations: "https://donations.test.taler.net/", + }, + }, + }, +); diff --git a/packages/libeufin-bank-webui/src/pages/BankFrame.tsx b/packages/libeufin-bank-webui/src/pages/BankFrame.tsx @@ -1,6 +1,6 @@ /* This file is part of GNU Taler - (C) 2022-2024 Taler Systems S.A. + (C) 2022-2024, 2026 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 @@ -16,7 +16,6 @@ import { AbsoluteTime, - Amounts, ObservabilityEventType, TalerError, assertUnreachable, @@ -25,16 +24,16 @@ import { Footer, Header, Loading, - RenderAmount, - RouteDefinition, ToastBanner, useBankCoreApiContext, useCommonPreferences, + useNotificationContext, + useOptionalNavigationContext, useRenderErrorReport, useTranslationContext, } from "@gnu-taler/web-util/browser"; import { ComponentChildren, Fragment, VNode, h } from "preact"; -import { useEffect, useState } from "preact/hooks"; +import { useEffect, useRef, useState } from "preact/hooks"; import { useSettingsContext } from "../context/settings.js"; import { revalidateAccountDetails, @@ -47,6 +46,13 @@ import { usePreferences, } from "../hooks/preferences.js"; import { useSessionState } from "../hooks/session.js"; +import { DemoBanner } from "../components/DemoBanner.js"; +import { OperationError } from "../components/OperationError.js"; +import { + COREBANK_API_BASE_URL_OVERRIDE_KEY, + shouldShowDemoBanner, + shouldShowPublicAccounts, +} from "../developer-settings.js"; const TALER_SCREEN_ID = 103; @@ -71,18 +77,38 @@ BankFrame.SCREEN_ID = TALER_SCREEN_ID; export function BankFrame({ children, account, - routeAccountDetails, + navigation, + publicAccountsUrl, }: { account?: string; - routeAccountDetails?: RouteDefinition; + navigation?: ComponentChildren; + publicAccountsUrl?: string; children: ComponentChildren; }): VNode { const { i18n } = useTranslationContext(); const session = useSessionState(); const settings = useSettingsContext(); - const [{ showDebugInfo }, update] = useCommonPreferences(); - const [preferences, updatePreferences] = usePreferences(); + const [preferences] = usePreferences(); + const showDemoBanner = shouldShowDemoBanner( + settings.showDemoDescription ?? false, + preferences.hideDemo, + settings.showDemoBannerOverride, + ); + const showPublicAccounts = shouldShowPublicAccounts( + settings.showPublicAccounts ?? false, + settings.showPublicAccountsOverride, + ); const [, , resetBankState] = useBankState(); + const path = useOptionalNavigationContext()?.path; + const { clear: clearNotifications, clearErrors } = useNotificationContext(); + const previousPath = useRef(path); + const clearErrorsRef = useRef(clearErrors); + clearErrorsRef.current = clearErrors; + useEffect(() => { + if (previousPath.current === path) return; + previousPath.current = path; + clearErrorsRef.current(); + }, [path]); const d = useBankCoreApiContext(); const config = d === undefined ? undefined : d.config; const authenticator = d === undefined ? undefined : d.lib.bank; @@ -91,6 +117,7 @@ export function BankFrame({ typeof window !== "undefined" && window.sessionStorage.getItem(REVOCATION_WARNING_KEY) === "true", ); + const [settingsOpen, setSettingsOpen] = useState(false); async function logOut(): Promise<void> { let revocationConfirmed = false; @@ -110,6 +137,7 @@ export function BankFrame({ } catch { revocationConfirmed = false; } finally { + clearNotifications(); session.logOut(); resetBankState(); setRevocationWarning(!revocationConfirmed); @@ -119,6 +147,7 @@ export function BankFrame({ } else { window.sessionStorage.setItem(REVOCATION_WARNING_KEY, "true"); } + window.location.hash = "#/login"; } } } @@ -130,168 +159,301 @@ export function BankFrame({ return ( <div - class="min-h-full flex flex-col m-0 bg-slate-200" + class="min-h-full flex flex-col m-0 bg-background text-onBackground" style="min-height: 100vh;" > - <div class="bg-indigo-600 pb-32"> - <Header - title={config?.bank_name ?? "Bank"} - iconLinkURL={settings.iconLinkURL ?? "#"} - profileURL={routeAccountDetails?.url({})} - notificationURL={undefined} - onLogout={ - session.state.status !== "loggedIn" - ? undefined - : () => void logOut() - } - sites={ - !settings.topNavSites ? [] : Object.entries(settings.topNavSites) - } + {showDemoBanner ? ( + <DemoBanner sites={settings.topNavSites ?? {}} /> + ) : ( + <div class={`bg-brand ${account ? "" : "pb-32"}`}> + <Header + title={config?.bank_name ?? "Bank"} + iconLinkURL={settings.iconLinkURL ?? "#"} + profileURL={undefined} + notificationURL={undefined} + onLogout={undefined} + showMenu={false} + sites={[]} + backgroundClass="bg-brand" + /> + </div> + )} + + <main class={account || showDemoBanner ? "flex-1" : "-mt-32 flex-1"}> + <div + class={`mx-auto max-w-7xl px-4 pb-4 sm:px-6 lg:px-8 ${account || showDemoBanner ? "pt-6" : ""}`} > - <li> - <div class="text-xs font-semibold leading-6 text-gray-400"> - <i18n.Translate>Preferences</i18n.Translate> - </div> - <ul class="space-y-4"> - {getAllBooleanPreferences(settings).map((set) => { - const isOn: boolean = !!preferences[set]; - return ( - <li key={set} class="pl-2"> - <div class="flex items-center justify-between"> - <span class="flex flex-grow flex-col"> - <span - class="text-sm text-black font-medium leading-6 " - id={`preference-${set}-label`} - > - {getLabelForPreferences(set, i18n)} - </span> - </span> - <button - type="button" - name={`${set} switch`} - data-enabled={isOn} - class="bg-indigo-600 data-[enabled=false]:bg-gray-200 relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-indigo-600 focus:ring-offset-2" - role="switch" - aria-checked={isOn} - aria-labelledby={`preference-${set}-label`} - onClick={() => { - updatePreferences(set, !isOn); - }} - > - <span - aria-hidden="true" - data-enabled={isOn} - class="translate-x-5 data-[enabled=false]:translate-x-0 pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out" - ></span> - </button> - </div> - </li> - ); - })} - <li class="pl-2"> - <div class="flex items-center justify-between"> - <span class="flex flex-grow flex-col"> - <span - class="text-sm text-black font-medium leading-6 " - id="debug-preference-label" - > - <i18n.Translate>Show debug information</i18n.Translate> - </span> + <div + class={ + account + ? undefined + : "rounded-lg bg-white px-5 py-6 shadow sm:px-6" + } + > + {account ? ( + <header class="mb-4 flex items-center justify-between gap-4"> + <h1 class="text-2xl font-bold tracking-tight text-brand"> + <WelcomeAccount account={account} /> + </h1> + <button + type="button" + class="shrink-0 rounded-md border border-outlineVariant px-3 py-2 text-sm font-semibold text-secondary hover:border-primary/40 hover:bg-primary/10 hover:text-primaryDark focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary" + onClick={() => void logOut()} + > + <i18n.Translate>Sign out</i18n.Translate> + </button> + </header> + ) : undefined} + {navigation} + <div class="mb-4 space-y-2 empty:hidden"> + <ToastBanner compact messageType="info" /> + {revocationWarning && ( + <div + class="flex items-start justify-between gap-4 rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-900" + role="alert" + > + <span> + <i18n.Translate> + You were signed out locally, but the bank could not + confirm that the server token was revoked. Close other + sessions or sign in again to review active tokens. + </i18n.Translate> </span> <button type="button" - name={`debug switch`} - data-enabled={showDebugInfo} - class="bg-indigo-600 data-[enabled=false]:bg-gray-200 relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-indigo-600 focus:ring-offset-2" - role="switch" - aria-checked={showDebugInfo} - aria-labelledby="debug-preference-label" + class="shrink-0 font-semibold underline" onClick={() => { - update("showDebugInfo", !showDebugInfo); + setRevocationWarning(false); + if (typeof window !== "undefined") { + window.sessionStorage.removeItem( + REVOCATION_WARNING_KEY, + ); + } }} > - <span - aria-hidden="true" - data-enabled={showDebugInfo} - class="translate-x-5 data-[enabled=false]:translate-x-0 pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out" - ></span> + <i18n.Translate>Dismiss</i18n.Translate> </button> </div> - </li> - </ul> - </li> - </Header> - </div> - - <div class="fixed z-40 top-10 w-full"> - <div class="mx-auto w-4/5"> - <ToastBanner /> - {revocationWarning && ( - <div - class="mt-2 flex items-start justify-between gap-4 rounded-md bg-amber-50 p-4 text-sm text-amber-900 shadow" - role="alert" - > - <span> - <i18n.Translate> - You were signed out locally, but the bank could not confirm - that the server token was revoked. Close other sessions or - sign in again to review active tokens. - </i18n.Translate> - </span> - <button - type="button" - class="font-semibold underline" - onClick={() => { - setRevocationWarning(false); - if (typeof window !== "undefined") { - window.sessionStorage.removeItem(REVOCATION_WARNING_KEY); - } - }} - > - <i18n.Translate>Dismiss</i18n.Translate> - </button> - </div> - )} - </div> - </div> - - <main class="-mt-32 flex-1"> - {account && routeAccountDetails && ( - <header class="py-6 bg-indigo-600"> - <div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8"> - <h1 class=" flex flex-wrap items-center justify-between sm:flex-nowrap"> - <span class="text-2xl font-bold tracking-tight text-white"> - <WelcomeAccount - account={account} - routeAccountDetails={routeAccountDetails} - /> - </span> - <span class="text-2xl font-bold tracking-tight text-white"> - <AccountBalance account={account} /> - </span> - </h1> + )} </div> - </header> - )} - - <div class="mx-auto max-w-7xl px-4 pb-4 sm:px-6 lg:px-8"> - <div class="rounded-lg bg-white px-5 py-6 shadow sm:px-6"> - {!failed ? children : undefined} + {failed ? <OperationError class="mb-4" /> : children} </div> </div> </main> <AppActivity /> + <BankSettingsDialog + open={settingsOpen} + onClose={() => setSettingsOpen(false)} + onSignOut={ + session.state.status === "loggedIn" + ? async () => { + setSettingsOpen(false); + await logOut(); + } + : undefined + } + /> + <Footer - testingUrlKey="corebank-api-base-url" + testingUrlKey={COREBANK_API_BASE_URL_OVERRIDE_KEY} GIT_HASH={GIT_HASH} VERSION={VERSION} + variant="demo" + actions={ + <div class="flex items-center gap-3"> + {showPublicAccounts && publicAccountsUrl ? ( + <a + href={publicAccountsUrl} + class="text-xs font-semibold text-gray-500 hover:text-primaryDark hover:underline" + > + <i18n.Translate>Public accounts</i18n.Translate> + </a> + ) : undefined} + <button + type="button" + class="rounded-full p-2 text-gray-500 hover:bg-primary/10 hover:text-primaryDark focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary" + aria-label={i18n.str`Open interface preferences`} + title={i18n.str`Interface preferences`} + onClick={() => setSettingsOpen(true)} + > + <svg + class="h-5 w-5" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + stroke-width="1.5" + aria-hidden="true" + > + <path + stroke-linecap="round" + stroke-linejoin="round" + d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.592c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827a1.125 1.125 0 0 0-.43 1.012v.254c-.008.378.153.738.43.992l1.003.827c.424.35.534.956.26 1.431l-1.296 2.247a1.125 1.125 0 0 1-1.37.49l-1.217-.456a1.125 1.125 0 0 0-1.075.124 6.57 6.57 0 0 1-.22.127 1.125 1.125 0 0 0-.645.87l-.213 1.281c-.09.542-.56.94-1.11.94h-2.592c-.55 0-1.02-.398-1.11-.94l-.213-1.281a1.125 1.125 0 0 0-.645-.87 6.52 6.52 0 0 1-.22-.127 1.125 1.125 0 0 0-1.075-.124l-1.217.456a1.125 1.125 0 0 1-1.37-.49L3.568 15.38a1.125 1.125 0 0 1 .26-1.431l1.003-.827c.277-.254.438-.614.43-.992v-.254a1.125 1.125 0 0 0-.43-1.012l-1.003-.827a1.125 1.125 0 0 1-.26-1.431l1.296-2.247a1.125 1.125 0 0 1 1.37-.49l1.217.456c.355.133.751.072 1.075-.124.073-.044.146-.087.22-.127.332-.184.582-.496.645-.87l.213-1.281Z" + /> + <path + stroke-linecap="round" + stroke-linejoin="round" + d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" + /> + </svg> + </button> + </div> + } /> </div> ); } +function BankSettingsDialog({ + open, + onClose, + onSignOut, +}: { + open: boolean; + onClose(): void; + onSignOut?: () => void | Promise<void>; +}): VNode { + const { i18n } = useTranslationContext(); + const settings = useSettingsContext(); + const [{ showDebugInfo }, updateCommonPreference] = useCommonPreferences(); + const [preferences, updatePreference] = usePreferences(); + + useEffect(() => { + if (!open || typeof window === "undefined") return; + const closeOnEscape = (event: KeyboardEvent): void => { + if (event.key === "Escape") onClose(); + }; + window.addEventListener("keydown", closeOnEscape); + return () => window.removeEventListener("keydown", closeOnEscape); + }, [onClose, open]); + + if (!open) return <Fragment />; + + return ( + <dialog + ref={(element) => { + if (element && !element.open) element.showModal(); + }} + aria-modal="true" + aria-labelledby="bank-settings-title" + class="fixed inset-0 z-50 size-auto max-h-none max-w-none overflow-y-auto bg-transparent p-4" + > + <div class="fixed inset-0 bg-secondary/45" aria-hidden="true" /> + <div class="relative flex min-h-full items-center justify-center"> + <section class="w-full max-w-md rounded-xl bg-white p-6 text-onBackground shadow-xl"> + <div class="flex items-start justify-between gap-4"> + <div> + <h2 id="bank-settings-title" class="text-lg font-semibold"> + <i18n.Translate>Interface preferences</i18n.Translate> + </h2> + <p class="mt-1 text-sm text-gray-600"> + <i18n.Translate> + Choose how this bank interface behaves on this browser. + </i18n.Translate> + </p> + </div> + <button + type="button" + class="rounded p-1 text-xl leading-none text-gray-500 hover:bg-gray-100 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary" + aria-label={i18n.str`Close interface preferences`} + onClick={onClose} + > + <span aria-hidden="true">×</span> + </button> + </div> + + <ul class="mt-6 divide-y divide-gray-200"> + {getAllBooleanPreferences(settings).map((preference) => { + const isOn = !!preferences[preference]; + const labelId = `settings-preference-${preference}`; + return ( + <li + key={preference} + class="flex items-center justify-between gap-4 py-4" + > + <span id={labelId} class="text-sm font-medium"> + {getLabelForPreferences(preference, i18n)} + </span> + <PreferenceSwitch + name={`${preference} switch`} + labelId={labelId} + enabled={isOn} + onChange={() => updatePreference(preference, !isOn)} + /> + </li> + ); + })} + <li class="flex items-center justify-between gap-4 py-4"> + <span id="settings-debug-label" class="text-sm font-medium"> + <i18n.Translate>Show debug information</i18n.Translate> + </span> + <PreferenceSwitch + name="debug switch" + labelId="settings-debug-label" + enabled={showDebugInfo} + onChange={() => + updateCommonPreference("showDebugInfo", !showDebugInfo) + } + /> + </li> + </ul> + + <div class="mt-6 flex justify-end gap-3"> + {onSignOut ? ( + <button + type="button" + class="mr-auto rounded-md border border-gray-300 bg-white px-3 py-2 text-sm font-semibold text-onBackground shadow-sm hover:bg-gray-50 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-600" + onClick={() => void onSignOut()} + > + <i18n.Translate>Sign out</i18n.Translate> + </button> + ) : undefined} + <button + type="button" + class="rounded-md bg-primary px-3 py-2 text-sm font-semibold text-onPrimary shadow-sm hover:bg-primaryHover hover:text-white focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary" + onClick={onClose} + > + <i18n.Translate>Done</i18n.Translate> + </button> + </div> + </section> + </div> + </dialog> + ); +} + +function PreferenceSwitch({ + name, + labelId, + enabled, + onChange, +}: { + name: string; + labelId: string; + enabled: boolean; + onChange(): void; +}): VNode { + return ( + <button + type="button" + name={name} + data-enabled={enabled} + class="relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent bg-primary transition-colors data-[enabled=false]:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2" + role="switch" + aria-checked={enabled} + aria-labelledby={labelId} + onClick={onChange} + > + <span + aria-hidden="true" + data-enabled={enabled} + class="pointer-events-none inline-block h-5 w-5 translate-x-5 transform rounded-full bg-white shadow ring-0 transition data-[enabled=false]:translate-x-0" + /> + </button> + ); +} + Wait.SCREEN_ID = TALER_SCREEN_ID; function Wait({ class: clazz }: { class?: string }): VNode { return ( @@ -303,7 +465,7 @@ function Wait({ class: clazz }: { class?: string }): VNode { border-radius: 50%; aspect-ratio: 1; padding: 1px; - background: conic-gradient(#0000 10%,#4f46e5) content-box; + background: conic-gradient(#0000 10%,#0042b3) content-box; -webkit-mask: repeating-conic-gradient(#0000 0deg,#000 1deg 20deg,#0000 21deg 36deg), radial-gradient(farthest-side,#0000 calc(100% - var(--b) - 1px),#000 calc(100% - var(--b))); @@ -320,6 +482,7 @@ function Wait({ class: clazz }: { class?: string }): VNode { AppActivity.SCREEN_ID = TALER_SCREEN_ID; function AppActivity(): VNode { + const { i18n } = useTranslationContext(); const [lastEvent, setLastEvent] = useState<{ url: string; id: string; @@ -395,7 +558,7 @@ function AppActivity(): VNode { if (cancelRequest) cancelRequest(lastEvent.id); }} > - cancel + <i18n.Translate>Cancel</i18n.Translate> </button> ) : undefined} </div> @@ -404,13 +567,7 @@ function AppActivity(): VNode { } WelcomeAccount.SCREEN_ID = TALER_SCREEN_ID; -function WelcomeAccount({ - account, - routeAccountDetails, -}: { - account: string; - routeAccountDetails: RouteDefinition; -}): VNode { +function WelcomeAccount({ account }: { account: string }): VNode { const { i18n } = useTranslationContext(); const result = useAccountDetails(account); if (!result) { @@ -420,46 +577,13 @@ function WelcomeAccount({ return <AccountDetailsRetry />; } if (result.type === "fail") { - return ( - <a - name="account details" - href={routeAccountDetails.url({})} - class="underline underline-offset-2" - > - <i18n.Translate>Welcome</i18n.Translate> - </a> - ); + return <i18n.Translate>Welcome</i18n.Translate>; } return ( - <a - name="account details" - href={routeAccountDetails.url({})} - class="underline underline-offset-2" - > + <span> <i18n.Translate> Welcome, <span class="whitespace-nowrap">{result.body.name}</span> </i18n.Translate> - </a> - ); -} - -function AccountBalance({ account }: { account: string }): VNode { - const result = useAccountDetails(account); - const { config } = useBankCoreApiContext(); - if (!result) { - return <Loading />; - } - if (result instanceof TalerError) { - return <AccountDetailsRetry />; - } - if (result.type === "fail") return <div />; - - return ( - <RenderAmount - value={Amounts.parseOrThrow(result.body.balance.amount)} - negative={result.body.balance.credit_debit_indicator === "debit"} - spec={config.currency_specification} - withSign - /> + </span> ); } diff --git a/packages/libeufin-bank-webui/src/pages/ConversionRateClassDetails.tsx b/packages/libeufin-bank-webui/src/pages/ConversionRateClassDetails.tsx @@ -53,9 +53,10 @@ import { import { useSessionState } from "../hooks/session.js"; import { RecursivePartial, undefinedIfEmpty } from "../utils.js"; import { DescribeConversion } from "./admin/ConversionClassList.js"; -import { doAutoFocus, InputAmount } from "./PaytoWireTransferForm.js"; +import { InputAmount } from "./PaytoWireTransferForm.js"; import { ConversionForm } from "./regional/ConversionConfig.js"; import { RetryableError } from "../components/RetryableError.js"; +import { OperationError } from "../components/OperationError.js"; interface Props { classId: number; @@ -335,157 +336,61 @@ function Form({ const both_high = in_ratio > 1 && out_ratio > 1; const both_low = in_ratio < 1 && out_ratio < 1; + const sections = [ + { id: "detail", label: i18n.str`Details` }, + { id: "cashout", label: i18n.str`Cashout settings` }, + { id: "cashin", label: i18n.str`Cash-in settings` }, + { id: "users", label: i18n.str`Accounts` }, + { id: "test", label: i18n.str`Test conversion` }, + { id: "delete", label: i18n.str`Delete class` }, + ] as const; + 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"> - <div class="px-4 sm:px-0"> - <h2 class="text-base font-semibold leading-7 text-gray-900"> - <i18n.Translate>Conversion rate class</i18n.Translate> - </h2> - <div class="px-2 mt-2 grid grid-cols-1 gap-y-4 sm:gap-x-4"> - <label - aria-label={i18n.str`Details`} - data-enabled={section === "detail"} - class="relative flex cursor-pointer rounded-lg border bg-white p-4 shadow-sm focus:outline-none border-gray-300 data-[enabled=true]:border-indigo-600 data-[enabled=true]:ring-2 data-[enabled=true]:ring-indigo-600" - > - <input - type="radio" - name="project-type" - value="detail" - checked={section === "detail"} - class="sr-only" - onChange={() => { - setSection("detail"); - }} - /> - <span class="flex flex-1"> - <span class="flex flex-col"> - <span class="block text-sm font-medium text-gray-900"> - <i18n.Translate>Details</i18n.Translate> - </span> - </span> - </span> - </label> - <label - aria-label={i18n.str`Config cashout`} - data-enabled={section === "cashout"} - class="relative flex cursor-pointer rounded-lg border bg-white p-4 shadow-sm focus:outline-none border-gray-300 -- data-[enabled=true]:border-indigo-600 data-[enabled=true]:ring-2 data-[enabled=true]:ring-indigo-600" - > - <input - type="radio" - name="project-type" - value="cashout" - checked={section === "cashout"} - class="sr-only" - onChange={() => { - setSection("cashout"); - }} - /> - <span class="flex flex-1"> - <span class="flex flex-col"> - <span class="block text-sm font-medium text-gray-900"> - <i18n.Translate>Config cashout</i18n.Translate> - </span> - </span> - </span> - </label> - <label - aria-label={i18n.str`Config cashin`} - data-enabled={section === "cashin"} - class="relative flex cursor-pointer rounded-lg border bg-white p-4 shadow-sm focus:outline-none border-gray-300 -- data-[enabled=true]:border-indigo-600 data-[enabled=true]:ring-2 data-[enabled=true]:ring-indigo-600" - > - <input - type="radio" - name="project-type" - value="cashin" - checked={section === "cashin"} - class="sr-only" - onChange={() => { - setSection("cashin"); - }} - /> - <span class="flex flex-1"> - <span class="flex flex-col"> - <span class="block text-sm font-medium text-gray-900"> - <i18n.Translate>Config cashin</i18n.Translate> - </span> - </span> - </span> - </label> - <label - aria-label={i18n.str`Accounts`} - data-enabled={section === "users"} - class="relative flex cursor-pointer rounded-lg border bg-white p-4 shadow-sm focus:outline-none border-gray-300 data-[enabled=true]:border-indigo-600 data-[enabled=true]:ring-2 data-[enabled=true]:ring-indigo-600" - > - <input - type="radio" - name="project-type" - value="users" - checked={section === "users"} - class="sr-only" - onChange={() => { - setSection("users"); - }} - /> - <span class="flex flex-1"> - <span class="flex flex-col"> - <span class="block text-sm font-medium text-gray-900"> - <i18n.Translate>Accounts</i18n.Translate> - </span> - </span> - </span> - </label> - <label - aria-label={i18n.str`Test`} - data-enabled={section === "test"} - class="relative flex cursor-pointer rounded-lg border bg-white p-4 shadow-sm focus:outline-none border-gray-300 data-[enabled=true]:border-indigo-600 data-[enabled=true]:ring-2 data-[enabled=true]:ring-indigo-600" - > - <input - type="radio" - name="project-type" - value="test" - checked={section === "test"} - class="sr-only" - onChange={() => { - setSection("test"); - }} - /> - <span class="flex flex-1"> - <span class="flex flex-col"> - <span class="block text-sm font-medium text-gray-900"> - <i18n.Translate>Test</i18n.Translate> - </span> - </span> - </span> - </label>{" "} - <label - aria-label={i18n.str`Delete`} - data-enabled={section === "delete"} - class="relative flex cursor-pointer rounded-lg border bg-white p-4 shadow-sm focus:outline-none border-gray-300 data-[enabled=true]:border-indigo-600 data-[enabled=true]:ring-2 data-[enabled=true]:ring-indigo-600" + <section class="mt-2"> + <a + href={routeCancel.url({})} + class="inline-flex items-center text-sm font-semibold text-brand hover:underline" + > + <span class="mr-1" aria-hidden="true"> + ← + </span> + <i18n.Translate>Back to conversion settings</i18n.Translate> + </a> + <div class="mx-auto mt-6 max-w-4xl"> + <h1 class="text-2xl font-semibold text-brand">{detailsResult.name}</h1> + <p class="mt-2 text-sm text-gray-600"> + <i18n.Translate> + Manage this conversion rate class and the accounts assigned to it. + </i18n.Translate> + </p> + + <nav + aria-label={i18n.str`Conversion rate class sections`} + class="mt-6 flex flex-wrap gap-2" + > + {sections.map((item) => ( + <button + key={item.id} + type="button" + aria-current={section === item.id ? "page" : undefined} + onClick={() => setSection(item.id)} + class={`rounded-full border px-3 py-1.5 text-sm font-semibold transition-colors ${ + section === item.id + ? item.id === "delete" + ? "border-red-700 bg-red-50 text-red-800" + : "border-primary bg-primary text-onPrimary" + : item.id === "delete" + ? "border-red-200 bg-white text-red-700 hover:bg-red-50" + : "border-gray-300 bg-white text-onBackground hover:border-primary hover:text-primary" + }`} > - <input - type="radio" - name="project-type" - value="delete" - checked={section === "delete"} - class="sr-only" - onChange={() => { - setSection("delete"); - }} - /> - <span class="flex flex-1"> - <span class="flex flex-col"> - <span class="block text-sm font-medium text-gray-900"> - <i18n.Translate>Delete</i18n.Translate> - </span> - </span> - </span> - </label> - </div> - </div> + {item.label} + </button> + ))} + </nav> <form - class="bg-white shadow-sm ring-1 ring-gray-900/5 sm:rounded-xl md:col-span-2" + class="mt-4 overflow-hidden rounded-xl border border-gray-200 bg-white shadow-sm" autoCapitalize="none" autoCorrect="off" onSubmit={(e) => { @@ -532,23 +437,25 @@ function Form({ {section == "detail" && ( <Fragment> - <div class="px-6 pt-6"> - <div class="justify-between items-center flex "> - <dt class="text-sm text-gray-600"> + <div class="space-y-6 px-5 py-6 sm:px-8"> + <div> + <label + class="block text-sm font-medium text-onBackground" + htmlFor="name" + > <i18n.Translate>Name</i18n.Translate> - </dt> - <dd class="text-sm text-gray-900"> + </label> + <div class="mt-2"> <input - ref={doAutoFocus} type="text" name="name" id="name" - class="block w-full disabled:bg-gray-200 rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" + class="block w-full disabled:bg-gray-200 rounded-md border-0 py-1.5 text-onBackground shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-primary sm:text-sm sm:leading-6" value={form?.name?.value ?? ""} enterkeyhint="next" placeholder={i18n.str`identification`} - autocomplete="username" - title={i18n.str`Username of the account`} + autocomplete="off" + title={i18n.str`Conversion rate class name`} required onInput={(e): void => { form?.name?.onUpdate(e.currentTarget.value); @@ -558,26 +465,27 @@ function Form({ message={form?.name?.error} isDirty={form?.name?.value !== undefined} /> - </dd> + </div> </div> - </div> - <div class="px-6 pt-6"> - <div class="justify-between items-center flex "> - <dt class="text-sm text-gray-600"> + <div> + <label + class="block text-sm font-medium text-onBackground" + htmlFor="description" + > <i18n.Translate>Description</i18n.Translate> - </dt> - <dd class="text-sm text-gray-900"> + </label> + <div class="mt-2"> <input type="text" name="description" id="description" - class="block w-full disabled:bg-gray-200 rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" + class="block w-full disabled:bg-gray-200 rounded-md border-0 py-1.5 text-onBackground shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-primary sm:text-sm sm:leading-6" value={form?.description?.value ?? ""} enterkeyhint="next" // placeholder="identification" - autocomplete="username" - title={i18n.str`Username of the account`} + autocomplete="off" + title={i18n.str`Conversion rate class description`} onInput={(e): void => { form?.description?.onUpdate(e.currentTarget.value); }} @@ -586,66 +494,59 @@ function Form({ message={form?.description?.error} isDirty={form?.description?.value !== undefined} /> - </dd> + </div> </div> - </div> - <div class="px-6 pt-6"> - <div class="justify-between items-center flex "> - <dt class="text-sm text-gray-600"> - <i18n.Translate>Cashin</i18n.Translate> - </dt> - <dd class="text-sm text-gray-900"> - <DescribeConversion - ratio={final_cashin_ratio} - fee={final_cashin_fee} - min={final_cashin_min} - rounding={final_cashin_rounding} - minSpec={conversionInfo.fiat_currency_specification} - feeSpec={conversionInfo.regional_currency_specification} - /> - </dd> - </div> - </div> - - <div class="px-6 pt-6"> - <div class="justify-between items-center flex "> - <dt class="text-sm text-gray-600"> - <i18n.Translate>Cashout</i18n.Translate> - </dt> - <dd class="text-sm text-gray-900"> - <DescribeConversion - ratio={final_cashout_ratio} - fee={final_cashout_fee} - min={final_cashout_min} - rounding={final_cashout_rounding} - minSpec={conversionInfo.regional_currency_specification} - feeSpec={conversionInfo.fiat_currency_specification} - /> - </dd> - </div> - </div> - <div class="px-6 pt-6"> - <div class="justify-between items-center flex "> - <dt class="text-sm text-gray-600"> - <i18n.Translate>Users</i18n.Translate> - </dt> - <dd class="text-sm text-gray-900"> - {detailsResult.num_users} - </dd> - </div> - </div> + <dl class="divide-y divide-gray-100 rounded-lg border border-gray-200 px-4"> + <div class="py-4 sm:grid sm:grid-cols-[8rem_1fr] sm:gap-4"> + <dt class="text-sm font-medium text-gray-600"> + <i18n.Translate>Cashin</i18n.Translate> + </dt> + <dd class="mt-1 text-sm text-onBackground sm:mt-0"> + <DescribeConversion + ratio={final_cashin_ratio} + fee={final_cashin_fee} + min={final_cashin_min} + rounding={final_cashin_rounding} + minSpec={conversionInfo.fiat_currency_specification} + feeSpec={conversionInfo.regional_currency_specification} + /> + </dd> + </div> + <div class="py-4 sm:grid sm:grid-cols-[8rem_1fr] sm:gap-4"> + <dt class="text-sm font-medium text-gray-600"> + <i18n.Translate>Cashout</i18n.Translate> + </dt> + <dd class="mt-1 text-sm text-onBackground sm:mt-0"> + <DescribeConversion + ratio={final_cashout_ratio} + fee={final_cashout_fee} + min={final_cashout_min} + rounding={final_cashout_rounding} + minSpec={conversionInfo.regional_currency_specification} + feeSpec={conversionInfo.fiat_currency_specification} + /> + </dd> + </div> + <div class="py-4 sm:grid sm:grid-cols-[8rem_1fr] sm:gap-4"> + <dt class="text-sm font-medium text-gray-600"> + <i18n.Translate>Users</i18n.Translate> + </dt> + <dd class="mt-1 text-sm text-onBackground sm:mt-0"> + {detailsResult.num_users} + </dd> + </div> + </dl> - {both_low || both_high ? ( - <div class="p-4"> + {both_low || both_high ? ( <Attention title={i18n.str`Bad ratios`} type="warning"> <i18n.Translate> One of the ratios should be higher or equal than 1 and the other should be lower or equal than 1. </i18n.Translate> </Attention> - </div> - ) : undefined} + ) : undefined} + </div> </Fragment> )} @@ -663,80 +564,83 @@ function Form({ <TestConversionClass classId={classId} info={conversionInfo} /> )} - <div class="flex items-center justify-between mt-4 gap-x-6 border-t border-gray-900/10 px-4 py-4"> - <a - name="cancel" - href={routeCancel.url({})} - class="text-sm font-semibold leading-6 text-gray-900" - > - <i18n.Translate>Cancel</i18n.Translate> - </a> - {section == "cashin" ? ( - <Fragment> - <AsyncButton - 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" - disabled={updateCashinDisabled} - onClick={() => - updateClass.run(creds!.token, classId, updateRequest!) - } - > - <i18n.Translate>Update</i18n.Translate> - </AsyncButton> - </Fragment> - ) : undefined} - {section == "cashout" ? ( - <Fragment> - <AsyncButton - 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" - disabled={updateCashoutDisabled} - onClick={() => - updateClass.run(creds!.token, classId, updateRequest!) - } - > - <i18n.Translate>Update</i18n.Translate> - </AsyncButton> - </Fragment> - ) : undefined} - {section == "detail" ? ( - <Fragment> - <AsyncButton - 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" - disabled={updateDetailsDisabled} - onClick={() => - updateClass.run(creds!.token, classId, updateRequest!) - } - > - <i18n.Translate>Update</i18n.Translate> - </AsyncButton> - </Fragment> - ) : undefined} - {section == "delete" ? ( - <Fragment> - <AsyncButton - 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" - disabled={ - !creds || - section !== "delete" || - detailsResult.num_users > 0 - } - onClick={() => deleteClass.run(creds!.token)} - > - <i18n.Translate>Delete</i18n.Translate> - </AsyncButton> - </Fragment> - ) : undefined} + <div class="border-t border-onBackground/10 bg-background/60 px-5 py-4 sm:px-8"> + <OperationError class="mb-4" /> + <div class="flex items-center justify-between gap-x-6"> + <a + name="cancel" + href={routeCancel.url({})} + class="text-sm font-semibold leading-6 text-onBackground" + > + <i18n.Translate>Cancel</i18n.Translate> + </a> + {section == "cashin" ? ( + <Fragment> + <AsyncButton + submit + name="update conversion" + class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-primary px-3 py-2 text-sm font-semibold text-onPrimary shadow-sm hover:bg-primaryHover hover:text-white focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary" + disabled={updateCashinDisabled} + onClick={() => + updateClass.run(creds!.token, classId, updateRequest!) + } + > + <i18n.Translate>Update</i18n.Translate> + </AsyncButton> + </Fragment> + ) : undefined} + {section == "cashout" ? ( + <Fragment> + <AsyncButton + submit + name="update conversion" + class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-primary px-3 py-2 text-sm font-semibold text-onPrimary shadow-sm hover:bg-primaryHover hover:text-white focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary" + disabled={updateCashoutDisabled} + onClick={() => + updateClass.run(creds!.token, classId, updateRequest!) + } + > + <i18n.Translate>Update</i18n.Translate> + </AsyncButton> + </Fragment> + ) : undefined} + {section == "detail" ? ( + <Fragment> + <AsyncButton + submit + name="update conversion" + class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-primary px-3 py-2 text-sm font-semibold text-onPrimary shadow-sm hover:bg-primaryHover hover:text-white focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary" + disabled={updateDetailsDisabled} + onClick={() => + updateClass.run(creds!.token, classId, updateRequest!) + } + > + <i18n.Translate>Update</i18n.Translate> + </AsyncButton> + </Fragment> + ) : undefined} + {section == "delete" ? ( + <Fragment> + <AsyncButton + 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" + disabled={ + !creds || + section !== "delete" || + detailsResult.num_users > 0 + } + onClick={() => deleteClass.run(creds!.token)} + > + <i18n.Translate>Delete</i18n.Translate> + </AsyncButton> + </Fragment> + ) : undefined} + </div> </div> </form> </div> - </div> + </section> ); } @@ -960,7 +864,7 @@ function TestConversionClass({ <div class="sm:col-span-5"> <label for="amount" - class="block text-sm font-medium leading-6 text-gray-900" + class="block text-sm font-medium leading-6 text-onBackground" >{i18n.str`Initial amount`}</label> <InputAmount name="amount" @@ -992,7 +896,7 @@ function TestConversionClass({ <dt class="text-sm text-gray-600"> <i18n.Translate>Sending to this bank</i18n.Translate> </dt> - <dd class="text-sm text-gray-900"> + <dd class="text-sm text-onBackground"> <RenderAmount value={cashinCalc.debit} negative @@ -1009,7 +913,7 @@ function TestConversionClass({ <i18n.Translate>Converted</i18n.Translate> </span> </dt> - <dd class="text-sm text-gray-900"> + <dd class="text-sm text-onBackground"> <RenderAmount value={cashinCalc.beforeFee} spec={info.regional_currency_specification} @@ -1018,10 +922,10 @@ function TestConversionClass({ </div> )} <div class="flex justify-between items-center border-t-2 afu pt-4"> - <dt class="text-lg text-gray-900 font-medium"> + <dt class="text-lg text-onBackground font-medium"> <i18n.Translate>Cashin after fee</i18n.Translate> </dt> - <dd class="text-lg text-gray-900 font-medium"> + <dd class="text-lg text-onBackground font-medium"> <RenderAmount value={cashinCalc.credit} withColor @@ -1038,7 +942,7 @@ function TestConversionClass({ <dt class="text-sm text-gray-600"> <i18n.Translate>Sending from this bank</i18n.Translate> </dt> - <dd class="text-sm text-gray-900"> + <dd class="text-sm text-onBackground"> <RenderAmount value={cashoutCalc.debit} negative @@ -1055,7 +959,7 @@ function TestConversionClass({ <i18n.Translate>Converted</i18n.Translate> </span> </dt> - <dd class="text-sm text-gray-900"> + <dd class="text-sm text-onBackground"> <RenderAmount value={cashoutCalc.beforeFee} spec={info.fiat_currency_specification} @@ -1064,10 +968,10 @@ function TestConversionClass({ </div> )} <div class="flex justify-between items-center border-t-2 afu pt-4"> - <dt class="text-lg text-gray-900 font-medium"> + <dt class="text-lg text-onBackground font-medium"> <i18n.Translate>Cashout after fee</i18n.Translate> </dt> - <dd class="text-lg text-gray-900 font-medium"> + <dd class="text-lg text-onBackground font-medium"> <RenderAmount value={cashoutCalc.credit} withColor @@ -1213,7 +1117,7 @@ function AccountsOnConversionClass({ classId }: { classId: number }): VNode { <div class="px-4 mt-4"> <div class="sm:flex sm:items-center"> <div class="sm:flex-auto"> - <h1 class="text-base font-semibold leading-6 text-gray-900"> + <h1 class="text-base font-semibold leading-6 text-onBackground"> <i18n.Translate>Filters</i18n.Translate> </h1> </div> @@ -1281,23 +1185,23 @@ function AccountsOnConversionClass({ classId }: { classId: number }): VNode { <tr> <th scope="col" - class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900" + class="px-3 py-3.5 text-left text-sm font-semibold text-onBackground" >{i18n.str`Name`}</th> <th scope="col" - class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900" + class="px-3 py-3.5 text-left text-sm font-semibold text-onBackground" >{i18n.str`Class`}</th> <th scope="col" - class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900" + class="px-3 py-3.5 text-left text-sm font-semibold text-onBackground" >{i18n.str`Cashin`}</th> <th scope="col" - class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900" + class="px-3 py-3.5 text-left text-sm font-semibold text-onBackground" >{i18n.str`Cashout`}</th> <th scope="col" - class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900" + class="px-3 py-3.5 text-left text-sm font-semibold text-onBackground" >{i18n.str`Action`}</th> </tr> </thead> @@ -1369,7 +1273,7 @@ function AccountsOnConversionClass({ classId }: { classId: number }): VNode { <button type="button" disabled={membership.running || !token} - class="disabled:opacity-50 disabled:bg-gray-600 disabled:hover:bg-gray-600 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" + class="disabled:opacity-50 disabled:bg-gray-600 disabled:hover:bg-gray-600 disabled:cursor-default cursor-pointer rounded-md bg-primary px-3 py-2 text-sm font-semibold text-onPrimary shadow-sm hover:bg-primaryHover hover:text-white focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary" onClick={() => { if (token) { void membership.run( @@ -1400,7 +1304,7 @@ function AccountsOnConversionClass({ classId }: { classId: number }): VNode { <button type="button" name="first page" - class="relative disabled:bg-gray-100 disabled:text-gray-500 inline-flex items-center rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus-visible:outline-offset-0" + class="relative disabled:bg-gray-100 disabled:text-gray-500 inline-flex items-center rounded-md bg-white px-3 py-2 text-sm font-semibold text-onBackground ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus-visible:outline-offset-0" disabled={!userListResult.loadFirst} onClick={userListResult.loadFirst} > @@ -1409,7 +1313,7 @@ function AccountsOnConversionClass({ classId }: { classId: number }): VNode { <button type="button" name="next page" - class="relative disabled:bg-gray-100 disabled:text-gray-500 ml-3 inline-flex items-center rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus-visible:outline-offset-0" + class="relative disabled:bg-gray-100 disabled:text-gray-500 ml-3 inline-flex items-center rounded-md bg-white px-3 py-2 text-sm font-semibold text-onBackground ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus-visible:outline-offset-0" disabled={!userListResult.loadNext} onClick={userListResult.loadNext} > diff --git a/packages/libeufin-bank-webui/src/pages/DeveloperSettings.stories.tsx b/packages/libeufin-bank-webui/src/pages/DeveloperSettings.stories.tsx @@ -0,0 +1,41 @@ +/* + This file is part of GNU Taler + (C) 2026 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. +*/ + +import * as tests from "@gnu-taler/web-util/testing"; +import { DeveloperSettings } from "./DeveloperSettings.js"; + +export default { + title: "developer settings", +}; + +export const Configured = tests.createExample(DeveloperSettings, { + configuredBackendBaseUrl: "https://bank.example/", + configuredShowDemoBanner: true, + configuredShowPublicAccounts: false, + overrides: {}, + onApply: () => undefined, + onClear: () => undefined, +}); + +export const Overridden = tests.createExample(DeveloperSettings, { + configuredBackendBaseUrl: "https://bank.example/", + configuredShowDemoBanner: false, + configuredShowPublicAccounts: false, + overrides: { + corebankApiBaseUrl: "http://localhost:8082/", + showDemoBanner: true, + showPublicAccounts: true, + }, + onApply: () => undefined, + onClear: () => undefined, +}); diff --git a/packages/libeufin-bank-webui/src/pages/DeveloperSettings.tsx b/packages/libeufin-bank-webui/src/pages/DeveloperSettings.tsx @@ -0,0 +1,259 @@ +/* + This file is part of GNU Taler + (C) 2026 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. +*/ + +import { canonicalizeBaseUrl } from "@gnu-taler/taler-util"; +import { Footer, useTranslationContext } from "@gnu-taler/web-util/browser"; +import { VNode, h } from "preact"; +import { useState } from "preact/hooks"; +import { + COREBANK_API_BASE_URL_OVERRIDE_KEY, + DeveloperOverrides, +} from "../developer-settings.js"; + +export interface DeveloperSettingsProps { + configuredBackendBaseUrl?: string; + configuredShowDemoBanner: boolean; + configuredShowPublicAccounts: boolean; + overrides: DeveloperOverrides; + onApply(overrides: DeveloperOverrides): void; + onClear(): void; +} + +function normalizeHttpBaseUrl(value: string): string { + const canonical = canonicalizeBaseUrl(value); + const protocol = new URL(canonical).protocol; + if (protocol !== "http:" && protocol !== "https:") { + throw new Error("unsupported protocol"); + } + return canonical; +} + +export function DeveloperSettings({ + configuredBackendBaseUrl, + configuredShowDemoBanner, + configuredShowPublicAccounts, + overrides, + onApply, + onClear, +}: DeveloperSettingsProps): VNode { + const { i18n } = useTranslationContext(); + const [backendBaseUrl, setBackendBaseUrl] = useState( + overrides.corebankApiBaseUrl ?? "", + ); + const [showDemoBanner, setShowDemoBanner] = useState( + overrides.showDemoBanner ?? configuredShowDemoBanner, + ); + const [showPublicAccounts, setShowPublicAccounts] = useState( + overrides.showPublicAccounts ?? configuredShowPublicAccounts, + ); + const [error, setError] = useState<string>(); + const [saved, setSaved] = useState(false); + + function apply(): void { + let normalizedUrl: string | undefined; + const requestedUrl = backendBaseUrl.trim(); + if (requestedUrl) { + try { + normalizedUrl = normalizeHttpBaseUrl(requestedUrl); + } catch { + setSaved(false); + setError(i18n.str`Enter a valid HTTP or HTTPS URL.`); + return; + } + } + setBackendBaseUrl(normalizedUrl ?? ""); + setError(undefined); + setSaved(true); + onApply({ + corebankApiBaseUrl: normalizedUrl, + showDemoBanner, + showPublicAccounts, + }); + } + + function clear(): void { + setBackendBaseUrl(""); + setShowDemoBanner(configuredShowDemoBanner); + setShowPublicAccounts(configuredShowPublicAccounts); + setError(undefined); + setSaved(false); + onClear(); + } + + return ( + <div class="min-h-screen bg-background text-onBackground flex flex-col"> + <header class="bg-brand py-8"> + <div class="mx-auto w-full max-w-3xl px-4 sm:px-6"> + <h1 class="text-3xl font-bold tracking-tight text-onBrand"> + <i18n.Translate>Development settings</i18n.Translate> + </h1> + </div> + </header> + <main class="mx-auto w-full max-w-3xl flex-1 px-4 py-8 sm:px-6"> + <form + class="space-y-8 rounded-lg bg-white p-6 shadow" + onSubmit={(event) => { + event.preventDefault(); + apply(); + }} + > + <p class="text-sm text-gray-600"> + <i18n.Translate> + These overrides are stored only in this browser. + </i18n.Translate> + </p> + + <div> + <label + class="block text-sm font-semibold leading-6 text-onBackground" + htmlFor="corebank-api-base-url" + > + <i18n.Translate>Corebank API base URL</i18n.Translate> + </label> + <input + id="corebank-api-base-url" + name="corebank-api-base-url" + type="url" + value={backendBaseUrl} + placeholder={configuredBackendBaseUrl} + onInput={(event) => { + setBackendBaseUrl(event.currentTarget.value); + setSaved(false); + }} + aria-invalid={error !== undefined} + aria-describedby="corebank-api-base-url-help" + class="mt-2 block w-full rounded-md border-0 px-3 py-2 text-onBackground shadow-sm ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-primary sm:text-sm" + /> + <p + id="corebank-api-base-url-help" + class="mt-2 text-sm text-gray-500" + > + {configuredBackendBaseUrl ? ( + <i18n.Translate> + Leave empty to use the configured URL:{" "} + {configuredBackendBaseUrl} + </i18n.Translate> + ) : ( + <i18n.Translate> + Leave empty to derive the URL from this page. + </i18n.Translate> + )} + </p> + {error && <p class="mt-2 text-sm text-red-600">{error}</p>} + </div> + + <div class="flex items-center justify-between gap-6"> + <div> + <div + id="show-demo-banner-label" + class="text-sm font-semibold leading-6 text-onBackground" + > + <i18n.Translate>Show demo banner</i18n.Translate> + </div> + <p class="text-sm text-gray-500"> + <i18n.Translate> + Saving this switch overrides the configured banner setting. + </i18n.Translate> + </p> + </div> + <button + type="button" + role="switch" + aria-checked={showDemoBanner} + aria-labelledby="show-demo-banner-label" + data-enabled={showDemoBanner} + class="bg-primary data-[enabled=false]:bg-gray-200 relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2" + onClick={() => { + setShowDemoBanner(!showDemoBanner); + setSaved(false); + }} + > + <span + aria-hidden="true" + data-enabled={showDemoBanner} + class="translate-x-5 data-[enabled=false]:translate-x-0 pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out" + /> + </button> + </div> + + <div class="flex items-center justify-between gap-6"> + <div> + <div + id="show-public-accounts-label" + class="text-sm font-semibold leading-6 text-onBackground" + > + <i18n.Translate>Show public accounts</i18n.Translate> + </div> + <p class="text-sm text-gray-500"> + <i18n.Translate> + Saving this switch overrides the configured public accounts + setting. + </i18n.Translate> + </p> + </div> + <button + type="button" + role="switch" + aria-checked={showPublicAccounts} + aria-labelledby="show-public-accounts-label" + data-enabled={showPublicAccounts} + class="bg-primary data-[enabled=false]:bg-gray-200 relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2" + onClick={() => { + setShowPublicAccounts(!showPublicAccounts); + setSaved(false); + }} + > + <span + aria-hidden="true" + data-enabled={showPublicAccounts} + class="translate-x-5 data-[enabled=false]:translate-x-0 pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out" + /> + </button> + </div> + + {saved && ( + <p role="status" class="text-sm font-medium text-green-700"> + <i18n.Translate>Overrides saved.</i18n.Translate> + </p> + )} + + <div class="flex flex-wrap gap-3"> + <button + type="submit" + class="rounded-md bg-primary px-4 py-2 text-sm font-semibold text-onPrimary shadow-sm hover:bg-primaryHover hover:text-white focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary" + > + <i18n.Translate>Apply overrides</i18n.Translate> + </button> + <button + type="button" + class="rounded-md bg-white px-4 py-2 text-sm font-semibold text-onBackground shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50" + onClick={clear} + > + <i18n.Translate>Clear overrides</i18n.Translate> + </button> + <a + href="#/" + class="rounded-md px-4 py-2 text-sm font-semibold text-primaryDark hover:bg-primaryContainer/40" + > + <i18n.Translate>Back to bank</i18n.Translate> + </a> + </div> + </form> + </main> + <Footer + testingUrlKey={COREBANK_API_BASE_URL_OVERRIDE_KEY} + variant="demo" + /> + </div> + ); +} diff --git a/packages/libeufin-bank-webui/src/pages/LoginForm.tsx b/packages/libeufin-bank-webui/src/pages/LoginForm.tsx @@ -9,10 +9,7 @@ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along with - GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> - */ +*/ import { AbsoluteTime, @@ -20,98 +17,72 @@ import { HttpStatusCode, TalerErrorCode, TokenRequest, + TranslatedString, assertUnreachable, createRFC8959AccessTokenEncoded, - opEmptySuccess, } from "@gnu-taler/taler-util"; -import { dummyHttpResponse } from "@gnu-taler/taler-util/http"; import { - Attention, - AsyncButton, + AsyncAction, RouteDefinition, - ShowInputErrorLabel, + useAsyncAction, useBankCoreApiContext, - useNotificationContext, - useNotifiedOperation, useTranslationContext, } from "@gnu-taler/web-util/browser"; -import { VNode, h } from "preact"; -import { useState } from "preact/hooks"; +import { RefObject, VNode, h } from "preact"; +import { useEffect, useRef, 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 { AuthAlert, AuthCard, FieldError, PasswordField } from "./AuthForm.js"; +import { + PASSWORD_MAX_LENGTH, + PasswordError, + USERNAME_MAX_LENGTH, + UsernameError, + validatePassword, + validateUsername, +} from "./auth-validation.js"; const TALER_SCREEN_ID = 104; export const SESSION_DURATION = Duration.toTalerProtocolDuration( - Duration.fromSpec({ - // seconds: 6, - minutes: 30, - }), + Duration.fromSpec({ minutes: 30 }), ); -/** - * Collect and submit login data. - */ -LoginForm.SCREEN_ID = TALER_SCREEN_ID; -export function LoginForm({ - currentUser, - fixedUser, - routeRegister, -}: { - fixedUser?: boolean; - currentUser?: string; - routeRegister?: RouteDefinition; -}): VNode { - const session = useSessionState(); +interface LoginOperationOptions { + onSuccess?(username: string): void; + onFailure?(username: string): void; +} - const sessionUser = - session.state.status !== "loggedOut" ? session.state.username : undefined; - const [username, setUsername] = useState<string | undefined>( - currentUser ?? sessionUser, - ); - const [password, setPassword] = useState<string | undefined>(); +export interface LoginOperation extends AsyncAction< + [username: string, password: string, challengeIds?: string[]] +> { + error?: TranslatedString; + clearError(): void; +} + +/** Create and persist a bank access token, including its MFA continuation. */ +export function useLoginOperation( + options: LoginOperationOptions = {}, +): LoginOperation { const { i18n } = useTranslationContext(); + const session = useSessionState(); const { - config, lib: { bank: api }, } = useBankCoreApiContext(); - const { showError } = useNotificationContext(); - const mfa = useBankChallengeHandlerContext(); + const [error, setError] = useState<TranslatedString>(); - const errors = undefinedIfEmpty({ - username: !username - ? i18n.str`Missing username` - : !USERNAME_REGEX.test(username) - ? i18n.str`Use letters, numbers or any of these characters: - . _ ~` - : undefined, - password: !password ? i18n.str`Missing password` : undefined, - }); - - // i18n.str`logout`, - const logout = useNotifiedOperation( - async () => { - session.logOut(); - return opEmptySuccess(dummyHttpResponse); - }, - { onSuccess: session.logOut }, - ); - - const tokenRequest = { + const tokenRequest: TokenRequest = { scope: "readwrite", duration: SESSION_DURATION, refreshable: true, - } as TokenRequest; + }; - // i18n.str`login`, - const login = useNotifiedOperation< + const action = useAsyncAction< Awaited<ReturnType<typeof api.createAccessToken>>, [string, string, challengeIds?: string[]] >( - (ct, username: string, password: string, challengeIds?: string[]) => + (ct, username, password, challengeIds) => api.createAccessToken( username, { type: "basic", username, password }, @@ -119,178 +90,356 @@ export function LoginForm({ { challengeIds }, ), { - onSuccess: (result, username) => { - mfa.cancel(); - session.logIn({ - username, - token: createRFC8959AccessTokenEncoded(result.access_token), - expiration: AbsoluteTime.fromProtocolTimestamp(result.expiration), - }); + onResult: async (result, username, password) => { + if (result.type === "ok") { + mfa.cancel(); + session.logIn({ + username, + token: createRFC8959AccessTokenEncoded(result.body.access_token), + expiration: AbsoluteTime.fromProtocolTimestamp( + result.body.expiration, + ), + }); + options.onSuccess?.(username); + return; + } + + let message: TranslatedString; + switch (result.case) { + case HttpStatusCode.Accepted: + mfa.onNewChallenge( + i18n.str`Identity verification.`, + username, + result.body, + { + running: action.running, + cancel: action.cancel, + run: (challengeIds) => + action.run(username, password, challengeIds), + }, + ); + return; + case TalerErrorCode.GENERIC_FORBIDDEN: + message = i18n.str`You do not have permission to access this account.`; + break; + case TalerErrorCode.BANK_ACCOUNT_LOCKED: + message = i18n.str`This account is locked. Contact the bank administrator.`; + break; + case HttpStatusCode.Unauthorized: + message = i18n.str`The password is incorrect.`; + break; + case HttpStatusCode.NotFound: + message = i18n.str`Account not found.`; + break; + default: + assertUnreachable(result); + } + setError(message); + options.onFailure?.(username); + }, + onError: (_cause, username) => { + setError( + i18n.str`Could not reach the bank. Check your connection and try again.`, + ); + options.onFailure?.(username); }, - onFail: showError( - i18n.str`Failed to login.`, - (fail, username, password) => { - switch (fail.case) { - case HttpStatusCode.Accepted: - mfa.onNewChallenge( - i18n.str`Identity verification.`, - username, - fail.body, - { - running: login.running, - cancel: login.cancel, - run: (challengeIds) => - login.run(username, password, challengeIds), - }, - ); - return undefined; - case TalerErrorCode.GENERIC_FORBIDDEN: - return i18n.str`The account has no rights to login.`; - case TalerErrorCode.BANK_ACCOUNT_LOCKED: - return i18n.str`The account is locked and cannot login. Contact administrator.`; - case HttpStatusCode.Unauthorized: - return i18n.str`Wrong credentials for "${username}"`; - case HttpStatusCode.NotFound: - return i18n.str`Account not found`; - default: - assertUnreachable(fail); - } - }, - ), }, ); - const onlyThisUser = fixedUser || session.state.status !== "loggedOut"; + return { + ...action, + error, + clearError: () => setError(undefined), + run: async (username, password, challengeIds) => { + setError(undefined); + await action.run(username, password, challengeIds); + }, + }; +} + +type LoginField = "username" | "password"; + +export interface LoginFormViewProps { + bankName: string; + initialUsername?: string; + fixedUser?: boolean; + reauthentication?: boolean; + sessionExpired?: boolean; + registrationNotice?: TranslatedString; + operationError?: TranslatedString; + running?: boolean; + registrationsAllowed?: boolean; + routeRegister?: RouteDefinition; + onSubmit(username: string, password: string): void | Promise<void>; + onSignOut?(): void | Promise<void>; +} + +LoginFormView.SCREEN_ID = TALER_SCREEN_ID; +export function LoginFormView({ + bankName, + initialUsername, + fixedUser = false, + reauthentication = false, + sessionExpired = false, + registrationNotice, + operationError, + running = false, + registrationsAllowed = false, + routeRegister, + onSubmit, + onSignOut, +}: LoginFormViewProps): VNode { + const { i18n } = useTranslationContext(); + const [username, setUsername] = useState(initialUsername ?? ""); + const [password, setPassword] = useState(""); + const [submitted, setSubmitted] = useState(false); + const [touched, setTouched] = useState<Partial<Record<LoginField, boolean>>>( + {}, + ); + const usernameRef = useRef<HTMLInputElement | null>(null); + const passwordRef = useRef<HTMLInputElement | null>(null); + + useEffect(() => { + const initialField = fixedUser ? passwordRef.current : usernameRef.current; + initialField?.focus({ preventScroll: true }); + }, [fixedUser]); + + const usernameError = translateUsernameError( + validateUsername(username), + i18n, + ); + const passwordError = translatePasswordError( + validatePassword(password, true), + i18n, + ); + + const markTouched = (field: LoginField): void => { + setTouched((current) => ({ ...current, [field]: true })); + }; + + const submit = async (event: Event): Promise<void> => { + event.preventDefault(); + if (running) return; + setSubmitted(true); + if (usernameError || passwordError) { + focusFirstInvalid( + [usernameError, usernameRef], + [passwordError, passwordRef], + ); + return; + } + await onSubmit(username, password); + }; + return ( - <div class="flex min-h-full flex-col justify-center "> - <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" /> - )} - <form - class="mt-10 space-y-6" - noValidate - onSubmit={(e) => { - e.preventDefault(); - e.stopPropagation(); - }} - autoCapitalize="none" - autoCorrect="off" - > - <div> - <label - htmlFor="username" - class="block text-sm font-medium leading-6 text-gray-900" + <AuthCard + title={ + reauthentication + ? i18n.str`Confirm your password` + : i18n.str`Sign in to ${bankName}` + } + subtitle={ + reauthentication + ? i18n.str`Enter the password for ${username} to continue.` + : i18n.str`Use your bank account credentials to continue.` + } + footer={ + !reauthentication && registrationsAllowed && routeRegister ? ( + <span> + <i18n.Translate>Need an account?</i18n.Translate>{" "} + <a + href={routeRegister.url({})} + class="font-semibold text-primaryDark hover:text-onPrimaryContainer hover:underline" > - <i18n.Translate>Username</i18n.Translate> - </label> - <div class="mt-2"> - <input - ref={onlyThisUser ? undefined : doAutoFocus} - type="text" - name="username" - id="username" - class="block w-full disabled:bg-gray-200 rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" - value={username ?? ""} - disabled={onlyThisUser} - enterkeyhint="next" - placeholder={i18n.str`identification`} - autocomplete="username" - title={i18n.str`Username of the account`} - required - onChange={(e): void => { - setUsername(e.currentTarget.value); - }} - /> - <ShowInputErrorLabel - message={errors?.username} - isDirty={username !== undefined} - /> - </div> - </div> + <i18n.Translate>Create one</i18n.Translate> + </a> + </span> + ) : undefined + } + > + {sessionExpired ? ( + <AuthAlert type="warning"> + <i18n.Translate> + Your session expired. Confirm your password to continue. + </i18n.Translate> + </AuthAlert> + ) : undefined} + {registrationNotice ? ( + <AuthAlert type="success">{registrationNotice}</AuthAlert> + ) : undefined} + {operationError ? ( + <AuthAlert type="error">{operationError}</AuthAlert> + ) : undefined} - <div> - <div class="flex items-center justify-between"> - <label - htmlFor="password" - class="block text-sm font-medium leading-6 text-gray-900" - > - <i18n.Translate>Password</i18n.Translate> - </label> - </div> - <div class="mt-2"> - <input - type="password" - name="password" - id="password" - autocomplete="current-password" - ref={!onlyThisUser ? undefined : doAutoFocus} - class="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" - enterkeyhint="send" - value={password ?? ""} - placeholder={i18n.str`Password`} - title={i18n.str`Password of the account`} - required - onChange={(e): void => { - setPassword(e.currentTarget.value); - }} - /> - <ShowInputErrorLabel - message={errors?.password} - isDirty={password !== undefined} - /> - </div> + <form + class="space-y-5" + noValidate + onSubmit={(event) => void submit(event)} + autoCapitalize="none" + autoCorrect="off" + > + <div> + <label + htmlFor="username" + class="block text-sm font-medium leading-6 text-onBackground" + > + <i18n.Translate>Username</i18n.Translate> + </label> + <div class="mt-2"> + <input + ref={usernameRef} + type="text" + name="username" + id="username" + class="block w-full rounded-md border-0 py-1.5 text-onBackground shadow-sm ring-1 ring-inset ring-gray-300 read-only:bg-gray-100 focus:ring-2 focus:ring-inset focus:ring-primary sm:text-sm sm:leading-6" + value={username} + readOnly={fixedUser} + enterkeyhint="next" + autocomplete="username" + maxLength={USERNAME_MAX_LENGTH} + required + aria-invalid={ + (submitted || !!touched.username) && !!usernameError + } + aria-describedby={ + (submitted || touched.username) && usernameError + ? "username-error" + : undefined + } + onInput={(event) => setUsername(event.currentTarget.value)} + onBlur={() => markTouched("username")} + /> + <FieldError + id="username-error" + message={usernameError} + visible={submitted || !!touched.username} + /> </div> + </div> - {session.state.status !== "loggedOut" ? ( - <div class="flex justify-between"> - <AsyncButton - 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.run()} - > - {session.state.status === "loggedIn" ? ( - <i18n.Translate>Log out</i18n.Translate> - ) : ( - <i18n.Translate>Clear</i18n.Translate> - )} - </AsyncButton> - - <AsyncButton - submit - name="check" - class="rounded-md bg-indigo-600 disabled:bg-gray-300 px-3 py-1.5 text-sm font-semibold leading-6 text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" - disabled={!!errors} - onClick={() => login.run(username!, password!)} - > - <i18n.Translate>Verify</i18n.Translate> - </AsyncButton> - </div> - ) : ( - <div> - <AsyncButton - 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 data-[failed=true]:hover:bg-error data-[failed=true]:bg-error" - disabled={!!errors} - onClick={() => login.run(username!, password!)} - > - <i18n.Translate>Log in</i18n.Translate> - </AsyncButton> - </div> - )} - </form> + <PasswordField + id="password" + name="password" + label={i18n.str`Password`} + value={password} + error={passwordError} + showError={submitted || !!touched.password} + autoComplete="current-password" + inputRef={passwordRef} + enterKeyHint="go" + onInput={setPassword} + onBlur={() => markTouched("password")} + /> - {config.allow_registrations && routeRegister && ( - <a - name="register" - href={routeRegister.url({})} - class="flex justify-center border-t mt-4 rounded-md bg-blue-600 px-3 py-1.5 text-sm font-semibold leading-6 text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600" + <div class={reauthentication ? "flex justify-between gap-3" : ""}> + {reauthentication && onSignOut ? ( + <button + type="button" + class="rounded-md border border-gray-300 bg-white px-3 py-2 text-sm font-semibold text-onBackground shadow-sm hover:bg-gray-50 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-600" + disabled={running} + onClick={() => void onSignOut()} + > + <i18n.Translate>Sign out</i18n.Translate> + </button> + ) : undefined} + <button + type="submit" + class="flex min-w-28 flex-1 justify-center rounded-md bg-primary px-3 py-2 text-sm font-semibold text-onPrimary shadow-sm hover:bg-primaryHover hover:text-white focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary disabled:cursor-wait disabled:bg-gray-300" + disabled={running} + aria-busy={running} > - <i18n.Translate>Register</i18n.Translate> - </a> - )} - </div> - </div> + {running ? ( + <i18n.Translate>Signing in…</i18n.Translate> + ) : reauthentication ? ( + <i18n.Translate>Continue</i18n.Translate> + ) : ( + <i18n.Translate>Sign in</i18n.Translate> + )} + </button> + </div> + </form> + </AuthCard> + ); +} + +/** Collect and submit login data. */ +LoginForm.SCREEN_ID = TALER_SCREEN_ID; +export function LoginForm({ + currentUser, + fixedUser, + routeRegister, + registrationNotice, + onSuccess, +}: { + fixedUser?: boolean; + currentUser?: string; + routeRegister?: RouteDefinition; + registrationNotice?: TranslatedString; + onSuccess?(username: string): void; +}): VNode { + const session = useSessionState(); + const { config } = useBankCoreApiContext(); + const sessionUser = + session.state.status !== "loggedOut" ? session.state.username : undefined; + const onlyThisUser = fixedUser || session.state.status !== "loggedOut"; + const login = useLoginOperation({ onSuccess }); + + return ( + <LoginFormView + bankName={config.bank_name ?? "Bank"} + initialUsername={currentUser ?? sessionUser} + fixedUser={onlyThisUser} + reauthentication={session.state.status !== "loggedOut"} + sessionExpired={session.state.status === "expired"} + registrationNotice={registrationNotice} + operationError={login.error} + running={login.running} + registrationsAllowed={config.allow_registrations ?? false} + routeRegister={routeRegister} + onSubmit={(username, password) => login.run(username, password, [])} + onSignOut={session.logOut} + /> ); } + +export function translateUsernameError( + error: UsernameError | undefined, + i18n: ReturnType<typeof useTranslationContext>["i18n"], +): TranslatedString | undefined { + switch (error) { + case undefined: + return undefined; + case "missing": + return i18n.str`Enter your username.`; + case "invalid": + return i18n.str`Use letters, numbers, or any of these characters: - . _ ~`; + case "too-long": + return i18n.str`The username must not exceed 126 characters.`; + default: + assertUnreachable(error); + } +} + +export function translatePasswordError( + error: PasswordError | undefined, + i18n: ReturnType<typeof useTranslationContext>["i18n"], +): TranslatedString | undefined { + switch (error) { + case undefined: + return undefined; + case "missing": + return i18n.str`Enter your password.`; + case "too-short": + return i18n.str`The password must contain at least 8 characters.`; + case "too-long": + return i18n.str`The password must not exceed ${PASSWORD_MAX_LENGTH} characters.`; + default: + assertUnreachable(error); + } +} + +function focusFirstInvalid( + ...fields: Array<[TranslatedString | undefined, RefObject<HTMLInputElement>]> +): void { + fields.find(([error]) => !!error)?.[1].current?.focus(); +} diff --git a/packages/libeufin-bank-webui/src/pages/NewConversionRateClass.tsx b/packages/libeufin-bank-webui/src/pages/NewConversionRateClass.tsx @@ -16,6 +16,7 @@ import { import { h, VNode } from "preact"; import { useState } from "preact/hooks"; import { useSessionState } from "../hooks/session.js"; +import { OperationError } from "../components/OperationError.js"; import { ConversionRateClassForm } from "./admin/ConversionRateClassForm.js"; interface Props { @@ -75,33 +76,51 @@ export function NewConversionRateClass({ ); return ( - <div class="grid grid-cols-1 gap-x-8 gap-y-8 pt-6 md:grid-cols-3 bg-gray-100 my-4 px-4 pb-4 rounded-lg"> - <div class="px-4 sm:px-0"> - <h2 class="text-base font-semibold leading-7 text-gray-900"> + <section class="mt-2"> + <a + href={routeCancel.url({})} + class="inline-flex items-center text-sm font-semibold text-brand hover:underline" + > + <span class="mr-1" aria-hidden="true"> + ← + </span> + <i18n.Translate>Back to conversion settings</i18n.Translate> + </a> + <div class="mx-auto mt-6 max-w-3xl"> + <h1 class="text-2xl font-semibold text-brand"> <i18n.Translate>New conversion rate class</i18n.Translate> - </h2> - </div> + </h1> + <p class="mt-2 text-sm text-gray-600"> + <i18n.Translate> + Create a named set of conversion terms that can be assigned to + accounts. + </i18n.Translate> + </p> - <ConversionRateClassForm onChange={setSubmitData}> - <div class="flex items-center justify-between gap-x-6 border-t border-gray-900/10 px-4 py-4 sm:px-8"> - <a - href={routeCancel.url({})} - name="cancel" - class="text-sm font-semibold leading-6 text-gray-900" - > - <i18n.Translate>Cancel</i18n.Translate> - </a> - <AsyncButton - submit - name="create" - class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" - disabled={!submitData || !token} - onClick={() => create.run(token!, submitData!)} - > - <i18n.Translate>Create</i18n.Translate> - </AsyncButton> - </div> - </ConversionRateClassForm> - </div> + <ConversionRateClassForm onChange={setSubmitData}> + <div class="border-t border-onBackground/10 bg-background/60 px-5 py-4 sm:px-8"> + <OperationError class="mb-4" /> + <div class="flex items-center justify-between gap-x-6"> + <a + href={routeCancel.url({})} + name="cancel" + class="text-sm font-semibold leading-6 text-onBackground hover:underline" + > + <i18n.Translate>Cancel</i18n.Translate> + </a> + <AsyncButton + submit + name="create" + class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-primary px-3 py-2 text-sm font-semibold text-onPrimary shadow-sm hover:bg-primaryHover hover:text-white focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary" + disabled={!submitData || !token} + onClick={() => create.run(token!, submitData!)} + > + <i18n.Translate>Create class</i18n.Translate> + </AsyncButton> + </div> + </div> + </ConversionRateClassForm> + </div> + </section> ); } diff --git a/packages/libeufin-bank-webui/src/pages/OperationState/OperationState.test.ts b/packages/libeufin-bank-webui/src/pages/OperationState/OperationState.test.ts @@ -21,58 +21,76 @@ import assert from "node:assert"; import { describe, it } from "node:test"; -import { isWithdrawalWithinLimit } from "../WalletWithdrawForm.js"; import { IntAmounts } from "../regional/CreateCashout.js"; import { buildWithdrawalOperationState, isTerminalWithdrawalResult, } from "./state.js"; +import { + maximumWithdrawalAmount, + validateWithdrawalAmount, +} from "../withdrawal-amount.js"; describe("withdrawal limits", () => { - const amount = { currency: "EUR", value: 5, fraction: 0 }; - - it("accepts values inside a positive limit", () => { - assert.equal( - isWithdrawalWithinLimit( - { ...amount, value: 10, negative: false, saturated: false }, - amount, + it("reserves the bank fee from the account limit", () => { + assert.deepEqual( + maximumWithdrawalAmount( + { + currency: "EUR", + value: 10, + fraction: 0, + negative: false, + saturated: false, + }, + { currency: "EUR", value: 0, fraction: 25000000 }, ), - true, + { currency: "EUR", value: 9, fraction: 75000000 }, ); }); - it("rejects zero, negative and saturated limits", () => { - assert.equal( - isWithdrawalWithinLimit( - { ...amount, value: 0, negative: false, saturated: false }, - amount, + it("applies the configured maximum after reserving the fee", () => { + assert.deepEqual( + maximumWithdrawalAmount( + { + currency: "EUR", + value: 10, + fraction: 0, + negative: false, + saturated: false, + }, + { currency: "EUR", value: 1, fraction: 0 }, + { currency: "EUR", value: 5, fraction: 0 }, ), - false, + { currency: "EUR", value: 5, fraction: 0 }, ); - assert.equal( - isWithdrawalWithinLimit( - { ...amount, negative: true, saturated: false }, - amount, - ), - false, - ); - assert.equal( - isWithdrawalWithinLimit( - { ...amount, negative: false, saturated: true }, - amount, + }); + + it("reports no available amount when the fee consumes the limit", () => { + assert.deepEqual( + maximumWithdrawalAmount( + { + currency: "EUR", + value: 0, + fraction: 50000000, + negative: false, + saturated: false, + }, + { currency: "EUR", value: 1, fraction: 0 }, ), - false, + { currency: "EUR", value: 0, fraction: 0 }, ); }); }); describe("withdrawal operation lifecycle", () => { const routeClose = {} as any; + const onContinueLater = () => undefined; const common = { operationId: "withdrawal-1", bankIntegrationApiBaseUrl: "https://bank.example/api/" as any, routeClose, onAbort: () => undefined, + onContinueLater, loadingErrorTitle: "loading failed" as any, onRetry: () => undefined, }; @@ -93,6 +111,48 @@ describe("withdrawal operation lifecycle", () => { bankIntegrationApiBaseUrl: "https://bank.example/api/", withdrawalOperationId: "withdrawal-1", }); + assert.equal( + state.status === "ready" ? state.amount : undefined, + undefined, + ); + assert.equal( + state.status === "ready" ? state.progressMode : undefined, + "wallet-first", + ); + }); + + it("preserves fixed and suggested amounts while waiting for the wallet", () => { + const fixed = buildWithdrawalOperationState({ + ...common, + result: success({ + status: "pending", + username: "alice", + amount: "EUR:5", + suggested_amount: "EUR:7.50", + }), + }); + const suggested = buildWithdrawalOperationState({ + ...common, + result: success({ + status: "pending", + username: "alice", + suggested_amount: "EUR:7.50", + }), + }); + + assert.equal(fixed.status, "ready"); + assert.equal(suggested.status, "ready"); + if (fixed.status !== "ready" || suggested.status !== "ready") return; + assert.deepEqual(fixed.amount, { + mode: "fixed", + value: { currency: "EUR", value: 5, fraction: 0 }, + }); + assert.deepEqual(suggested.amount, { + mode: "suggested", + value: { currency: "EUR", value: 7, fraction: 50_000_000 }, + }); + assert.equal(fixed.progressMode, "amount-first"); + assert.equal(suggested.progressMode, "wallet-first"); }); it("preserves aborted and confirmed terminal states", () => { @@ -149,11 +209,89 @@ describe("withdrawal operation lifecycle", () => { assert.equal(state.account, "alice"); assert.equal(state.details.reserve, "reserve-pub"); assert.equal(state.details.account.targetType, "iban"); + assert.equal(state.onContinueLater, onContinueLater); + assert.equal(state.progressMode, "amount-first"); assert.deepEqual(state.details.amount, { - currency: "EUR", - value: 5, - fraction: 0, + mode: "fixed", + value: { + currency: "EUR", + value: 5, + fraction: 0, + }, + }); + }); + + it("maps bank-selected and missing amounts explicitly", () => { + const selected = buildWithdrawalOperationState({ + ...common, + result: success({ + status: "selected", + username: "alice", + no_amount_to_wallet: true, + suggested_amount: "EUR:7.50", + selected_reserve_pub: "reserve-pub", + selected_exchange_account: + "payto://iban/DE02120300000000202051?receiver-name=Exchange", + }), }); + const missing = buildWithdrawalOperationState({ + ...common, + result: success({ + status: "selected", + username: "alice", + selected_reserve_pub: "reserve-pub", + selected_exchange_account: + "payto://iban/DE02120300000000202051?receiver-name=Exchange", + }), + }); + + assert.equal(selected.status, "need-confirmation"); + assert.equal(missing.status, "need-confirmation"); + if ( + selected.status !== "need-confirmation" || + missing.status !== "need-confirmation" + ) { + return; + } + assert.deepEqual(selected.details.amount, { + mode: "bank-selected", + suggested: { + currency: "EUR", + value: 7, + fraction: 50_000_000, + }, + }); + assert.deepEqual(missing.details.amount, { mode: "missing" }); + }); +}); + +describe("withdrawal confirmation amounts", () => { + const amount = { currency: "EUR", value: 5, fraction: 0 }; + + it("accepts amounts within the configured range", () => { + assert.equal( + validateWithdrawalAmount( + amount, + { ...amount, value: 1 }, + { ...amount, value: 10 }, + ), + undefined, + ); + }); + + it("rejects zero and amounts outside the configured range", () => { + assert.equal( + validateWithdrawalAmount({ ...amount, value: 0 }, undefined, undefined), + "zero", + ); + assert.equal( + validateWithdrawalAmount(amount, { ...amount, value: 6 }, undefined), + "below-minimum", + ); + assert.equal( + validateWithdrawalAmount(amount, undefined, { ...amount, value: 4 }), + "above-maximum", + ); }); }); diff --git a/packages/libeufin-bank-webui/src/pages/OperationState/index.ts b/packages/libeufin-bank-webui/src/pages/OperationState/index.ts @@ -28,6 +28,7 @@ import { VNode } from "preact"; import { Paytos } from "@gnu-taler/taler-util"; import { useComponentState, useWithdrawalOperationState } from "./state.js"; import { RetryableError } from "../../components/RetryableError.js"; +import type { WithdrawalProgressMode } from "../WithdrawalProgress.js"; import { AbortedView, ConfirmedView, @@ -42,6 +43,8 @@ import { export interface Props { routeClose: RouteDefinition; onAbort: () => void; + onContinueLater?: () => void; + onOperationCreated?: (operationId: string) => void; focus?: boolean; } @@ -87,6 +90,10 @@ export namespace State { status: "ready"; error: undefined; uri: TalerWithdrawUri; + amount?: + | { mode: "fixed"; value: AmountJson } + | { mode: "suggested"; value: AmountJson }; + progressMode: WithdrawalProgressMode; focus?: boolean; onAbort: () => void; operationId: string; @@ -113,15 +120,20 @@ export namespace State { account: string; onAbort: () => void; + onContinueLater?: () => void; error: undefined; details: { account: Paytos.URI; reserve: string; username: string; - amount?: AmountJson; + amount: + | { mode: "fixed"; value: AmountJson } + | { mode: "bank-selected"; suggested?: AmountJson } + | { mode: "missing" }; }; operationId: string; + progressMode: WithdrawalProgressMode; } export interface Aborted { status: "aborted"; diff --git a/packages/libeufin-bank-webui/src/pages/OperationState/state.ts b/packages/libeufin-bank-webui/src/pages/OperationState/state.ts @@ -41,7 +41,7 @@ import { usePreferences } from "../../hooks/preferences.js"; import { useSessionState } from "../../hooks/session.js"; import { Props, State, WithdrawalOperationProps } from "./index.js"; -type WithdrawalDetailsResult = Exclude< +export type WithdrawalDetailsResult = Exclude< ReturnType<typeof useWithdrawalDetails>, undefined >; @@ -73,6 +73,7 @@ export function buildWithdrawalOperationState({ bankIntegrationApiBaseUrl, routeClose, onAbort, + onContinueLater, focus, loadingErrorTitle, onRetry, @@ -103,6 +104,11 @@ export function buildWithdrawalOperationState({ return { status: "confirmed", error: undefined, routeClose }; } + const amount = !data.amount ? undefined : Amounts.parse(data.amount); + const suggestedAmount = !data.suggested_amount + ? undefined + : Amounts.parse(data.suggested_amount); + if (data.status === "pending") { return { status: "ready", @@ -112,6 +118,12 @@ export function buildWithdrawalOperationState({ bankIntegrationApiBaseUrl, withdrawalOperationId: operationId, }, + amount: amount + ? { mode: "fixed", value: amount } + : suggestedAmount + ? { mode: "suggested", value: suggestedAmount } + : undefined, + progressMode: amount ? "amount-first" : "wallet-first", routeClose, focus, operationId, @@ -143,17 +155,34 @@ export function buildWithdrawalOperationState({ account: account.value, reserve: data.selected_reserve_pub, username: data.username, - amount: !data.amount ? undefined : Amounts.parse(data.amount), + amount: data.no_amount_to_wallet + ? { + mode: "bank-selected", + suggested: suggestedAmount, + } + : amount + ? { + mode: "fixed", + value: amount, + } + : { mode: "missing" }, }, account: data.username, operationId, + progressMode: + data.no_amount_to_wallet || suggestedAmount + ? "wallet-first" + : "amount-first", onAbort, + onContinueLater, }; } export function useComponentState({ routeClose, onAbort, + onContinueLater, + onOperationCreated, focus, }: Props): utils.RecursiveState<State> { const [preference] = usePreferences(); @@ -164,6 +193,7 @@ export function useComponentState({ const { config, lib: { bank }, + url: backendUrl, } = useBankCoreApiContext(); const [failure, setFailure] = useState< @@ -192,19 +222,34 @@ export function useComponentState({ setFailure(resp); return; } - updateBankState("currentWithdrawalOperationId", resp.body.withdrawal_id); + updateBankState("activeWithdrawal", { + operationId: resp.body.withdrawal_id, + username: creds.username, + backendBaseUrl: backendUrl.href, + confirmationDeferred: false, + }); + onOperationCreated?.(resp.body.withdrawal_id); }, [ amount, bank, + backendUrl.href, config.currency, creds, + onOperationCreated, preference.fastWithdrawalForm, updateBankState, ], ); - const withdrawalOperationId = bankState.currentWithdrawalOperationId; + const activeWithdrawal = bankState.activeWithdrawal; + const withdrawalOperationId = + activeWithdrawal !== undefined && + creds !== undefined && + activeWithdrawal.username === creds.username && + activeWithdrawal.backendBaseUrl === backendUrl.href + ? activeWithdrawal.operationId + : undefined; useEffect(() => { const generation = ++creationGeneration.current; if (withdrawalOperationId === undefined) { @@ -235,6 +280,8 @@ export function useComponentState({ operationId: withdrawalOperationId, routeClose, onAbort, + onContinueLater, + onOperationCreated, focus, clearWhenTerminal: true, }); @@ -245,12 +292,18 @@ export function useWithdrawalOperationState({ operationId, routeClose, onAbort, + onContinueLater, + onOperationCreated: _onOperationCreated, focus, clearWhenTerminal = false, }: WithdrawalOperationProps): State { - const result = useWithdrawalDetails(operationId); + const polledOperationId = useRef(operationId); + const polledResult = useWithdrawalDetails(operationId); + const result = + polledOperationId.current === operationId ? polledResult : undefined; + polledOperationId.current = operationId; const { i18n } = useTranslationContext(); - const [, updateBankState] = useBankState(); + const [bankState, updateBankState] = useBankState(); const { lib: { bank }, } = useBankCoreApiContext(); @@ -258,10 +311,20 @@ export function useWithdrawalOperationState({ const terminal = result !== undefined && isTerminalWithdrawalResult(result); useEffect(() => { - if (clearWhenTerminal && terminal) { - updateBankState("currentWithdrawalOperationId", undefined); + if ( + clearWhenTerminal && + terminal && + bankState.activeWithdrawal?.operationId === operationId + ) { + updateBankState("activeWithdrawal", undefined); } - }, [clearWhenTerminal, terminal, updateBankState]); + }, [ + bankState.activeWithdrawal?.operationId, + clearWhenTerminal, + operationId, + terminal, + updateBankState, + ]); if (!result) { return { status: "loading", error: undefined }; @@ -274,6 +337,7 @@ export function useWithdrawalOperationState({ onRetry: () => void revalidateWithdrawalDetails(), routeClose, onAbort, + onContinueLater, focus, }); } diff --git a/packages/libeufin-bank-webui/src/pages/OperationState/stories.tsx b/packages/libeufin-bank-webui/src/pages/OperationState/stories.tsx @@ -37,20 +37,60 @@ import { TalerUriAction, } from "@gnu-taler/taler-util"; import { urlPattern } from "@gnu-taler/web-util/browser"; +import { ActiveWithdrawalNotice } from "../ActiveWithdrawal.js"; +import { AbortWithdrawalDialog } from "../../components/AbortWithdrawalDialog.js"; export default { title: "operation status page", }; -export const Ready = tests.createExample(ReadyView, { +const readyProps = { uri: { - type: TalerUriAction.Withdraw, + type: TalerUriAction.Withdraw as const, bankIntegrationApiBaseUrl: "http://bank.example/" as HostPortPath, withdrawalOperationId: "story-withdrawal", }, operationId: "story-withdrawal", + progressMode: "amount-first" as const, routeClose: urlPattern(/.*/, () => "#"), onAbort: () => undefined, +}; + +export const Ready = tests.createExample( + ReadyView, + { + ...readyProps, + amount: { + mode: "fixed", + value: Amounts.parseOrThrow("ASR:5"), + }, + }, + { loggedIn: true }, +); + +export const ReadyWithSuggestedAmount = tests.createExample( + ReadyView, + { + ...readyProps, + progressMode: "wallet-first" as const, + amount: { + mode: "suggested", + value: Amounts.parseOrThrow("ASR:7.50"), + }, + }, + { loggedIn: true }, +); + +export const ReadyWithoutAmount = tests.createExample( + ReadyView, + { ...readyProps, progressMode: "wallet-first" as const }, + { loggedIn: true }, +); + +export const ReadyWhileSignedOut = tests.createExample(ReadyView, { + ...readyProps, + progressMode: "wallet-first" as const, + routeClose: urlPattern(/.*/, () => "#/login"), }); export const Aborted = tests.createExample(AbortedView, { @@ -85,121 +125,185 @@ export const NeedConfirmation = tests.createExample( { account: "alice", operationId: "withdrawal-1", + progressMode: "amount-first", onAbort: () => undefined, + onContinueLater: () => undefined, details: { account: exchangeAccount.value, reserve: "reserve-pub", username: "alice", - amount: Amounts.parseOrThrow("EUR:5"), + amount: { + mode: "fixed", + value: Amounts.parseOrThrow("EUR:5"), + }, + }, + }, + { + loggedIn: true, + config: { + wire_type: "IBAN", + currency: "EUR", + currency_specification: { + name: "EUR", + alt_unit_names: {}, + num_fractional_input_digits: 2, + num_fractional_normal_digits: 2, + num_fractional_trailing_zero_digits: 2, + }, + wire_transfer_fees: "EUR:0.10", }, }, +); + +export const PendingWithdrawalNotice = tests.createExample( + ActiveWithdrawalNotice, + { + operationId: "withdrawal-1", + status: "pending", + routeOperation: urlPattern<{ wopid: string }>( + /.*/, + ({ wopid }) => `#/start-operation/${wopid}`, + ), + onReview: () => undefined, + onAborted: () => undefined, + }, + { loggedIn: true }, +); + +export const SelectedWithdrawalNotice = tests.createExample( + ActiveWithdrawalNotice, + { + operationId: "withdrawal-1", + status: "selected", + routeOperation: urlPattern<{ wopid: string }>( + /.*/, + ({ wopid }) => `#/start-operation/${wopid}`, + ), + onReview: () => undefined, + onAborted: () => undefined, + }, { loggedIn: true }, ); +export const AbortWithdrawalConfirmation = tests.createExample( + AbortWithdrawalDialog, + { + running: false, + disabled: false, + onKeep: () => undefined, + onAbort: () => undefined, + }, +); + const confirmationStory = { account: "alice", operationId: "withdrawal-1", + progressMode: "amount-first" as const, onAbort: () => undefined, + onContinueLater: () => undefined, }; +const talerBankAccount = { + targetType: PaytoType.TalerBank, + host: "bank.example", + account: "exchange", + params: { "receiver-name": "Exchange" }, +} as any; + export const NeedTalerBankConfirmation = tests.createExample( NeedConfirmationView, { ...confirmationStory, details: { - account: { - targetType: PaytoType.TalerBank, - host: "bank.example", - account: "exchange", - params: { "receiver-name": "Exchange" }, - } as any, + account: talerBankAccount, reserve: "reserve-pub", username: "alice", + amount: { + mode: "fixed", + value: Amounts.parseOrThrow("ASR:5"), + }, }, }, - { loggedIn: true }, + { + loggedIn: true, + config: { wire_transfer_fees: "ASR:0.10" }, + }, ); -export const NeedBitcoinConfirmation = tests.createExample( +export const BankSelectedAmountConfirmation = tests.createExample( NeedConfirmationView, { ...confirmationStory, + progressMode: "wallet-first" as const, details: { - account: { - targetType: PaytoType.Bitcoin, - address: "bc1qexample", - params: {}, - } as any, + account: talerBankAccount, reserve: "reserve-pub", username: "alice", + amount: { + mode: "bank-selected", + suggested: Amounts.parseOrThrow("ASR:7.50"), + }, }, }, - { loggedIn: true }, -); - -export const NeedEthereumConfirmation = tests.createExample( - NeedConfirmationView, { - ...confirmationStory, - details: { - account: { - targetType: PaytoType.Ethereum, - address: "0x0000000000000000000000000000000000000000", - params: { "receiver-name": "Exchange" }, - } as any, - reserve: "reserve-pub", - username: "alice", + loggedIn: true, + config: { + wire_transfer_fees: "ASR:0.10", + min_wire_transfer_amount: "ASR:1", + max_wire_transfer_amount: "ASR:100", }, }, - { loggedIn: true }, ); -export const UnsupportedWalletConfirmation = tests.createExample( +export const MissingAmountConfirmation = tests.createExample( NeedConfirmationView, { ...confirmationStory, + progressMode: "wallet-first" as const, details: { - account: { - targetType: PaytoType.TalerReserve, - params: {}, - } as any, + account: talerBankAccount, reserve: "reserve-pub", username: "alice", + amount: { mode: "missing" }, }, }, { loggedIn: true }, ); -export const NeedCyclosConfirmation = tests.createExample( +export const UnsupportedExchangeAccount = tests.createExample( NeedConfirmationView, { ...confirmationStory, details: { account: { - targetType: PaytoType.Cyclos, - url: "https://cyclos.example/", - account: "exchange", + targetType: PaytoType.Bitcoin, + address: "bc1qexample", params: {}, } as any, reserve: "reserve-pub", username: "alice", + amount: { + mode: "fixed", + value: Amounts.parseOrThrow("ASR:5"), + }, }, }, { loggedIn: true }, ); -export const NeedVoidConfirmation = tests.createExample( +export const WithdrawalAuthentication = tests.createExample( NeedConfirmationView, { ...confirmationStory, details: { - account: { - targetType: PaytoType.Void, - params: {}, - } as any, + account: talerBankAccount, reserve: "reserve-pub", username: "alice", + amount: { + mode: "fixed", + value: Amounts.parseOrThrow("ASR:5"), + }, }, }, - { loggedIn: true }, + { loggedIn: false }, ); diff --git a/packages/libeufin-bank-webui/src/pages/OperationState/views.tsx b/packages/libeufin-bank-webui/src/pages/OperationState/views.tsx @@ -15,33 +15,42 @@ */ import { + AmountJson, + AmountString, Amounts, HttpStatusCode, PaytoType, TalerErrorCode, TalerUriAction, TalerUris, + TranslatedString, assertUnreachable, } from "@gnu-taler/taler-util"; import { Attention, AsyncButton, RenderAmount, + TalerQrCode, + useAsyncAction, useBankCoreApiContext, - useNotificationContext, - useNotifiedOperation, useTalerWalletIntegrationAPI, useTranslationContext, } from "@gnu-taler/web-util/browser"; -import { ComponentChildren, Fragment, VNode, h } from "preact"; -import { useEffect } from "preact/hooks"; -import { QR } from "../../components/QR.js"; -import { usePreferences } from "../../hooks/preferences.js"; +import { Fragment, VNode, h } from "preact"; +import { useEffect, useRef, useState } from "preact/hooks"; +import { revalidateWithdrawalDetails } from "../../hooks/account.js"; import { LoggedIn, useSessionState } from "../../hooks/session.js"; import { useBankChallengeHandlerContext } from "../../context/challenge.js"; import { LoginForm } from "../LoginForm.js"; +import { InputAmount } from "../PaytoWireTransferForm.js"; import { State } from "./index.js"; +import { + AbortWithdrawalDialog, + WithdrawalDialogError, +} from "../../components/AbortWithdrawalDialog.js"; +import { WithdrawalProgress } from "../WithdrawalProgress.js"; +import { validateWithdrawalAmount } from "../withdrawal-amount.js"; export function InvalidPaytoView({ payto }: State.InvalidPayto) { const { i18n } = useTranslationContext(); @@ -68,417 +77,736 @@ export function InvalidReserveView({ reserve }: State.InvalidReserve) { ); } -export function ShouldBeSameUser({ - username, - children, -}: { - username: string; - children: ComponentChildren; -}): VNode { - const { state: credentials } = useSessionState(); - const { i18n } = useTranslationContext(); - if (credentials.status === "loggedOut") { - return ( - <Fragment> - <Attention type="info" title={i18n.str`Authentication required`} /> - <LoginForm currentUser={username} fixedUser /> - </Fragment> - ); - } - if (credentials.status === "expired") { - return <LoginForm currentUser={username} fixedUser />; - } - if (credentials.username !== username) { - return ( - <Fragment> - <Attention - type="warning" - title={i18n.str`This operation was created with another username`} - > - <p> - <i18n.Translate> - You are currently logged in with user "{credentials.username}" and - the operation was made with user "{username}" - </i18n.Translate> - </p> - </Attention> - <LoginForm currentUser={username} fixedUser /> - </Fragment> - ); - } - return <Fragment>{children}</Fragment>; -} - export function NeedConfirmationView({ onAbort, + onContinueLater, account, details, operationId, + progressMode, }: State.NeedConfirmation) { const { i18n } = useTranslationContext(); - const [settings] = usePreferences(); - const { showError } = useNotificationContext(); - const mfa = useBankChallengeHandlerContext(); + const dialogRef = useRef<HTMLDialogElement>(null); + const titleRef = useRef<HTMLHeadingElement>(null); + const abortTitleRef = useRef<HTMLHeadingElement>(null); + const abortTriggerRef = useRef<HTMLButtonElement>(null); + const restoreAbortFocusRef = useRef(false); + const [confirmingAbort, setConfirmingAbort] = useState(false); + const [amountTouched, setAmountTouched] = useState(false); + const [dialogError, setDialogError] = useState< + { title: TranslatedString; description: TranslatedString } | undefined + >(); const { state: credentials } = useSessionState(); - const creds = credentials.status !== "loggedIn" ? undefined : credentials; + const creds = + credentials.status === "loggedIn" && credentials.username === account + ? credentials + : undefined; const { config, lib: { bank }, } = useBankCoreApiContext(); - const wireFee = - config.wire_transfer_fees === undefined - ? Amounts.zeroOfCurrency(config.currency) - : Amounts.parseOrThrow(config.wire_transfer_fees); + const initialAmount = + details.amount.mode === "bank-selected" && details.amount.suggested + ? Amounts.stringifyValue(details.amount.suggested) + : undefined; + const [amountInput, setAmountInput] = useState<string | undefined>( + initialAmount, + ); + + useEffect(() => { + setAmountInput(initialAmount); + setAmountTouched(false); + setDialogError(undefined); + setConfirmingAbort(false); + }, [initialAmount, operationId]); + + useEffect(() => { + const dialog = dialogRef.current; + if (!dialog) return; + const preventCancel = (event: Event): void => event.preventDefault(); + dialog.addEventListener("cancel", preventCancel); + if (mfa.pending) { + if (dialog.open) dialog.close(); + return () => dialog.removeEventListener("cancel", preventCancel); + } + let cancelled = false; + let frame: number | undefined; + const openDialog = (): void => { + if (cancelled) return; + if (!dialog.isConnected) { + frame = requestAnimationFrame(openDialog); + return; + } + if (!dialog.open) dialog.showModal(); + titleRef.current?.focus(); + }; + openDialog(); + return () => { + cancelled = true; + if (frame !== undefined) cancelAnimationFrame(frame); + dialog.removeEventListener("cancel", preventCancel); + if (dialog.open) dialog.close(); + }; + }, [mfa.pending]); + + useEffect(() => { + if (confirmingAbort) { + abortTitleRef.current?.focus(); + } else if (restoreAbortFocusRef.current) { + restoreAbortFocusRef.current = false; + abortTriggerRef.current?.focus(); + } + }, [confirmingAbort]); + + const minimum = !config.min_wire_transfer_amount + ? undefined + : Amounts.parse(config.min_wire_transfer_amount); + const maximum = !config.max_wire_transfer_amount + ? undefined + : Amounts.parse(config.max_wire_transfer_amount); + const wireFee = !config.wire_transfer_fees + ? Amounts.zeroOfCurrency(config.currency) + : Amounts.parse(config.wire_transfer_fees); + const configAmountsValid = + (!config.min_wire_transfer_amount || !!minimum) && + (!config.max_wire_transfer_amount || !!maximum) && + (!config.wire_transfer_fees || !!wireFee) && + [minimum, maximum, wireFee] + .filter((amount): amount is AmountJson => amount !== undefined) + .every( + (amount) => + amount.currency.toUpperCase() === config.currency.toUpperCase(), + ); + + const trimmedAmount = amountInput?.trim(); + const editableAmount = !trimmedAmount + ? undefined + : Amounts.parse(`${config.currency}:${trimmedAmount}`); + const selectedAmount = + details.amount.mode === "fixed" + ? details.amount.value + : details.amount.mode === "bank-selected" + ? editableAmount + : undefined; + const amountValidation = + !editableAmount || !configAmountsValid + ? undefined + : validateWithdrawalAmount(editableAmount, minimum, maximum); + const amountError = + details.amount.mode !== "bank-selected" || !amountTouched + ? undefined + : !trimmedAmount + ? i18n.str`Enter an amount.` + : !editableAmount + ? i18n.str`Enter a valid amount.` + : amountValidation === "zero" + ? i18n.str`The amount must be greater than zero.` + : amountValidation === "below-minimum" + ? i18n.str`The amount is below the bank's minimum.` + : amountValidation === "above-maximum" + ? i18n.str`The amount is above the bank's maximum.` + : undefined; + + const expectsTalerBank = + config.wire_type === "X_TALER_BANK" || config.wire_type === "x-taler-bank"; + const supportedAccount = expectsTalerBank + ? details.account.targetType === PaytoType.TalerBank + : details.account.targetType === PaytoType.IBAN; + const missingAmount = details.amount.mode === "missing"; + const invalidConfiguration = + !configAmountsValid || + wireFee === undefined || + (selectedAmount !== undefined && + selectedAmount.currency.toUpperCase() !== config.currency.toUpperCase()); + let totalDebit: AmountJson | undefined; + if (selectedAmount && wireFee && !invalidConfiguration) { + const total = Amounts.add(selectedAmount, wireFee); + if (!total.saturated) totalDebit = total.amount; + } + const canConfirm = + !!creds && + supportedAccount && + !missingAmount && + !invalidConfiguration && + !!selectedAmount && + !amountValidation && + !!totalDebit; + + function fail(title: TranslatedString, description: TranslatedString): void { + setDialogError({ title, description }); + } // i18n.str`abort withdrawal`, - const abort = useNotifiedOperation< + const abort = useAsyncAction< Awaited<ReturnType<typeof bank.abortWithdrawalById>>, [LoggedIn] >((ct, creds: LoggedIn) => bank.abortWithdrawalById(creds, operationId), { - onSuccess: onAbort, - onFail: showError(i18n.str`Failed to abort the withdrawal.`, (fail) => { - switch (fail.case) { + onResult: (result) => { + if (result.type === "ok") { + onAbort(); + return; + } + switch (result.case) { case HttpStatusCode.BadRequest: - return i18n.str`The server did not understand the request.`; + fail( + i18n.str`Failed to abort the withdrawal.`, + i18n.str`The server did not understand the request.`, + ); + return; case HttpStatusCode.NotFound: - return i18n.str`The operation was not found.`; + fail( + i18n.str`Failed to abort the withdrawal.`, + i18n.str`The operation was not found.`, + ); + void revalidateWithdrawalDetails(); + return; case HttpStatusCode.Conflict: - return i18n.str`The withdrawal operation has been confirmed previously and can not be aborted.`; + fail( + i18n.str`Failed to abort the withdrawal.`, + i18n.str`The withdrawal operation has already been confirmed and cannot be aborted.`, + ); + void revalidateWithdrawalDetails(); + return; default: - assertUnreachable(fail); + assertUnreachable(result); } - }), + }, + onError: () => + fail( + i18n.str`Failed to abort the withdrawal.`, + i18n.str`The bank could not be reached. Please try again.`, + ), }); // i18n.str`confirm withdrawal`, - const confirm = useNotifiedOperation< + const confirm = useAsyncAction< Awaited<ReturnType<typeof bank.confirmWithdrawalById>>, - [LoggedIn, challengeIds?: string[]] + [LoggedIn, amount?: AmountString, challengeIds?: string[]] >( - (ct, creds: LoggedIn, challengeIds?: string[]) => - bank.confirmWithdrawalById(creds, {}, operationId, { challengeIds }), + (ct, creds: LoggedIn, amount?: AmountString, challengeIds?: string[]) => + bank.confirmWithdrawalById(creds, amount ? { amount } : {}, operationId, { + challengeIds, + }), { - onSuccess: () => { - mfa.cancel(); - if (!settings.showWithdrawalSuccess) { - // notifyInfo(i18n.str`Wire transfer completed!`); + onResult: (result, currentCreds, amount) => { + if (result.type === "ok") { + mfa.cancel(); + onAbort(); + return; + } + switch (result.case) { + case HttpStatusCode.Accepted: + mfa.onNewChallenge( + i18n.str`Withdrawal confirmation`, + currentCreds.username, + result.body, + { + running: confirm.running, + cancel: confirm.cancel, + run: (challengeIds) => + confirm.run(currentCreds, amount, challengeIds), + }, + ); + return; + case HttpStatusCode.BadRequest: + fail( + i18n.str`Failed to confirm the withdrawal.`, + i18n.str`The server did not understand the request.`, + ); + return; + case HttpStatusCode.NotFound: + fail( + i18n.str`Failed to confirm the withdrawal.`, + i18n.str`The operation was not found.`, + ); + void revalidateWithdrawalDetails(); + return; + case TalerErrorCode.BANK_UNALLOWED_DEBIT: + fail( + i18n.str`Failed to confirm the withdrawal.`, + i18n.str`The account does not have sufficient funds or the amount is outside the limits.`, + ); + return; + case TalerErrorCode.BANK_CONFIRM_ABORT_CONFLICT: + fail( + i18n.str`Failed to confirm the withdrawal.`, + i18n.str`The withdrawal has been aborted and cannot be confirmed.`, + ); + void revalidateWithdrawalDetails(); + return; + case TalerErrorCode.BANK_CONFIRM_INCOMPLETE: + fail( + i18n.str`Failed to confirm the withdrawal.`, + i18n.str`The wallet has not finished selecting an exchange account.`, + ); + void revalidateWithdrawalDetails(); + return; + case TalerErrorCode.BANK_AMOUNT_DIFFERS: + fail( + i18n.str`Failed to confirm the withdrawal.`, + i18n.str`The withdrawal amount changed. Review the current amount and try again.`, + ); + void revalidateWithdrawalDetails(); + return; + case TalerErrorCode.BANK_AMOUNT_REQUIRED: + fail( + i18n.str`Failed to confirm the withdrawal.`, + i18n.str`Enter an amount before confirming the withdrawal.`, + ); + return; + default: + assertUnreachable(result); } - onAbort(); }, - 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, - { - running: confirm.running, - cancel: confirm.cancel, - run: (challengeIds) => confirm.run(creds, challengeIds), - }, - ); - 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 differ.`; - case TalerErrorCode.BANK_AMOUNT_REQUIRED: - return i18n.str`The bank requires a bank account which has not been specified yet.`; - default: - assertUnreachable(fail); - } - }, - ), + onError: () => { + mfa.cancel(); + fail( + i18n.str`Failed to confirm the withdrawal.`, + i18n.str`The bank could not be reached. Please try again.`, + ); + }, }, ); + const busy = confirm.running || abort.running; + const exchangeName = + details.account.params["receiver-name"] ?? i18n.str`Selected exchange`; + const confirmationAmount = + details.amount.mode === "bank-selected" && selectedAmount + ? (Amounts.stringify(selectedAmount) as AmountString) + : undefined; + + async function submitConfirmation(): Promise<void> { + if (!canConfirm || !creds) return; + setAmountTouched(true); + setDialogError(undefined); + await confirm.run(creds, confirmationAmount); + } + + const authentication = !creds ? ( + <section class="w-full max-w-lg rounded-xl bg-white p-6 text-onBackground shadow-xl"> + <WithdrawalProgress current={3} mode={progressMode} /> + <h2 + ref={titleRef} + tabIndex={-1} + id="withdrawal-confirmation-title" + class="mt-5 text-lg font-semibold outline-none" + > + <i18n.Translate>Sign in to confirm the withdrawal</i18n.Translate> + </h2> + <p + id="withdrawal-confirmation-description" + class="mt-2 text-sm text-gray-600" + > + <i18n.Translate> + Sign in as {account} before reviewing and confirming this withdrawal. + </i18n.Translate> + </p> + {credentials.status === "loggedIn" ? ( + <div class="mt-4 rounded-md border border-warning/60 bg-warningContainer px-3 py-2 text-sm text-onWarningContainer"> + <i18n.Translate> + You are signed in as {credentials.username}, but this withdrawal + belongs to {account}. + </i18n.Translate> + </div> + ) : undefined} + <div class="mt-5"> + <LoginForm currentUser={account} fixedUser /> + </div> + {onContinueLater ? ( + <div class="mt-4 border-t border-onBackground/10 pt-4 text-right"> + <button + type="button" + class="rounded-md px-3 py-2 text-sm font-semibold text-onBackground hover:bg-gray-100" + onClick={onContinueLater} + > + <i18n.Translate>Finish later</i18n.Translate> + </button> + </div> + ) : undefined} + </section> + ) : undefined; + + const confirmation = creds ? ( + <form + class="w-full max-w-2xl overflow-hidden rounded-xl bg-white text-onBackground shadow-xl" + autoCapitalize="none" + autoCorrect="off" + onSubmit={(event) => { + event.preventDefault(); + void submitConfirmation(); + }} + > + <div class="px-5 py-5 sm:px-7 sm:py-6"> + <WithdrawalProgress current={3} mode={progressMode} /> + <h2 + ref={titleRef} + tabIndex={-1} + id="withdrawal-confirmation-title" + class="mt-5 text-lg font-semibold outline-none" + > + <i18n.Translate>Confirm wallet withdrawal</i18n.Translate> + </h2> + <p + id="withdrawal-confirmation-description" + class="mt-2 text-sm text-gray-600" + > + <i18n.Translate> + Review how much will leave your bank account before confirming. + </i18n.Translate> + </p> + + {dialogError ? ( + <div + class="mt-4 rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-800" + role="alert" + > + <div class="font-semibold">{dialogError.title}</div> + <p class="mt-1">{dialogError.description}</p> + </div> + ) : undefined} + + {!supportedAccount ? ( + <div + class="mt-4 rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-800" + role="alert" + > + <div class="font-semibold"> + <i18n.Translate>Unsupported exchange account</i18n.Translate> + </div> + <p class="mt-1"> + <i18n.Translate> + The exchange account does not use the transfer type configured + by this bank. This withdrawal cannot be confirmed. + </i18n.Translate> + </p> + </div> + ) : undefined} + {missingAmount ? ( + <div + class="mt-4 rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-800" + role="alert" + > + <div class="font-semibold"> + <i18n.Translate>Withdrawal amount missing</i18n.Translate> + </div> + <p class="mt-1"> + <i18n.Translate> + The bank did not provide an amount for this withdrawal. It + cannot be confirmed. + </i18n.Translate> + </p> + </div> + ) : undefined} + {invalidConfiguration || (selectedAmount && !totalDebit) ? ( + <div + class="mt-4 rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-800" + role="alert" + > + <div class="font-semibold"> + <i18n.Translate>Bank amount configuration invalid</i18n.Translate> + </div> + <p class="mt-1"> + <i18n.Translate> + The withdrawal amount and bank fee could not be combined safely. + This withdrawal cannot be confirmed. + </i18n.Translate> + </p> + </div> + ) : undefined} + + <div class="mt-5 rounded-xl border border-onBackground/10 bg-background px-4 py-5 text-center"> + <div class="text-sm font-medium text-gray-600"> + <i18n.Translate>Wallet receives</i18n.Translate> + </div> + {details.amount.mode === "bank-selected" ? ( + <div class="mx-auto mt-3 max-w-xs text-left"> + <div + id="withdrawal-amount-label" + class="block text-sm font-medium text-onBackground" + > + <i18n.Translate>Amount to your Taler Wallet</i18n.Translate> + </div> + <InputAmount + currency={config.currency} + name="amount" + ariaLabelledby="withdrawal-amount-label" + left + value={amountInput} + onChange={(value) => { + setAmountInput(value); + setAmountTouched(true); + setDialogError(undefined); + }} + /> + {amountError ? ( + <p class="mt-2 text-sm text-red-700" role="alert"> + {amountError} + </p> + ) : undefined} + {configAmountsValid && (minimum || maximum) ? ( + <p class="mt-2 text-xs text-gray-600"> + {minimum && maximum ? ( + <Fragment> + <i18n.Translate>Allowed amount</i18n.Translate>:{" "} + <RenderAmount + value={minimum} + spec={config.currency_specification} + />{" "} + –{" "} + <RenderAmount + value={maximum} + spec={config.currency_specification} + /> + </Fragment> + ) : minimum ? ( + <Fragment> + <i18n.Translate>Minimum amount</i18n.Translate>:{" "} + <RenderAmount + value={minimum} + spec={config.currency_specification} + /> + </Fragment> + ) : maximum ? ( + <Fragment> + <i18n.Translate>Maximum amount</i18n.Translate>:{" "} + <RenderAmount + value={maximum} + spec={config.currency_specification} + /> + </Fragment> + ) : undefined} + </p> + ) : undefined} + </div> + ) : selectedAmount ? ( + <div class="mt-1 text-3xl font-bold text-brand"> + <RenderAmount + value={selectedAmount} + spec={config.currency_specification} + /> + </div> + ) : ( + <div class="mt-1 text-sm font-semibold text-red-700"> + <i18n.Translate>Amount unavailable</i18n.Translate> + </div> + )} + </div> + + <dl class="mt-5 divide-y divide-onBackground/10 text-sm"> + <div class="grid grid-cols-[minmax(0,1fr)_minmax(0,2fr)] gap-4 py-3"> + <dt class="font-medium text-gray-600"> + <i18n.Translate>From</i18n.Translate> + </dt> + <dd class="text-right font-medium break-words">{account}</dd> + </div> + <div class="grid grid-cols-[minmax(0,1fr)_minmax(0,2fr)] gap-4 py-3"> + <dt class="font-medium text-gray-600"> + <i18n.Translate>To</i18n.Translate> + </dt> + <dd class="text-right font-medium"> + <i18n.Translate>Taler Wallet</i18n.Translate> + </dd> + </div> + <div class="grid grid-cols-[minmax(0,1fr)_minmax(0,2fr)] gap-4 py-3"> + <dt class="font-medium text-gray-600"> + <i18n.Translate>Exchange</i18n.Translate> + </dt> + <dd class="text-right font-medium break-words">{exchangeName}</dd> + </div> + <div class="grid grid-cols-[minmax(0,1fr)_minmax(0,2fr)] gap-4 py-3"> + <dt class="font-medium text-gray-600"> + <i18n.Translate>Bank fee</i18n.Translate> + </dt> + <dd class="text-right font-medium"> + {wireFee && Amounts.isZero(wireFee) ? ( + <i18n.Translate>No fee</i18n.Translate> + ) : wireFee ? ( + <RenderAmount + value={wireFee} + spec={config.currency_specification} + /> + ) : ( + <i18n.Translate>Unavailable</i18n.Translate> + )} + </dd> + </div> + <div class="grid grid-cols-[minmax(0,1fr)_minmax(0,2fr)] gap-4 py-3 text-base"> + <dt class="font-semibold"> + <i18n.Translate>Total debited</i18n.Translate> + </dt> + <dd class="text-right font-bold"> + {totalDebit ? ( + <RenderAmount + value={totalDebit} + spec={config.currency_specification} + /> + ) : ( + <i18n.Translate>Unavailable</i18n.Translate> + )} + </dd> + </div> + </dl> + + {supportedAccount ? ( + <details class="mt-4 rounded-lg border border-onBackground/10 px-4 py-3 text-sm"> + <summary class="cursor-pointer font-semibold"> + <i18n.Translate>Exchange details</i18n.Translate> + </summary> + <dl class="mt-3 space-y-3"> + {details.account.targetType === PaytoType.IBAN ? ( + <div> + <dt class="text-gray-600"> + <i18n.Translate>Exchange IBAN</i18n.Translate> + </dt> + <dd class="mt-1 break-all font-medium"> + {details.account.iban} + </dd> + </div> + ) : details.account.targetType === PaytoType.TalerBank ? ( + <Fragment> + <div> + <dt class="text-gray-600"> + <i18n.Translate>Bank hostname</i18n.Translate> + </dt> + <dd class="mt-1 break-all font-medium"> + {details.account.host} + </dd> + </div> + <div> + <dt class="text-gray-600"> + <i18n.Translate>Exchange account</i18n.Translate> + </dt> + <dd class="mt-1 break-all font-medium"> + {details.account.account} + </dd> + </div> + </Fragment> + ) : undefined} + </dl> + </details> + ) : undefined} + </div> + + <div class="border-t border-onBackground/10 px-5 py-4 sm:px-7"> + <p class="text-sm text-gray-600"> + <i18n.Translate> + No money is withdrawn until you confirm. You can finish this later + from your account overview. + </i18n.Translate> + </p> + <div class="mt-4 flex flex-col gap-3 sm:flex-row sm:justify-end"> + {onContinueLater ? ( + <button + type="button" + class="w-full rounded-md px-3 py-2 text-sm font-semibold text-onBackground hover:bg-gray-100 disabled:cursor-default disabled:opacity-50 sm:w-auto" + disabled={busy} + onClick={onContinueLater} + > + <i18n.Translate>Finish later</i18n.Translate> + </button> + ) : undefined} + <AsyncButton + submit + name="confirm withdrawal" + class="w-full cursor-pointer rounded-md bg-primary px-3 py-2 text-sm font-semibold text-onPrimary shadow-sm hover:bg-primaryHover hover:text-white disabled:cursor-default disabled:opacity-50 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary sm:w-auto" + disabled={!canConfirm || busy} + onClick={(event) => { + event.preventDefault(); + return submitConfirmation(); + }} + > + <i18n.Translate>Confirm withdrawal</i18n.Translate> + </AsyncButton> + </div> + <div class="mt-4 border-t border-onBackground/10 pt-4"> + <button + ref={abortTriggerRef} + type="button" + name="abort withdrawal" + class="text-sm font-semibold text-red-700 hover:text-red-800 disabled:cursor-default disabled:opacity-50" + disabled={busy} + onClick={() => { + setDialogError(undefined); + setConfirmingAbort(true); + }} + > + <i18n.Translate>Abort withdrawal</i18n.Translate> + </button> + </div> + </div> + </form> + ) : undefined; + return ( - <div class="bg-white shadow sm:rounded-lg"> - <div class="px-4 py-5 sm:p-6"> - <h3 class="text-base font-semibold text-gray-900"> - <i18n.Translate>Confirm the withdrawal operation</i18n.Translate> - </h3> - <div class="mt-3 text-sm leading-6"> - <ShouldBeSameUser username={account}> - <form - class="bg-white shadow-sm ring-1 ring-gray-900/5 sm:rounded-xl md:col-span-2" - autoCapitalize="none" - autoCorrect="off" - onSubmit={(e) => { - e.preventDefault(); - }} + <dialog + ref={dialogRef} + aria-modal="true" + aria-labelledby="withdrawal-confirmation-title" + aria-describedby="withdrawal-confirmation-description" + class="fixed inset-0 z-20 size-auto max-h-none max-w-none overflow-y-auto bg-transparent p-4 backdrop:bg-secondary/45" + > + <div class="flex min-h-full items-center justify-center"> + {confirmingAbort ? ( + <section class="w-full max-w-md rounded-xl bg-white p-6 text-onBackground shadow-xl"> + <h2 + ref={abortTitleRef} + tabIndex={-1} + id="withdrawal-confirmation-title" + class="text-lg font-semibold outline-none" > - <div class="px-4 mt-4"> - <div class="w-full"> - <dl class=""> - {((): VNode => { - switch (details.account.targetType) { - case undefined: - case PaytoType.TalerReserveHttp: - case PaytoType.TalerReserve: { - // FIXME: support wire transfer to wallet - return ( - <div> - <i18n.Translate> - Transfers to this wallet account type are not - supported yet. - </i18n.Translate> - </div> - ); - } - case PaytoType.IBAN: { - const name = details.account.params["receiver-name"]; - return ( - <Fragment> - <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0"> - <dt class="text-sm font-medium leading-6 text-gray-900"> - <i18n.Translate> - Payment Service Provider's account number - </i18n.Translate> - </dt> - <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0"> - {details.account.iban} - </dd> - </div> - {name && ( - <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0"> - <dt class="text-sm font-medium leading-6 text-gray-900"> - <i18n.Translate> - Payment Service Provider's name - </i18n.Translate> - </dt> - <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0"> - {name} - </dd> - </div> - )} - </Fragment> - ); - } - case PaytoType.TalerBank: { - const name = details.account.params["receiver-name"]; - return ( - <Fragment> - <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0"> - <dt class="text-sm font-medium leading-6 text-gray-900"> - <i18n.Translate> - Payment Service Provider's account bank - hostname - </i18n.Translate> - </dt> - <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0"> - {details.account.host} - </dd> - </div> - <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0"> - <dt class="text-sm font-medium leading-6 text-gray-900"> - <i18n.Translate> - Payment Service Provider's account id - </i18n.Translate> - </dt> - <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0"> - {details.account.account} - </dd> - </div> - {name && ( - <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0"> - <dt class="text-sm font-medium leading-6 text-gray-900"> - <i18n.Translate> - Payment Service Provider's name - </i18n.Translate> - </dt> - <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0"> - {name} - </dd> - </div> - )} - </Fragment> - ); - } - case PaytoType.Bitcoin: { - const name = details.account.params["receiver-name"]; - return ( - <Fragment> - <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0"> - <dt class="text-sm font-medium leading-6 text-gray-900"> - <i18n.Translate> - Payment Service Provider's account address - </i18n.Translate> - </dt> - <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0"> - {details.account.address} - </dd> - </div> - {name && ( - <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0"> - <dt class="text-sm font-medium leading-6 text-gray-900"> - <i18n.Translate> - Payment Service Provider's name - </i18n.Translate> - </dt> - <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0"> - {name} - </dd> - </div> - )} - </Fragment> - ); - } - case PaytoType.Ethereum: { - const name = details.account.params["receiver-name"]; - return ( - <Fragment> - <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0"> - <dt class="text-sm font-medium leading-6 text-gray-900"> - <i18n.Translate> - Payment Service Provider's account address - </i18n.Translate> - </dt> - <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0"> - {details.account.address} - </dd> - </div> - {name && ( - <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0"> - <dt class="text-sm font-medium leading-6 text-gray-900"> - <i18n.Translate> - Payment Service Provider's name - </i18n.Translate> - </dt> - <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0"> - {name} - </dd> - </div> - )} - </Fragment> - ); - } - case PaytoType.Cyclos: { - const name = details.account.params["receiver-name"]; - return ( - <Fragment> - <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0"> - <dt class="text-sm font-medium leading-6 text-gray-900"> - <i18n.Translate> - Payment Service Provider's account cyclos - hostname - </i18n.Translate> - </dt> - <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0"> - {details.account.url} - </dd> - </div> - <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0"> - <dt class="text-sm font-medium leading-6 text-gray-900"> - <i18n.Translate> - Payment Service Provider's account id - </i18n.Translate> - </dt> - <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0"> - {details.account.account} - </dd> - </div> - {name && ( - <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0"> - <dt class="text-sm font-medium leading-6 text-gray-900"> - <i18n.Translate> - Payment Service Provider's name - </i18n.Translate> - </dt> - <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0"> - {name} - </dd> - </div> - )} - </Fragment> - ); - } - case PaytoType.Void: - return ( - <p> - <i18n.Translate> - Void payment targets are not supported. - </i18n.Translate> - </p> - ); - default: { - assertUnreachable(details.account); - } - } - })()} - <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0"> - <dt class="text-sm font-medium leading-6 text-gray-900"> - <i18n.Translate>Amount</i18n.Translate> - </dt> - <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0"> - {details.amount !== undefined ? ( - <RenderAmount - value={details.amount} - spec={config.currency_specification} - /> - ) : ( - <i18n.Translate> - No amount has yet been determined. - </i18n.Translate> - )} - </dd> - </div> - {Amounts.isZero(wireFee) ? undefined : ( - <Fragment> - <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0"> - <dt class="text-sm font-medium leading-6 text-gray-900"> - <i18n.Translate>Cost</i18n.Translate> - </dt> - <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0"> - <RenderAmount - value={wireFee} - negative - withColor - spec={config.currency_specification} - /> - </dd> - </div> - </Fragment> - )} - </dl> - </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"> - <AsyncButton - name="cancel" - class="text-sm font-semibold leading-6 text-gray-900" - disabled={!creds} - onClick={() => abort.run(creds!)} - > - <i18n.Translate>Cancel</i18n.Translate> - </AsyncButton> - <AsyncButton - 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" - disabled={!creds} - onClick={() => confirm.run(creds!)} - > - <i18n.Translate>Transfer</i18n.Translate> - </AsyncButton> + <i18n.Translate>Abort this withdrawal?</i18n.Translate> + </h2> + <p + id="withdrawal-confirmation-description" + class="mt-3 text-sm text-gray-600" + > + <i18n.Translate> + This stops the withdrawal. No money will be transferred, and + this operation cannot be resumed. + </i18n.Translate> + </p> + {dialogError ? ( + <div + class="mt-4 rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-800" + role="alert" + > + <div class="font-semibold">{dialogError.title}</div> + <p class="mt-1">{dialogError.description}</p> </div> - </form> - </ShouldBeSameUser> - </div> + ) : undefined} + <div class="mt-6 flex flex-wrap items-center justify-end gap-3"> + <button + type="button" + class="rounded-md px-3 py-2 text-sm font-semibold text-onBackground hover:bg-gray-100 disabled:cursor-default disabled:opacity-50" + disabled={busy} + onClick={() => { + restoreAbortFocusRef.current = true; + setConfirmingAbort(false); + setDialogError(undefined); + }} + > + <i18n.Translate>Keep withdrawal</i18n.Translate> + </button> + <AsyncButton + name="confirm abort withdrawal" + class="rounded-md bg-red-700 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-800" + disabled={!creds || busy} + onClick={() => { + setDialogError(undefined); + return abort.run(creds!); + }} + > + <i18n.Translate>Abort withdrawal</i18n.Translate> + </AsyncButton> + </div> + </section> + ) : ( + (authentication ?? confirmation) + )} </div> - </div> + </dialog> ); } export function FailedView({ error }: State.Failed) { @@ -533,13 +861,15 @@ export function AbortedView() { export function ConfirmedView({ routeClose }: State.Confirmed) { const { i18n } = useTranslationContext(); - const [settings, updateSettings] = usePreferences(); return ( - <Fragment> - <div class="relative ml-auto mr-auto transform overflow-hidden rounded-lg bg-white p-4 text-left shadow-xl transition-all "> - <div class="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-green-100"> + <section + class="mx-auto w-full max-w-lg overflow-hidden rounded-xl bg-white text-onBackground shadow-xl" + aria-labelledby="withdrawal-confirmed-title" + > + <div class="px-6 py-8 text-center sm:px-8 sm:py-10"> + <div class="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-green-100"> <svg - class="h-6 w-6 text-green-600" + class="h-7 w-7 text-green-700" fill="none" viewBox="0 0 24 24" stroke-width="1.5" @@ -553,83 +883,52 @@ export function ConfirmedView({ routeClose }: State.Confirmed) { /> </svg> </div> - <div class="mt-3 text-center sm:mt-5"> - <h3 - class="text-base font-semibold leading-6 text-gray-900" - id="modal-title" + <div class="mt-5"> + <h1 + class="text-xl font-semibold leading-7" + id="withdrawal-confirmed-title" > <i18n.Translate>Withdrawal confirmed</i18n.Translate> - </h3> - <div class="mt-2"> - <p class="text-sm text-gray-500"> - <i18n.Translate> - The wire transfer to the Payment Service Provider has been - initiated. You will shortly receive the requested amount in your - Taler wallet.{" "} - </i18n.Translate> - </p> - </div> - </div> - </div> - <div class="mt-4"> - <div class="flex items-center justify-between"> - <span class="flex flex-grow flex-col"> - <span - class="text-sm text-black font-medium leading-6 " - id="hide-withdrawal-success-label" - > - <i18n.Translate>Do not show this again</i18n.Translate> - </span> - </span> - <button - type="button" - name="toggle withdrawal" - data-enabled={!settings.showWithdrawalSuccess} - class="bg-indigo-600 data-[enabled=false]:bg-gray-200 relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-indigo-600 focus:ring-offset-2" - role="switch" - aria-checked={!settings.showWithdrawalSuccess} - aria-labelledby="hide-withdrawal-success-label" - onClick={() => { - updateSettings( - "showWithdrawalSuccess", - !settings.showWithdrawalSuccess, - ); - }} - > - <span - aria-hidden="true" - data-enabled={!settings.showWithdrawalSuccess} - class="translate-x-5 data-[enabled=false]:translate-x-0 pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out" - ></span> - </button> + </h1> + <p class="mx-auto mt-3 max-w-sm text-sm leading-6 text-gray-600"> + <i18n.Translate> + The bank transfer to the Taler exchange has been initiated. Your + Taler Wallet will receive the withdrawn amount shortly. + </i18n.Translate> + </p> </div> </div> - <div class="mt-5 sm:mt-6"> + <div class="border-t border-onBackground/10 bg-background px-6 py-4 sm:flex sm:justify-end sm:px-8"> <a href={routeClose.url({})} - name="close" - class="inline-flex w-full justify-center rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" + name="back to account" + class="inline-flex w-full justify-center rounded-md bg-primary px-4 py-2 text-sm font-semibold text-onPrimary shadow-sm hover:bg-primaryHover hover:text-white focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary sm:w-auto" > - <i18n.Translate>Close</i18n.Translate> + <i18n.Translate>Back to account</i18n.Translate> </a> </div> - </Fragment> + </section> ); } export function ReadyView({ uri, - focus, + amount, + progressMode, onAbort, operationId, + routeClose, }: State.Ready): VNode { const { i18n } = useTranslationContext(); const { publishTalerAction } = useTalerWalletIntegrationAPI(); - const { showError } = useNotificationContext(); + const [confirmAbort, setConfirmAbort] = useState(false); + const [dialogError, setDialogError] = useState<WithdrawalDialogError>(); + const abortTriggerRef = useRef<HTMLButtonElement>(null); const { state: credentials } = useSessionState(); const creds = credentials.status !== "loggedIn" ? undefined : credentials; const { + config, lib: { bank }, } = useBankCoreApiContext(); @@ -648,100 +947,174 @@ export function ReadyView({ }); }, [integrationBaseUrl, publishTalerAction, withdrawalOperationId]); + function fail(description: string): void { + setDialogError({ + title: i18n.str`Failed to abort the withdrawal.`, + description, + }); + } + // i18n.str`abort withdrawal`, - const abort = useNotifiedOperation< + const abort = useAsyncAction< Awaited<ReturnType<typeof bank.abortWithdrawalById>>, [LoggedIn] - >((ct, creds: LoggedIn) => bank.abortWithdrawalById(creds, operationId), { - onSuccess: onAbort, - onFail: showError(i18n.str`Failed to abort the withdrawal`, (fail) => { - switch (fail.case) { + >((ct, auth: LoggedIn) => bank.abortWithdrawalById(auth, operationId), { + onResult: (result) => { + if (result.type === "ok") { + setConfirmAbort(false); + onAbort(); + return; + } + switch (result.case) { case HttpStatusCode.BadRequest: - return i18n.str`The server did not understand the request.`; + fail(i18n.str`The server did not understand the request.`); + return; case HttpStatusCode.NotFound: - return i18n.str`The operation was not found.`; + fail(i18n.str`The operation was not found.`); + void revalidateWithdrawalDetails(); + return; case HttpStatusCode.Conflict: - return i18n.str`The withdrawal operation has been confirmed previously and can not be aborted.`; + fail( + i18n.str`The withdrawal operation has already been confirmed and cannot be aborted.`, + ); + void revalidateWithdrawalDetails(); + return; default: - assertUnreachable(fail); + assertUnreachable(result); } - }), + }, + onError: () => + fail(i18n.str`The bank could not be reached. Please try again.`), }); return ( <Fragment> - <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"> + <section + class="mx-auto max-w-3xl overflow-hidden bg-white shadow-xl sm:rounded-xl" + aria-labelledby="wallet-withdrawal-title" + > + <div class="px-5 py-7 text-center sm:px-8 sm:py-9"> + <WithdrawalProgress + current={progressMode === "amount-first" ? 2 : 1} + mode={progressMode} + /> + <h1 + id="wallet-withdrawal-title" + class="mt-6 text-xl font-semibold text-brand sm:text-2xl" + > <i18n.Translate> - If you have a Taler wallet installed on this device + Complete withdrawal with your Taler Wallet </i18n.Translate> - </h3> - <div class="mt-4 mb-4 text-sm text-gray-500"> - <p> - <i18n.Translate> - Your wallet will display the details of the transaction - including the fees (if applicable). If you do not yet have a - wallet, please follow the instructions - </i18n.Translate>{" "} - <a - class="font-semibold text-indigo-600 hover:text-indigo-900" - name="wallet page" - href="https://taler.net/en/wallet.html" - > - <i18n.Translate>on this page</i18n.Translate> - </a> - . - </p> + </h1> + <p class="mx-auto mt-3 max-w-xl text-sm leading-6 text-gray-600"> + <i18n.Translate> + Scan this QR code with your Taler Wallet. Your wallet will select + an exchange and prepare the withdrawal. + </i18n.Translate> + </p> + + {amount ? ( + <div class="mx-auto mt-6 max-w-sm rounded-lg border border-primary/20 bg-primary/5 px-4 py-3"> + <div class="text-sm font-medium text-secondary"> + {amount.mode === "fixed" ? ( + <i18n.Translate>Withdrawal amount</i18n.Translate> + ) : ( + <i18n.Translate>Suggested amount</i18n.Translate> + )} + </div> + <div class="mt-1 text-2xl font-semibold text-brand"> + <RenderAmount + value={amount.value} + spec={config.currency_specification} + /> + </div> + {amount.mode === "suggested" ? ( + <p class="mt-1 text-xs text-gray-600"> + <i18n.Translate> + You can change this amount in your wallet. + </i18n.Translate> + </p> + ) : undefined} + </div> + ) : undefined} + + <div class="mt-6 flex justify-center"> + <TalerQrCode + url={talerWithdrawUri} + size={360} + alt={i18n.str`QR code to continue the withdrawal in a Taler Wallet.`} + /> </div> - <div class="flex items-center justify-between gap-x-6 pt-2 mt-2 "> - <AsyncButton - 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 " - disabled={!creds} - onClick={() => abort.run(creds!)} - > - <i18n.Translate>Cancel</i18n.Translate> - </AsyncButton> + <div class="mt-6"> <a href={talerWithdrawUri} name="withdraw" - class="inline-flex items-center disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" + class="inline-flex items-center justify-center rounded-md border border-primary px-4 py-2 text-sm font-semibold text-primaryDark hover:bg-primary/10 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary" > - <i18n.Translate>Withdraw</i18n.Translate> + <i18n.Translate>Open Taler Wallet manually</i18n.Translate> </a> </div> - </div> - </div> - <div class="bg-white shadow-xl sm:rounded-lg mt-8"> - <div class="px-4 py-5 sm:p-6"> - <h3 class="text-base font-semibold leading-6 text-gray-900"> - <i18n.Translate> - In case you have a Taler wallet on another device - </i18n.Translate> - </h3> - <div class="mt-4 max-w-xl text-sm text-gray-500"> + <p class="mt-4 text-sm text-gray-600"> + <i18n.Translate>Don't have a Taler Wallet?</i18n.Translate>{" "} + <a + class="font-semibold text-primaryDark hover:text-onPrimaryContainer" + name="wallet page" + href="https://taler.net/en/wallet.html" + target="_blank" + rel="noreferrer noopener" + > + <i18n.Translate>Get a wallet</i18n.Translate> + </a> + </p> + + <p class="mx-auto mt-6 max-w-xl border-t border-onBackground/10 pt-5 text-sm leading-6 text-gray-600"> <i18n.Translate> - Scan the QR below to start the withdrawal. + No money will be transferred until you return here to review and + confirm the withdrawal. </i18n.Translate> - </div> - <div class="mt-2 max-w-md ml-auto mr-auto"> - <QR text={talerWithdrawUri} /> - </div> + </p> </div> - <div class="flex items-center justify-center gap-x-6 border-t border-gray-900/10 px-4 py-4 sm:px-8"> - <AsyncButton - class="text-sm font-semibold leading-6 text-gray-900" - disabled={!creds} - onClick={() => abort.run(creds!)} - > - <i18n.Translate>Cancel</i18n.Translate> - </AsyncButton> + + <div class="border-t border-onBackground/10 bg-background px-5 py-4 text-left sm:px-8"> + {creds ? ( + <button + ref={abortTriggerRef} + type="button" + name="abort withdrawal" + class="text-sm font-semibold text-red-700 hover:text-red-800" + onClick={() => { + setDialogError(undefined); + setConfirmAbort(true); + }} + > + <i18n.Translate>Abort withdrawal</i18n.Translate> + </button> + ) : ( + <a + href={routeClose.url({})} + class="text-sm font-semibold text-red-700 hover:text-red-800" + > + <i18n.Translate>Sign in to abort this withdrawal</i18n.Translate> + </a> + )} </div> - </div> + </section> + + {confirmAbort ? ( + <AbortWithdrawalDialog + running={abort.running} + disabled={!creds || abort.running} + error={dialogError} + returnFocus={abortTriggerRef} + onKeep={() => { + setDialogError(undefined); + setConfirmAbort(false); + }} + onAbort={() => abort.run(creds!)} + /> + ) : undefined} </Fragment> ); } diff --git a/packages/libeufin-bank-webui/src/pages/PaymentOptions.stories.tsx b/packages/libeufin-bank-webui/src/pages/PaymentOptions.stories.tsx @@ -30,19 +30,8 @@ export default { const route = urlPattern<any>(/.*/, () => "#"); export const USD = tests.createExample(PaymentOptions, { - limit: { - currency: "USD", - fraction: 0, - value: 1, - negative: false, - saturated: false, - }, - balance: { currency: "USD", fraction: 0, value: 1 }, - routeClose: route, routeCashout: route, routeChargeWallet: route, routeWireTransfer: route, routeOperationDetails: route, - onOperationCreated: () => undefined, - onClose: () => undefined, }); diff --git a/packages/libeufin-bank-webui/src/pages/PaymentOptions.tsx b/packages/libeufin-bank-webui/src/pages/PaymentOptions.tsx @@ -1,224 +1,175 @@ /* This file is part of GNU Taler - (C) 2022-2024 Taler Systems S.A. + (C) 2022-2024, 2026 Taler Systems S.A. GNU Taler is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation; either version 3, or (at your option) any later version. +*/ - GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along with - GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> - */ - -import { AmountJson } from "@gnu-taler/taler-util"; import { RouteDefinition, + useBankCoreApiContext, useTranslationContext, } from "@gnu-taler/web-util/browser"; -import { VNode, h } from "preact"; -import { PaytoWireTransferForm } from "./PaytoWireTransferForm.js"; -import { WalletWithdrawForm } from "./WalletWithdrawForm.js"; -import { IntAmountJson } from "./regional/CreateCashout.js"; - -// function ShowOperationPendingTag({ -// woid, -// onOperationAlreadyCompleted, -// }: { -// woid: string; -// onOperationAlreadyCompleted?: () => void; -// }): VNode { -// const { i18n } = useTranslationContext(); -// const { state: credentials } = useSessionState(); -// const result = useWithdrawalDetails(woid); -// const loading = !result; -// const error = -// !loading && (result instanceof TalerError || result.type === "fail"); -// const pending = -// !loading && -// !error && -// result.body.status === "selected" && -// // (result.body.status === "pending" || result.body.status === "selected") && -// credentials.status === "loggedIn" && -// credentials.username === result.body.username; - -// if (error || !pending) { -// return <Fragment />; -// } - -// return ( -// <span class="flex items-center gap-x-1.5 w-fit rounded-md bg-yellow-100 px-2 py-1 text-xs font-medium text-yellow-700 whitespace-pre"> -// <svg -// class="h-1.5 w-1.5 fill-yellow-500" -// viewBox="0 0 6 6" -// aria-hidden="true" -// > -// <circle cx="3" cy="3" r="3" /> -// </svg> -// <i18n.Translate>Pending operation</i18n.Translate> -// </span> -// ); -// } +import { ComponentChildren, VNode, h } from "preact"; +import { useBankState } from "../hooks/bank-state.js"; +import { useSessionState } from "../hooks/session.js"; export interface PaymentOptionProps { - limit: IntAmountJson; - balance: AmountJson; - tab: "charge-wallet" | "wire-transfer" | undefined; - - onOperationCreated: (wopid: string) => void; - onClose: () => void; - routeOperationDetails: RouteDefinition<{ wopid: string }>; - routeClose: RouteDefinition; routeCashout: RouteDefinition; routeChargeWallet: RouteDefinition; - routeWireTransfer: RouteDefinition<{ - account?: string; - subject?: string; - amount?: string; - }>; + routeWireTransfer: RouteDefinition; } -/** - * Let the user choose a payment option, - * then specify the details trigger the action. - */ export function PaymentOptions({ - routeClose, routeCashout, routeChargeWallet, routeWireTransfer, - tab, - limit, - balance, - onOperationCreated, - onClose, routeOperationDetails, }: PaymentOptionProps): VNode { const { i18n } = useTranslationContext(); + const { config, url: backendUrl } = useBankCoreApiContext(); + const [bankState] = useBankState(); + const { state: session } = useSessionState(); + const activeWithdrawal = + session.status === "loggedIn" && + bankState.activeWithdrawal?.username === session.username && + bankState.activeWithdrawal.backendBaseUrl === backendUrl.href + ? bankState.activeWithdrawal + : undefined; + const activeOperationId = + activeWithdrawal?.operationId ?? bankState.currentWithdrawalOperationId; + const walletHref = activeOperationId + ? routeOperationDetails.url({ wopid: activeOperationId }) + : routeChargeWallet.url({}); + + return ( + <section class="mt-8" aria-labelledby="account-actions-heading"> + <h2 + id="account-actions-heading" + class="text-lg font-semibold text-onBackground" + > + <i18n.Translate>Actions</i18n.Translate> + </h2> + <div class="mt-4 grid gap-4 sm:grid-cols-2 lg:grid-cols-3"> + <ActionCard + name="charge wallet" + href={walletHref} + icon={<WalletIcon />} + title={i18n.str`Withdraw to Taler Wallet`} + description={i18n.str`Move digital cash to your Taler Wallet.`} + /> + <ActionCard + name="wire transfer" + href={routeWireTransfer.url({})} + icon={<TransferIcon />} + title={i18n.str`Send bank transfer`} + description={i18n.str`Transfer money to another bank account.`} + /> + {config.allow_conversion ? ( + <ActionCard + name="cash out" + href={routeCashout.url({})} + icon={<CashoutIcon />} + title={i18n.str`Cash out`} + description={i18n.str`Convert regional currency and send it to your cashout account.`} + /> + ) : undefined} + </div> + </section> + ); +} +function ActionCard({ + href, + name, + icon, + title, + description, +}: { + href: string; + name: string; + icon: ComponentChildren; + title: string; + description: string; +}): VNode { return ( - <div class="mt-4"> - <fieldset> - <legend class="px-4 text-base font-semibold leading-6 text-gray-900"> - <i18n.Translate>Send money</i18n.Translate> - </legend> + <a + href={href} + name={name} + aria-label={title} + class="group flex min-h-28 gap-4 rounded-lg border border-outlineVariant bg-white p-5 shadow-sm transition hover:border-primary/50 hover:shadow focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary" + > + <span class="flex h-11 w-11 shrink-0 items-center justify-center rounded-lg bg-primaryMuted text-primaryDark"> + {icon} + </span> + <span> + <span class="block font-semibold text-onBackground group-hover:text-primaryDark"> + {title} + </span> + <span class="mt-1 block text-sm leading-5 text-secondary"> + {description} + </span> + </span> + </a> + ); +} - <div class="px-4 mt-4 grid grid-cols-1 gap-y-6 sm:grid-cols-2 sm:gap-x-4"> - {/* <!-- Active: "border-indigo-600 ring-2 ring-indigo-600", Not Active: "border-gray-300" --> */} - <a name="charge wallet" href={routeChargeWallet.url({})}> - <div - class={ - "relative flex cursor-pointer rounded-lg border bg-white p-4 shadow-sm focus:outline-none " + - (tab === "charge-wallet" - ? "border-indigo-600 ring-2 ring-indigo-600" - : "border-gray-300") - } - > - <div class="flex flex-col"> - <span class="flex"> - <div class="text-4xl mr-4 my-auto">&#x1F4B5;</div> - <span class="grow self-center text-lg text-gray-900 align-middle text-center"> - <i18n.Translate>to a Taler wallet</i18n.Translate> - </span> - <svg - data-selection={tab} - class="self-center flex-none h-5 w-5 text-indigo-600 invisible data-[selection=charge-wallet]:visible" - viewBox="0 0 20 20" - fill="currentColor" - aria-hidden="true" - > - <path - fill-rule="evenodd" - d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z" - clip-rule="evenodd" - /> - </svg> - </span> - <div class="mt-1 flex items-center text-sm text-gray-500"> - <i18n.Translate> - Withdraw digital money into your mobile wallet or browser - extension - </i18n.Translate> - </div> - {/* {!!bankState.currentWithdrawalOperationId && ( - <ShowOperationPendingTag - woid={bankState.currentWithdrawalOperationId} - onOperationAlreadyCompleted={() => { - updateBankState( - "currentWithdrawalOperationId", - undefined, - ); - }} - /> - )} */} - </div> - </div> - </a> +function WalletIcon(): VNode { + return ( + <svg + aria-hidden="true" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + stroke-width="1.8" + class="h-6 w-6" + > + <path + stroke-linecap="round" + stroke-linejoin="round" + d="M3 7.5h15A3 3 0 0 1 21 10.5v7A2.5 2.5 0 0 1 18.5 20h-13A2.5 2.5 0 0 1 3 17.5v-12A2.5 2.5 0 0 1 5.5 3H18v4.5m0 5h3m-5 0h.01" + /> + </svg> + ); +} - <a name="wire transfer" href={routeWireTransfer.url({})}> - <div - class={ - "relative flex cursor-pointer rounded-lg border bg-white p-4 shadow-sm focus:outline-none " + - (tab === "wire-transfer" - ? "border-indigo-600 ring-2 ring-indigo-600" - : "border-gray-300") - } - > - <div class="flex flex-col"> - <span class="flex"> - <div class="text-4xl mr-4 my-auto">&#x2194;</div> - <span class="grow self-center text-lg font-medium text-gray-900 align-middle text-center"> - <i18n.Translate>to another bank account</i18n.Translate> - </span> - <svg - data-selection={tab} - class="self-center flex-none h-5 w-5 text-indigo-600 invisible data-[selection=wire-transfer]:visible" - viewBox="0 0 20 20" - fill="currentColor" - aria-hidden="true" - > - <path - fill-rule="evenodd" - d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z" - clip-rule="evenodd" - /> - </svg> - </span> - <div class="mt-1 flex items-center text-sm text-gray-500"> - <i18n.Translate> - Make a wire transfer to an account with a known bank account - number. - </i18n.Translate> - </div> - </div> - </div> - </a> - </div> - {tab === "charge-wallet" && ( - <WalletWithdrawForm - focus - limit={limit} - balance={balance} - onOperationCreated={onOperationCreated} - onOperationAborted={onClose} - routeCancel={routeClose} - /> - )} - {tab === "wire-transfer" && ( - <PaytoWireTransferForm - focus - limit={limit} - balance={balance} - onSuccess={onClose} - routeCashout={routeCashout} - routeCancel={routeClose} - /> - )} - </fieldset> - </div> +function TransferIcon(): VNode { + return ( + <svg + aria-hidden="true" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + stroke-width="1.8" + class="h-6 w-6" + > + <path + stroke-linecap="round" + stroke-linejoin="round" + d="M4 8h15m0 0-4-4m4 4-4 4M20 16H5m0 0 4 4m-4-4 4-4" + /> + </svg> + ); +} + +function CashoutIcon(): VNode { + return ( + <svg + aria-hidden="true" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + stroke-width="1.8" + class="h-6 w-6" + > + <path + stroke-linecap="round" + stroke-linejoin="round" + d="M12 3v12m0 0 4-4m-4 4-4-4M5 19h14" + /> + </svg> ); } diff --git a/packages/libeufin-bank-webui/src/pages/PaytoWireTransferForm.stories.tsx b/packages/libeufin-bank-webui/src/pages/PaytoWireTransferForm.stories.tsx @@ -34,5 +34,17 @@ export const USD = tests.createExample(PaytoWireTransferForm, { negative: false, saturated: false, }, - balance: { currency: "ASR", fraction: 0, value: 1 }, +}); + +export const WithTransferDetails = tests.createExample(PaytoWireTransferForm, { + limit: { + currency: "ASR", + fraction: 0, + value: 100, + negative: false, + saturated: false, + }, + withAccount: "merchant", + withSubject: "Invoice 1042", + withAmount: "25", }); diff --git a/packages/libeufin-bank-webui/src/pages/PaytoWireTransferForm.tsx b/packages/libeufin-bank-webui/src/pages/PaytoWireTransferForm.tsx @@ -42,6 +42,7 @@ import { import { ComponentChildren, Fragment, Ref, VNode, h } from "preact"; import { useState } from "preact/hooks"; import { useBankChallengeHandlerContext } from "../context/challenge.js"; +import { OperationError } from "../components/OperationError.js"; import { LoggedIn, useSessionState } from "../hooks/session.js"; import { undefinedIfEmpty, validateIBAN, validateTalerBank } from "../utils.js"; import { IntAmountJson, IntAmounts } from "./regional/CreateCashout.js"; @@ -55,7 +56,6 @@ export interface Props { routeCancel?: RouteDefinition; routeCashout?: RouteDefinition; limit: IntAmountJson; - balance: AmountJson; } export function PaytoWireTransferForm({ @@ -67,10 +67,9 @@ export function PaytoWireTransferForm({ routeCancel, routeCashout, limit, - balance, }: Props): VNode { - const [inputType, setInputType] = useState<"form" | "payto" | "qr">("form"); - const isRawPayto = inputType !== "form"; + const [inputType, setInputType] = useState<"form" | "payto">("form"); + const isRawPayto = inputType === "payto"; const { state: credentials } = useSessionState(); const creds = credentials.status === "loggedIn" ? credentials : undefined; @@ -208,7 +207,7 @@ export function PaytoWireTransferForm({ rawPaytoInputSetter(undefined); }, onFail: showError( - i18n.str`Failed to create the transactions.`, + i18n.str`Failed to create the transaction.`, (fail, creds, amount, uri) => { switch (fail.case) { case HttpStatusCode.BadRequest: @@ -248,228 +247,153 @@ export function PaytoWireTransferForm({ }, ); + function switchToForm(): void { + if (parsed?.tag === "ok") { + switch (parsed.value.targetType) { + case PaytoType.Ethereum: + case PaytoType.Bitcoin: + case undefined: + case PaytoType.TalerReserve: + case PaytoType.Void: + case PaytoType.TalerReserveHttp: + break; + case PaytoType.IBAN: + setAccount(parsed.value.iban); + break; + case PaytoType.TalerBank: + case PaytoType.Cyclos: + setAccount(parsed.value.account); + break; + default: + assertUnreachable(parsed.value); + } + const paytoAmount = parsed.value.params.amount; + if (paytoAmount) { + const parsedPaytoAmount = Amounts.parse(paytoAmount); + if (parsedPaytoAmount) { + setAmount(Amounts.stringifyValue(parsedPaytoAmount)); + } + } + const paytoSubject = + parsed.value.params.message ?? parsed.value.params.subject; + if (paytoSubject) setSubject(paytoSubject); + } + setInputType("form"); + } + + function switchToPayto(): void { + if (account) { + let payto: Paytos.URI; + switch (paytoType) { + case "x-taler-bank": + payto = Paytos.createTalerBank(url.href as HostPortPath, account); + break; + case "iban": + payto = Paytos.createIban(account as IbanString, undefined); + break; + default: + assertUnreachable(paytoType); + } + if (parsedAmount) { + payto.params.amount = Amounts.stringify(parsedAmount); + } + if (subject) payto.params.message = subject; + rawPaytoInputSetter(Paytos.toFullString(payto)); + } + setInputType("payto"); + } + + const parsedSendingAmount = sAmount ? Amounts.parse(sAmount) : undefined; + const totalResult = + parsedSendingAmount?.currency === wireFee.currency + ? Amounts.add(parsedSendingAmount, wireFee) + : undefined; + const totalDebit = totalResult?.saturated ? undefined : totalResult?.amount; + return ( - <div class="grid grid-cols-1 gap-x-8 gap-y-8 pt-6 my-4 md:grid-cols-3 bg-gray-100 px-4 pb-4 rounded-lg"> - <div> - <fieldset class="px-2 grid grid-cols-1 gap-y-4 sm:gap-x-4"> - <legend class="sr-only"> - <i18n.Translate>Input wire transfer detail</i18n.Translate> - </legend> - <div class="-space-y-px rounded-md "> - <label - aria-label={i18n.str`Using a form`} - data-checked={inputType === "form"} - class="group rounded-tl-md rounded-tr-md relative flex cursor-pointer border p-4 focus:outline-none bg-white data-[checked=true]:z-10 data-[checked=true]:border-indigo-200 data-[checked=true]:bg-indigo-50" - > - <input - type="radio" - name="input-type" - onChange={() => { - if (parsed && parsed.tag === "ok") { - switch (parsed.value.targetType) { - case PaytoType.Ethereum: - case PaytoType.Bitcoin: - case undefined: - case PaytoType.TalerReserve: - case PaytoType.Void: - case PaytoType.TalerReserveHttp: { - // FIXME: unsupported payto - break; - } - case PaytoType.IBAN: { - setAccount(parsed.value.iban); - break; - } - case PaytoType.TalerBank: { - setAccount(parsed.value.account); - break; - } - case PaytoType.Cyclos: { - setAccount(parsed.value.account); - break; - } - default: { - assertUnreachable(parsed.value); - } - } - const amountStr = !parsed.value.params - ? undefined - : parsed.value.params["amount"]; - if (amountStr) { - const amount = Amounts.parse(amountStr); - if (amount) { - setAmount(Amounts.stringifyValue(amount)); - } - } - // FIXME: Why? Do we still need this fallback? - const subject = !parsed.value.params["message"] - ? parsed.value.params["subject"] - : parsed.value.params["message"]; - if (subject) { - setSubject(subject); - } - } - setInputType("form"); - }} - checked={inputType === "form"} - value="form" - class="mt-0.5 h-4 w-4 shrink-0 cursor-pointer text-indigo-600 border-gray-300 focus:ring-indigo-600 active:ring-2 active:ring-offset-2 active:ring-indigo-600" + <form + class="mt-6 overflow-hidden rounded-xl border border-onBackground/10 bg-white shadow-sm" + autoCapitalize="none" + autoCorrect="off" + onSubmit={(e) => e.preventDefault()} + > + <div class="border-b border-onBackground/10 bg-primary/5 px-5 py-5 sm:px-8"> + <div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"> + <div> + <h2 class="text-lg font-semibold text-onBackground"> + <i18n.Translate>Transfer details</i18n.Translate> + </h2> + <p class="mt-1 text-sm text-gray-600"> + <i18n.Translate> + Enter the recipient, a reference, and the amount to send. + </i18n.Translate> + </p> + </div> + <div class="shrink-0 rounded-lg bg-white px-4 py-3 ring-1 ring-primary/15"> + <div class="text-xs font-medium uppercase tracking-wide text-gray-500"> + <i18n.Translate>Available to transfer</i18n.Translate> + </div> + <div class="mt-1 text-lg font-semibold text-secondary"> + <RenderAmount + value={limitWithFee} + spec={config.currency_specification} /> - <span class="ml-3 flex flex-col"> - {/* <!-- Checked: "text-indigo-900", Not Checked: "text-gray-900" --> */} - <span - data-checked={inputType === "form"} - class="block text-sm font-medium data-[checked=true]:text-indigo-900" - > - <i18n.Translate>Using a form</i18n.Translate> - </span> - </span> - </label> - {sendingToFixedAccount ? undefined : ( - <Fragment> - <label - aria-label="payto:// URI" - data-checked={inputType === "payto"} - class="relative flex cursor-pointer border p-4 focus:outline-none bg-white data-[checked=true]:z-10 data-[checked=true]:border-indigo-200 data-[checked=true]:bg-indigo-50" - > - <input - type="radio" - name="input-type" - onChange={() => { - if (account) { - let payto: Paytos.URI; - switch (paytoType) { - case "x-taler-bank": { - payto = Paytos.createTalerBank( - url.href as HostPortPath, - account, - ); - if (parsedAmount) { - payto.params["amount"] = - Amounts.stringify(parsedAmount); - } - if (subject) { - payto.params["message"] = subject; - } - break; - } - case "iban": { - payto = Paytos.createIban( - account as IbanString, - undefined, - ); - if (parsedAmount) { - payto.params["amount"] = - Amounts.stringify(parsedAmount); - } - if (subject) { - payto.params["message"] = subject; - } - break; - } - default: - assertUnreachable(paytoType); - } - rawPaytoInputSetter(Paytos.toFullString(payto)); - } - setInputType("payto"); - }} - checked={inputType === "payto"} - value="payto" - class="mt-0.5 h-4 w-4 shrink-0 cursor-pointer text-indigo-600 border-gray-300 focus:ring-indigo-600 active:ring-2 active:ring-offset-2 active:ring-indigo-600" - /> - <span class="ml-3 flex flex-col"> - <span - data-checked={inputType === "payto"} - class="block font-medium data-[checked=true]:text-indigo-900" - > - payto:// URI - </span> - <span - data-checked={inputType === "payto"} - class="block text-sm text-gray-500 data-[checked=true]:text-indigo-600" - > - <i18n.Translate> - A special URI that specifies the amount to be - transferred and the destination account. - </i18n.Translate> - </span> - </span> - </label> - { - //FIXME: add QR support - // eslint-disable-next-line no-constant-binary-expression - false && ( - <label - aria-label={i18n.str`Scan a QR code`} - data-checked={inputType === "qr"} - class="rounded-bl-md rounded-br-md relative flex cursor-pointer border p-4 focus:outline-none bg-white data-[checked=true]:z-10 data-[checked=true]:border-indigo-200 data-[checked=true]:bg-indigo-50" - > - <input - type="radio" - name="input-type" - onChange={() => { - setInputType("qr"); - }} - checked={inputType === "qr"} - value="qr" - class="mt-0.5 h-4 w-4 shrink-0 cursor-pointer text-indigo-600 border-gray-300 focus:ring-indigo-600 active:ring-2 active:ring-offset-2 active:ring-indigo-600" - /> - <span class="ml-3 flex flex-col"> - <span - data-checked={inputType === "qr"} - class="block font-medium data-[checked=true]:text-indigo-900" - > - <i18n.Translate>QR code</i18n.Translate> - </span> - <span - data-checked={inputType === "qr"} - class="block text-sm text-gray-500 data-[checked=true]:text-indigo-600" - > - <i18n.Translate> - If your device has a camera, you can import a - payto:// URI from a QR code. - </i18n.Translate> - </span> - </span> - </label> - ) - } - </Fragment> - )} + </div> </div> - {routeCashout && config.allow_conversion ? ( - <a - name="do cashout" - href={routeCashout.url({})} - class="bg-white p-4 rounded-lg text-sm font-semibold leading-6 text-gray-900" - > - <i18n.Translate>Cashout</i18n.Translate> - </a> - ) : undefined} - </fieldset> + </div> + + {!sendingToFixedAccount ? ( + <fieldset class="mt-5"> + <legend class="sr-only"> + <i18n.Translate>Transfer input method</i18n.Translate> + </legend> + <div class="inline-flex rounded-lg bg-onBackground/5 p-1"> + <label + data-checked={inputType === "form"} + class="cursor-pointer rounded-md px-3 py-2 text-sm font-medium text-gray-600 data-[checked=true]:bg-white data-[checked=true]:text-brand data-[checked=true]:shadow-sm" + > + <input + type="radio" + name="input-type" + onChange={switchToForm} + checked={inputType === "form"} + value="form" + class="sr-only" + /> + <i18n.Translate>Enter details</i18n.Translate> + </label> + <label + data-checked={inputType === "payto"} + class="cursor-pointer rounded-md px-3 py-2 text-sm font-medium text-gray-600 data-[checked=true]:bg-white data-[checked=true]:text-brand data-[checked=true]:shadow-sm" + > + <input + type="radio" + name="input-type" + onChange={switchToPayto} + checked={inputType === "payto"} + value="payto" + class="sr-only" + /> + <i18n.Translate>Paste payto URI</i18n.Translate> + </label> + </div> + </fieldset> + ) : undefined} </div> - <form - class="bg-white shadow-sm ring-1 ring-gray-900/5 rounded-md sm:rounded-xl md:col-span-2 w-fit mx-auto" - autoCapitalize="none" - autoCorrect="off" - onSubmit={(e) => { - e.preventDefault(); - }} - > - <div class="m-4"> + <div class="px-5 py-6 sm:px-8"> + <div class="max-w-2xl"> {!isRawPayto ? ( - <div class="grid max-w-xs grid-cols-1 gap-x-6 gap-y-8 "> + <div class="grid grid-cols-1 gap-y-6"> {(() => { switch (paytoType) { - case "x-taler-bank": { + case "x-taler-bank": return ( <TextField id="x-taler-bank" required label={i18n.str`Recipient`} - help={i18n.str`ID of the recipient's account`} + help={i18n.str`Account name of the recipient`} error={errorsWire?.account} onChange={setAccount} value={account} @@ -478,8 +402,7 @@ export function PaytoWireTransferForm({ disabled={sendingToFixedAccount} /> ); - } - case "iban": { + case "iban": return ( <TextField id="iban" @@ -488,13 +411,12 @@ export function PaytoWireTransferForm({ help={i18n.str`IBAN of the recipient's account`} placeholder={"CC0123456789" as TranslatedString} error={errorsWire?.account} - onChange={(v) => setAccount(v.toUpperCase())} + onChange={(value) => setAccount(value.toUpperCase())} value={account} focus={focus} disabled={sendingToFixedAccount} /> ); - } default: assertUnreachable(paytoType); } @@ -503,42 +425,34 @@ export function PaytoWireTransferForm({ <div class="sm:col-span-5"> <label for="subject" - class="block text-sm font-medium leading-6 text-gray-900" + class="block text-sm font-medium leading-6 text-onBackground" > - {i18n.str`Transfer subject`} + {i18n.str`Transfer reference`} <b class="text-[red]"> *</b> </label> <div class="mt-2"> <textarea - type="textarea" - rows={3} - class="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" + rows={2} + class="block w-full rounded-md border-0 py-1.5 text-onBackground shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-primary sm:text-sm sm:leading-6" name="subject" id="subject" autocomplete="off" - placeholder={i18n.str`Subject`} + placeholder={i18n.str`What is this transfer for?`} value={subject ?? ""} required - onInput={(e): void => { - setSubject(e.currentTarget.value); - }} + onInput={(e) => setSubject(e.currentTarget.value)} /> <ShowInputErrorLabel message={errorsWire?.subject} isDirty={subject !== undefined} /> </div> - <p class="mt-2 text-sm text-gray-500"> - <i18n.Translate> - Some text to identify the transfer - </i18n.Translate> - </p> </div> <div class="sm:col-span-5"> <label for="amount" - class="block text-sm font-medium leading-6 text-gray-900" + class="block text-sm font-medium leading-6 text-onBackground" > {i18n.str`Amount`} <b class="text-[red]"> *</b> @@ -548,105 +462,120 @@ export function PaytoWireTransferForm({ left currency={limitWithFee.currency} value={trimmedAmountStr} - onChange={(d) => { - setAmount(d); - }} + onChange={setAmount} /> <ShowInputErrorLabel message={errorsWire?.amount} isDirty={trimmedAmountStr !== undefined} /> - <p class="mt-2 text-sm text-gray-500"> - <i18n.Translate>Amount to transfer</i18n.Translate> - </p> </div> </div> ) : ( - <div class="grid max-w-2xl grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6 w-full"> - <div class="sm:col-span-6"> - <label - for="address" - class="block text-sm font-medium leading-6 text-gray-900" - > - {i18n.str`Payto URI:`} - <b class="text-[red]"> *</b> - </label> - <div class="mt-2"> - <textarea - ref={focus ? doAutoFocus : undefined} - name="address" - id="address" - type="textarea" - rows={5} - class="block overflow-hidden w-44 sm:w-96 rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" - value={rawPaytoInput ?? ""} - required - title={i18n.str`Uniform resource identifier of the target account`} - placeholder={((): TranslatedString => { - switch (paytoType) { - case "x-taler-bank": - return i18n.str`payto://x-taler-bank/[bank-host]/[receiver-account]?message=[subject]&amount=[${limitWithFee.currency}:X.Y]`; - case "iban": - return i18n.str`payto://iban/[receiver-iban]?message=[subject]&amount=[${limitWithFee.currency}:X.Y]`; - } - })()} - onInput={(e): void => { - rawPaytoInputSetter(e.currentTarget.value); - }} - /> - <ShowInputErrorLabel - message={errorsPayto?.rawPaytoInput} - isDirty={rawPaytoInput !== undefined} - /> - </div> + <div> + <label + for="address" + class="block text-sm font-medium leading-6 text-onBackground" + > + {i18n.str`Payto URI`} + <b class="text-[red]"> *</b> + </label> + <p class="mt-1 text-sm text-gray-500"> + <i18n.Translate> + Paste a payto URI containing the recipient, amount, and + transfer reference. + </i18n.Translate> + </p> + <div class="mt-2"> + <textarea + ref={focus ? doAutoFocus : undefined} + name="address" + id="address" + rows={5} + class="block w-full rounded-md border-0 py-1.5 text-onBackground shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-primary sm:text-sm sm:leading-6" + value={rawPaytoInput ?? ""} + required + title={i18n.str`Uniform resource identifier of the target account`} + placeholder={((): TranslatedString => { + switch (paytoType) { + case "x-taler-bank": + return i18n.str`payto://x-taler-bank/[bank-host]/[receiver-account]?message=[subject]&amount=[${limitWithFee.currency}:X.Y]`; + case "iban": + return i18n.str`payto://iban/[receiver-iban]?message=[subject]&amount=[${limitWithFee.currency}:X.Y]`; + } + })()} + onInput={(e) => rawPaytoInputSetter(e.currentTarget.value)} + /> + <ShowInputErrorLabel + message={errorsPayto?.rawPaytoInput} + isDirty={rawPaytoInput !== undefined} + /> </div> </div> )} - {Amounts.isNonZero(limitWithFee) ? ( - <p class="mt-2 text-sm text-gray-900"> + + {parsedSendingAmount && totalDebit ? ( + <dl class="mt-6 divide-y divide-onBackground/10 rounded-lg bg-background px-4 py-2 text-sm ring-1 ring-onBackground/10"> + <div class="flex items-center justify-between py-2"> + <dt class="text-gray-600"> + <i18n.Translate>Recipient receives</i18n.Translate> + </dt> + <dd class="font-medium text-onBackground"> + <RenderAmount + value={parsedSendingAmount} + spec={config.currency_specification} + /> + </dd> + </div> + <div class="flex items-center justify-between py-2"> + <dt class="text-gray-600"> + <i18n.Translate>Bank fee</i18n.Translate> + </dt> + <dd class="text-onBackground"> + <RenderAmount + value={wireFee} + spec={config.currency_specification} + /> + </dd> + </div> + <div class="flex items-center justify-between py-2"> + <dt class="font-medium text-onBackground"> + <i18n.Translate>Total debited</i18n.Translate> + </dt> + <dd class="font-semibold text-onBackground"> + <RenderAmount + value={totalDebit} + spec={config.currency_specification} + /> + </dd> + </div> + </dl> + ) : undefined} + + {routeCashout && config.allow_conversion ? ( + <p class="mt-6 text-sm text-gray-600"> <i18n.Translate> - The maximum amount for a wire transfer is{" "} - <RenderAmount - value={limitWithFee} - spec={config.currency_specification} - /> - </i18n.Translate> + Need to move money out of this bank? + </i18n.Translate>{" "} + <a + name="do cashout" + href={routeCashout.url({})} + class="font-semibold text-brand hover:underline" + > + <i18n.Translate>Start a cashout</i18n.Translate> + </a> </p> ) : undefined} </div> - {Amounts.isZero(wireFee) ? undefined : ( - <div class="px-4 my-4"> - <div class="grid max-w-2xl grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6"> - <div class="sm:col-span-6"> - <dl class="mt-4 space-y-4"> - <Fragment> - <div class="flex items-center justify-between "> - <dt class="flex items-center text-sm text-gray-600"> - <span> - <i18n.Translate>Cost</i18n.Translate> - </span> - </dt> - <dd class="text-sm text-gray-900"> - <RenderAmount - value={wireFee} - negative - withColor - spec={config.currency_specification} - /> - </dd> - </div> - </Fragment> - </dl> - </div> - </div> - </div> - )} - <div class="flex items-center justify-between gap-x-6 border-t border-gray-900/10 px-4 py-4 sm:px-8"> + </div> + + <div class="border-t border-onBackground/10 bg-background/60 px-5 py-4 sm:px-8"> + <OperationError class="mb-4" /> + <div class="flex items-center justify-between gap-x-6"> {routeCancel ? ( <a name="cancel" href={routeCancel.url({})} - class="text-sm font-semibold leading-6 text-gray-900" + class="text-sm font-semibold leading-6 text-onBackground hover:underline" > <i18n.Translate>Cancel</i18n.Translate> </a> @@ -656,15 +585,15 @@ export function PaytoWireTransferForm({ <AsyncButton 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" + class="cursor-pointer rounded-md bg-primary px-4 py-2 text-sm font-semibold text-onPrimary shadow-sm hover:bg-primaryHover hover:text-white focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary disabled:cursor-default disabled:opacity-50" disabled={sendDisabled} onClick={() => send.run(creds!, sAmount!, parsedURI!)} > - <i18n.Translate>Send</i18n.Translate> + <i18n.Translate>Send transfer</i18n.Translate> </AsyncButton> </div> - </form> - </div> + </div> + </form> ); } @@ -692,6 +621,7 @@ export function InputAmount( value, left, placeholder, + ariaLabelledby, onChange, }: { currency: string; @@ -699,6 +629,7 @@ export function InputAmount( left?: boolean | undefined; value: string | undefined; placeholder?: string | undefined; + ariaLabelledby?: string | undefined; onChange?: (s: string) => void; }, ref: Ref<HTMLInputElement>, @@ -706,16 +637,17 @@ export function InputAmount( const { config } = useBankCoreApiContext(); return ( <div class="mt-2"> - <div class="flex rounded-md shadow-sm border-0 ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-indigo-600"> + <div class="flex rounded-md shadow-sm border-0 ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-primary"> <div class="pointer-events-none inset-y-0 flex items-center px-3"> <span class="text-gray-500 sm:text-sm">{currency}</span> </div> <input type="number" data-left={left} - class="disabled:bg-gray-200 text-right rounded-md rounded-l-none data-[left=true]:text-left w-full py-1.5 pl-3 text-gray-900 placeholder:text-gray-400 sm:text-sm sm:leading-6" + class="disabled:bg-gray-200 text-right rounded-md rounded-l-none data-[left=true]:text-left w-full py-1.5 pl-3 text-onBackground placeholder:text-gray-400 sm:text-sm sm:leading-6" placeholder={placeholder ?? "0.00"} aria-describedby="price-currency" + aria-labelledby={ariaLabelledby} ref={ref} name={name} id={name} @@ -870,7 +802,10 @@ export function TextField({ }: PaytoFieldProps): VNode { return ( <div class="sm:col-span-5"> - <label for={id} class="block text-sm font-medium leading-6 text-gray-900"> + <label + for={id} + class="block text-sm font-medium leading-6 text-onBackground" + > {label} {required && <b class="text-[red]"> *</b>} </label> @@ -879,7 +814,7 @@ export function TextField({ <input ref={focus ? doAutoFocus : undefined} type="text" - class="block w-full disabled:bg-gray-200 rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" + class="block w-full disabled:bg-gray-200 rounded-md border-0 py-1.5 text-onBackground shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-primary sm:text-sm sm:leading-6" name={id} id={id} disabled={disabled} diff --git a/packages/libeufin-bank-webui/src/pages/ProfileNavigation.stories.tsx b/packages/libeufin-bank-webui/src/pages/ProfileNavigation.stories.tsx @@ -0,0 +1,54 @@ +/* + This file is part of GNU Taler + (C) 2026 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. +*/ + +import { urlPattern } from "@gnu-taler/web-util/browser"; +import * as tests from "@gnu-taler/web-util/testing"; +import { ProfileNavigation } from "./ProfileNavigation.js"; + +export default { + title: "my account navigation", +}; + +const routeMyAccountDetails = urlPattern( + /^\/my-profile$/, + () => "#/my-profile", +); +const routeMyAccountPassword = urlPattern( + /^\/my-password$/, + () => "#/my-password", +); +const routeMyAccountMerchantIntegration = urlPattern( + /^\/my-merchant-integration$/, + () => "#/my-merchant-integration", +); +const routeOverview = urlPattern(/^\/account$/, () => "#/account"); + +export const Profile = tests.createExample(ProfileNavigation, { + current: "details", + routeOverview, + routeMyAccountDetails, + routeMyAccountMerchantIntegration, + routeMyAccountPassword, +}); + +export const Security = tests.createExample(ProfileNavigation, { + current: "credentials", + routeOverview, + routeMyAccountDetails, + routeMyAccountMerchantIntegration, + routeMyAccountPassword, +}); + +export const MerchantIntegration = tests.createExample(ProfileNavigation, { + current: "merchant-integration", + routeOverview, + routeMyAccountDetails, + routeMyAccountMerchantIntegration, + routeMyAccountPassword, +}); diff --git a/packages/libeufin-bank-webui/src/pages/ProfileNavigation.tsx b/packages/libeufin-bank-webui/src/pages/ProfileNavigation.tsx @@ -1,6 +1,6 @@ /* This file is part of GNU Taler - (C) 2022-2024 Taler Systems S.A. + (C) 2022-2024, 2026 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 @@ -9,201 +9,87 @@ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. +*/ - You should have received a copy of the GNU General Public License along with - GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> - */ -import { assertUnreachable } from "@gnu-taler/taler-util"; import { - useNavigationContext, + RouteDefinition, useTranslationContext, } from "@gnu-taler/web-util/browser"; -import { Fragment, VNode, h } from "preact"; -import { useBankCoreApiContext } from "@gnu-taler/web-util/browser"; -import { useSessionState } from "../hooks/session.js"; -import { RouteDefinition } from "@gnu-taler/web-util/browser"; +import { VNode, h } from "preact"; export function ProfileNavigation({ current, - routeMyAccountCashout, - routeMyAccountDelete, + routeOverview, routeMyAccountDetails, + routeMyAccountMerchantIntegration, routeMyAccountPassword, - routeConversionConfig, }: { - current: "details" | "delete" | "credentials" | "cashouts" | "conversion"; + current: "details" | "credentials" | "merchant-integration"; + routeOverview: RouteDefinition; routeMyAccountDetails: RouteDefinition; - routeMyAccountDelete: RouteDefinition; + routeMyAccountMerchantIntegration: RouteDefinition; routeMyAccountPassword: RouteDefinition; - routeMyAccountCashout: RouteDefinition; - routeConversionConfig: RouteDefinition; }): VNode { const { i18n } = useTranslationContext(); - const { config } = useBankCoreApiContext(); - const { state: credentials } = useSessionState(); - const isAdminUser = - credentials.status !== "loggedIn" ? false : credentials.isUserAdministrator; - const nonAdminUser = !isAdminUser; + const links = [ + { + id: "details" as const, + label: i18n.str`Profile`, + href: routeMyAccountDetails.url({}), + }, + { + id: "credentials" as const, + label: i18n.str`Security`, + href: routeMyAccountPassword.url({}), + }, + { + id: "merchant-integration" as const, + label: i18n.str`Merchant integration`, + href: routeMyAccountMerchantIntegration.url({}), + }, + ]; - const { navigateTo } = useNavigationContext(); return ( - <div> - <div class="sm:hidden"> - <label - htmlFor="tabs" - class="sr-only" + <div class="mb-6"> + <a + class="mb-3 inline-flex items-center gap-2 text-sm font-semibold text-primaryDark hover:underline focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary" + href={routeOverview.url({})} + > + <svg + class="h-4 w-4" + fill="none" + viewBox="0 0 24 24" + stroke-width="2" + stroke="currentColor" + aria-hidden="true" > - <i18n.Translate>Select a section</i18n.Translate> - </label> - <select - id="tabs" - name="tabs" - class="block w-full rounded-md border-gray-300 focus:border-indigo-500 focus:ring-indigo-500" - onChange={(e) => { - const op = e.currentTarget.value as typeof current; - switch (op) { - case "details": { - navigateTo(routeMyAccountDetails.url({})); - return; - } - case "delete": { - navigateTo(routeMyAccountDelete.url({})); - return; - } - case "credentials": { - navigateTo(routeMyAccountPassword.url({})); - return; - } - case "cashouts": { - navigateTo(routeMyAccountCashout.url({})); - return; - } - case "conversion": { - navigateTo(routeConversionConfig.url({})); - return; - } - default: - assertUnreachable(op); - } - }} - > - <option value="details" selected={current == "details"}> - <i18n.Translate>Details</i18n.Translate> - </option> - {!config.allow_deletions ? undefined : ( - <option value="delete" selected={current == "delete"}> - <i18n.Translate>Delete</i18n.Translate> - </option> - )} - <option value="credentials" selected={current == "credentials"}> - <i18n.Translate>Credentials</i18n.Translate> - </option> - {config.allow_conversion && nonAdminUser ? ( - <Fragment> - <option value="cashouts" selected={current == "cashouts"}> - <i18n.Translate>Cashouts</i18n.Translate> - </option> - </Fragment> - ) : undefined} - {config.allow_conversion && isAdminUser ? ( - <Fragment> - <option value="conversion" selected={current == "conversion"}> - <i18n.Translate>Conversion</i18n.Translate> - </option> - </Fragment> - ) : undefined} - </select> - </div> - <div class="hidden sm:block"> - <nav - class="isolate flex divide-x divide-gray-200 rounded-lg shadow" - aria-label={i18n.str`Tabs`} - > - <a - name="my account details" - href={routeMyAccountDetails.url({})} - data-selected={current == "details"} - class="rounded-l-lg text-gray-500 hover:text-gray-700 data-[selected=true]:text-gray-900 group relative min-w-0 flex-1 overflow-hidden bg-white py-4 px-4 text-center text-sm font-medium hover:bg-gray-50 focus:z-10" - > - <span> - <i18n.Translate>Details</i18n.Translate> - </span> - <span - aria-hidden="true" - data-selected={current == "details"} - class="bg-transparent data-[selected=true]:bg-indigo-500 absolute inset-x-0 bottom-0 h-0.5" - ></span> - </a> - {!config.allow_deletions ? undefined : ( - <a - name="my account delete" - href={routeMyAccountDelete.url({})} - data-selected={current == "delete"} - aria-current="page" - class=" text-gray-500 hover:text-gray-700 data-[selected=true]:text-gray-900 group relative min-w-0 flex-1 overflow-hidden bg-white py-4 px-4 text-center text-sm font-medium hover:bg-gray-50 focus:z-10" - > - <span> - <i18n.Translate>Delete</i18n.Translate> - </span> - <span - aria-hidden="true" - data-selected={current == "delete"} - class="bg-transparent data-[selected=true]:bg-indigo-500 absolute inset-x-0 bottom-0 h-0.5" - ></span> - </a> - )} - <a - name="my account password" - href={routeMyAccountPassword.url({})} - data-selected={current == "credentials"} - aria-current="page" - class=" text-gray-500 hover:text-gray-700 data-[selected=true]:text-gray-900 group relative min-w-0 flex-1 overflow-hidden bg-white py-4 px-4 text-center text-sm font-medium hover:bg-gray-50 focus:z-10" - > - <span> - <i18n.Translate>Credentials</i18n.Translate> - </span> - <span - aria-hidden="true" - data-selected={current == "credentials"} - class="bg-transparent data-[selected=true]:bg-indigo-500 absolute inset-x-0 bottom-0 h-0.5" - ></span> - </a> - {config.allow_conversion && nonAdminUser ? ( - <a - name="my account cashout" - href={routeMyAccountCashout.url({})} - data-selected={current == "cashouts"} - class="rounded-r-lg text-gray-500 hover:text-gray-700 data-[selected=true]:text-gray-900 group relative min-w-0 flex-1 overflow-hidden bg-white py-4 px-4 text-center text-sm font-medium hover:bg-gray-50 focus:z-10" - > - <span> - <i18n.Translate>Cashouts</i18n.Translate> - </span> - <span - aria-hidden="true" - data-selected={current == "cashouts"} - class="bg-transparent data-[selected=true]:bg-indigo-500 absolute inset-x-0 bottom-0 h-0.5" - ></span> - </a> - ) : undefined} - {config.allow_conversion && isAdminUser ? ( - <a - name="conversion config" - href={routeConversionConfig.url({})} - data-selected={current == "conversion"} - class="rounded-r-lg text-gray-500 hover:text-gray-700 data-[selected=true]:text-gray-900 group relative min-w-0 flex-1 overflow-hidden bg-white py-4 px-4 text-center text-sm font-medium hover:bg-gray-50 focus:z-10" - > - <span> - <i18n.Translate>Conversion</i18n.Translate> - </span> - <span - aria-hidden="true" - data-selected={current == "conversion"} - class="bg-transparent data-[selected=true]:bg-indigo-500 absolute inset-x-0 bottom-0 h-0.5" - ></span> - </a> - ) : undefined} - </nav> - </div> + <path + stroke-linecap="round" + stroke-linejoin="round" + d="M10.5 19.5 3 12m0 0 7.5-7.5M3 12h18" + /> + </svg> + <i18n.Translate>Back to overview</i18n.Translate> + </a> + <nav aria-label={i18n.str`My account settings`}> + <ul class="flex overflow-x-auto border-b border-gray-200"> + {links.map((link) => { + const selected = link.id === current; + return ( + <li key={link.id} class="shrink-0"> + <a + href={link.href} + aria-current={selected ? "page" : undefined} + data-selected={selected} + class="inline-flex min-h-11 items-center border-b-2 border-transparent px-4 text-sm font-semibold text-secondary hover:border-primary/40 hover:text-primaryDark data-[selected=true]:border-primary data-[selected=true]:text-primaryDark" + > + {link.label} + </a> + </li> + ); + })} + </ul> + </nav> </div> ); } diff --git a/packages/libeufin-bank-webui/src/pages/PublicHistoriesPage.tsx b/packages/libeufin-bank-webui/src/pages/PublicHistoriesPage.tsx @@ -65,56 +65,51 @@ export function PublicHistoriesPage(): VNode { ? showAccount : accountList[0]?.username; - const accountsBar = []; - - // Ask story of all the public accounts. - for (const account of accountList) { - const isSelected = account.username === selectedAccount; - accountsBar.push( - <li - key={account.username} - class={ - isSelected - ? "pure-menu-selected pure-menu-item" - : "pure-menu-item pure-menu" - } - > - <button - type="button" - name={`show account ${account.username}`} - class="pure-menu-link" - onClick={(event) => { - setShowAccount(account.username); - }} - > - {account.username} - </button> - </li>, - ); - } - return ( - <Fragment> - <h1 class="nav">{i18n.str`History of public accounts`}</h1> - <section id="main"> - <article> - <div class="pure-menu pure-menu-horizontal" name="accountMenu"> - <ul class="pure-menu-list">{accountsBar}</ul> - {selectedAccount !== undefined ? ( - <Transactions - account={selectedAccount} - routeCreateWireTransfer={undefined} - anonymous - /> - ) : ( - <p> - <i18n.Translate>No public transactions found.</i18n.Translate> - </p> - )} - <br /> + <section> + <header> + <h1 class="text-2xl font-bold tracking-tight text-onBackground"> + <i18n.Translate>Public accounts</i18n.Translate> + </h1> + <p class="mt-2 text-sm text-secondary"> + <i18n.Translate> + Browse transaction histories shared by public bank accounts. + </i18n.Translate> + </p> + </header> + + {selectedAccount === undefined ? ( + <div class="mt-6 rounded-lg border border-outlineVariant bg-primaryMuted p-6 text-sm text-secondary"> + <i18n.Translate>No public accounts are available.</i18n.Translate> + </div> + ) : ( + <Fragment> + <nav aria-label={i18n.str`Public accounts`} class="mt-6"> + <ul class="flex flex-wrap gap-2"> + {accountList.map((account) => { + const isSelected = account.username === selectedAccount; + return ( + <li key={account.username}> + <button + type="button" + name={`show account ${account.username}`} + aria-pressed={isSelected} + data-selected={isSelected} + class="rounded-full border border-outlineVariant bg-white px-4 py-2 text-sm font-semibold text-secondary hover:border-primary/40 hover:text-primaryDark data-[selected=true]:border-primary data-[selected=true]:bg-primaryMuted data-[selected=true]:text-primaryDark" + onClick={() => setShowAccount(account.username)} + > + {account.username} + </button> + </li> + ); + })} + </ul> + </nav> + <div class="mt-6"> + <Transactions account={selectedAccount} anonymous /> </div> - </article> - </section> - </Fragment> + </Fragment> + )} + </section> ); } diff --git a/packages/libeufin-bank-webui/src/pages/RegistrationPage.tsx b/packages/libeufin-bank-webui/src/pages/RegistrationPage.tsx @@ -1,6 +1,6 @@ /* This file is part of GNU Taler - (C) 2022-2024 Taler Systems S.A. + (C) 2022-2024, 2026 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 @@ -9,364 +9,392 @@ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. +*/ - You should have received a copy of the GNU General Public License along with - GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> - */ import { - assertUnreachable, HttpStatusCode, TalerCorebankApi, TalerErrorCode, + TranslatedString, + assertUnreachable, } from "@gnu-taler/taler-util"; import { - AsyncButton, RouteDefinition, - ShowInputErrorLabel, + useAsyncAction, useBankCoreApiContext, - useNotificationContext, - useNotifiedOperation, useTranslationContext, } from "@gnu-taler/web-util/browser"; -import { Fragment, h, VNode } from "preact"; -import { useState } from "preact/hooks"; -import { useSettingsContext } from "../context/settings.js"; -import { undefinedIfEmpty } from "../utils.js"; -import { getRandomUsername } from "./rnd.js"; -import { doAutoFocus } from "./PaytoWireTransferForm.js"; +import { RefObject, VNode, h } from "preact"; +import { useEffect, useRef, useState } from "preact/hooks"; +import { AuthAlert, AuthCard, FieldError, PasswordField } from "./AuthForm.js"; +import { + PASSWORD_MAX_LENGTH, + USERNAME_MAX_LENGTH, + isBlankName, + validatePassword, + validateUsername, +} from "./auth-validation.js"; +import { + translatePasswordError, + translateUsernameError, + useLoginOperation, +} from "./LoginForm.js"; const TALER_SCREEN_ID = 110; -RegistrationPage.SCREEN_ID = TALER_SCREEN_ID; -export function RegistrationPage({ - onRegistrationSuccesful, - routeCancel, -}: { - onRegistrationSuccesful: (user: string, password: string) => void; - routeCancel: RouteDefinition; -}): VNode { - const { i18n } = useTranslationContext(); - const { config } = useBankCoreApiContext(); - if (!config.allow_registrations) { - return ( - <p>{i18n.str`Currently, the bank is not accepting new registrations!`}</p> - ); - } - return ( - <RegistrationForm - onRegistrationSuccesful={onRegistrationSuccesful} - routeCancel={routeCancel} - /> - ); +interface RegistrationValues { + name: string; + username: string; + password: string; } -// eslint-disable-next-line no-useless-escape -export const USERNAME_REGEX = /^[a-zA-Z0-9\-\.\_\~]*$/; -// export const PHONE_REGEX = /^(\+\d{1,2}\s)?\(?\d{3}\)?[\s.-]\d{3}[\s.-]\d{4}$/; -// export const EMAIL_REGEX = /^[\w-.]+@([\w-]+\.)+[\w-]{2,4}$/; +type RegistrationField = "name" | "username" | "password" | "confirmation"; -/** - * Collect and submit registration data. - */ -RegistrationForm.SCREEN_ID = TALER_SCREEN_ID; -function RegistrationForm({ - onRegistrationSuccesful, - routeCancel, -}: { - onRegistrationSuccesful: (user: string, password: string) => void; - routeCancel: RouteDefinition; -}): VNode { - const [username, setUsername] = useState<string | undefined>(); - const [name, setName] = useState<string | undefined>(); - const [password, setPassword] = useState<string | undefined>(); - // const [phone, setPhone] = useState<string | undefined>(); - // const [email, setEmail] = useState<string | undefined>(); - const [repeatPassword, setRepeatPassword] = useState<string | undefined>(); - const { showError } = useNotificationContext(); - const settings = useSettingsContext(); - const { - lib: { bank: api }, - } = useBankCoreApiContext(); - // const { register } = useTestingAPI(); - const { i18n } = useTranslationContext(); +export interface RegistrationFormViewProps { + routeLogin: RouteDefinition; + running?: boolean; + operationError?: TranslatedString; + onSubmit(values: RegistrationValues): void | Promise<void>; +} - const errors = undefinedIfEmpty({ - name: !name ? i18n.str`The name is missing` : undefined, - username: !username - ? i18n.str`Missing username` - : !USERNAME_REGEX.test(username) - ? i18n.str`Use letters, numbers or any of these characters: - . _ ~` - : undefined, - password: !password - ? i18n.str`Missing password` - : password.length < 8 - ? i18n.str`The password should be longer than 8 characters` - : undefined, - repeatPassword: !repeatPassword - ? i18n.str`Missing password` - : repeatPassword !== password - ? i18n.str`The passwords do not match` - : undefined, - }); +RegistrationFormView.SCREEN_ID = TALER_SCREEN_ID; +export function RegistrationFormView({ + routeLogin, + running = false, + operationError, + onSubmit, +}: RegistrationFormViewProps): VNode { + const { i18n } = useTranslationContext(); + const [name, setName] = useState(""); + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [confirmation, setConfirmation] = useState(""); + const [submitted, setSubmitted] = useState(false); + const [touched, setTouched] = useState< + Partial<Record<RegistrationField, boolean>> + >({}); + const nameRef = useRef<HTMLInputElement | null>(null); + const usernameRef = useRef<HTMLInputElement | null>(null); + const passwordRef = useRef<HTMLInputElement | null>(null); + const confirmationRef = useRef<HTMLInputElement | null>(null); - const reg: TalerCorebankApi.RegisterAccountRequest | undefined = - !name || !username || !password || !!errors - ? undefined - : { - name, - username, - password, - }; + useEffect(() => { + nameRef.current?.focus({ preventScroll: true }); + }, []); - // i18n.str`register new account`, - const register = useNotifiedOperation< - Awaited<ReturnType<typeof api.createAccount>>, - [TalerCorebankApi.RegisterAccountRequest] - >( - (ct, account: TalerCorebankApi.RegisterAccountRequest) => - api.createAccount(undefined, account), - { - onSuccess: (success, acc) => { - setUsername(undefined); - setPassword(undefined); - setRepeatPassword(undefined); - setName(undefined); - onRegistrationSuccesful(acc.username, acc.password); - }, - 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 authorized 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 it 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 nameError = isBlankName(name) + ? i18n.str`Enter your full name.` + : undefined; + const usernameError = translateUsernameError( + validateUsername(username), + i18n, + ); + const passwordError = translatePasswordError( + validatePassword(password, true), + i18n, ); + const confirmationError = !confirmation + ? i18n.str`Repeat your password.` + : confirmation !== password + ? i18n.str`The passwords do not match.` + : undefined; - const registerRandom = () => { - const user = getRandomUsername(); + const markTouched = (field: RegistrationField): void => { + setTouched((current) => ({ ...current, [field]: true })); + }; - const password = "12345678"; - const username = `_${user.first}-${user.second}_`; - const name = `${capitalizeFirstLetter(user.first)} ${capitalizeFirstLetter( - user.second, - )}`; - return register.run({ name, username, password }); + const submit = async (event: Event): Promise<void> => { + event.preventDefault(); + if (running) return; + setSubmitted(true); + const invalidFields: Array< + [TranslatedString | undefined, RefObject<HTMLInputElement>] + > = [ + [nameError, nameRef], + [usernameError, usernameRef], + [passwordError, passwordRef], + [confirmationError, confirmationRef], + ]; + const firstInvalid = invalidFields.find(([error]) => !!error); + if (firstInvalid) { + firstInvalid[1].current?.focus(); + return; + } + await onSubmit({ name: name.trim(), username, password }); }; return ( - <Fragment> - <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> + <AuthCard + title={i18n.str`Create your bank account`} + subtitle={i18n.str`Choose the credentials you will use to sign in.`} + footer={ + <span> + <i18n.Translate>Already have an account?</i18n.Translate>{" "} + <a + href={routeLogin.url({})} + class="font-semibold text-primaryDark hover:text-onPrimaryContainer hover:underline" + > + <i18n.Translate>Sign in</i18n.Translate> + </a> + </span> + } + > + {operationError ? ( + <AuthAlert type="error">{operationError}</AuthAlert> + ) : undefined} + <form + class="space-y-5" + noValidate + onSubmit={(event) => void submit(event)} + autoCapitalize="none" + autoCorrect="off" + > + <div> + <label + htmlFor="register-name" + class="block text-sm font-medium leading-6 text-onBackground" + > + <i18n.Translate>Full name</i18n.Translate> + </label> + <div class="mt-2"> + <input + ref={nameRef} + type="text" + name="name" + id="register-name" + autocomplete="name" + enterkeyhint="next" + class="block w-full rounded-md border-0 py-1.5 text-onBackground shadow-sm ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-primary sm:text-sm sm:leading-6" + value={name} + required + aria-invalid={(submitted || !!touched.name) && !!nameError} + aria-describedby={ + (submitted || touched.name) && nameError + ? "register-name-error" + : undefined + } + onInput={(event) => setName(event.currentTarget.value)} + onBlur={() => markTouched("name")} + /> + <FieldError + id="register-name-error" + message={nameError} + visible={submitted || !!touched.name} + /> + </div> </div> - <div class="mt-10 sm:mx-auto sm:w-full sm:max-w-sm"> - <form - class="space-y-6" - noValidate - onSubmit={(e) => { - e.preventDefault(); - }} - autoCapitalize="none" - autoCorrect="off" + <div> + <label + htmlFor="register-username" + class="block text-sm font-medium leading-6 text-onBackground" > - <div> - <label - htmlFor="username" - class="block text-sm font-medium leading-6 text-gray-900" - > - <i18n.Translate>Login username</i18n.Translate> - <b class="text-[red]"> *</b> - </label> - <div class="mt-2"> - <input - ref={doAutoFocus} - type="text" - name="username" - id="username" - class="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" - value={username ?? ""} - enterkeyhint="next" - placeholder={i18n.str`account identification to login`} - autocomplete="username" - required - onInput={(e): void => { - setUsername(e.currentTarget.value); - }} - /> - <ShowInputErrorLabel - message={errors?.username} - isDirty={username !== undefined} - /> - </div> - </div> - - <div> - <div class="flex items-center justify-between"> - <label - htmlFor="password" - class="block text-sm font-medium leading-6 text-gray-900" - > - <i18n.Translate>Password</i18n.Translate> - <b class="text-[red]"> *</b> - </label> - </div> - <div class="mt-2"> - <input - type="password" - name="password" - id="password" - autocomplete="current-password" - class="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" - enterkeyhint="send" - value={password ?? ""} - placeholder={i18n.str`Password`} - required - onInput={(e): void => { - setPassword(e.currentTarget.value); - }} - /> - <ShowInputErrorLabel - message={errors?.password} - isDirty={password !== undefined} - /> - </div> - <p class="mt-2 text-sm text-gray-500"> - <i18n.Translate> - Use a strong password: 8 characters minimum, don't use any - public information related to you (names, birthday, phone - number, etc...) and mix lowercase, uppercase, symbols and - numbers - </i18n.Translate> - </p> - </div> + <i18n.Translate>Username</i18n.Translate> + </label> + <div class="mt-2"> + <input + ref={usernameRef} + type="text" + name="username" + id="register-username" + autocomplete="username" + enterkeyhint="next" + maxLength={USERNAME_MAX_LENGTH} + class="block w-full rounded-md border-0 py-1.5 text-onBackground shadow-sm ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-primary sm:text-sm sm:leading-6" + value={username} + required + aria-invalid={ + (submitted || !!touched.username) && !!usernameError + } + aria-describedby={ + (submitted || touched.username) && usernameError + ? "register-username-error" + : undefined + } + onInput={(event) => setUsername(event.currentTarget.value)} + onBlur={() => markTouched("username")} + /> + <FieldError + id="register-username-error" + message={usernameError} + visible={submitted || !!touched.username} + /> + </div> + </div> - <div> - <div class="flex items-center justify-between"> - <label - htmlFor="register-repeat" - class="block text-sm font-medium leading-6 text-gray-900" - > - <i18n.Translate>Repeat password</i18n.Translate> - <b class="text-[red]"> *</b> - </label> - </div> - <div class="mt-2"> - <input - type="password" - name="register-repeat" - id="register-repeat" - autocomplete="current-password" - class="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" - enterkeyhint="send" - value={repeatPassword ?? ""} - placeholder={i18n.str`Same password`} - required - onInput={(e): void => { - setRepeatPassword(e.currentTarget.value); - }} - /> - <ShowInputErrorLabel - message={errors?.repeatPassword} - isDirty={repeatPassword !== undefined} - /> - </div> - </div> + <PasswordField + id="register-password" + name="password" + label={i18n.str`Password`} + value={password} + error={passwordError} + showError={submitted || !!touched.password} + autoComplete="new-password" + inputRef={passwordRef} + enterKeyHint="next" + onInput={setPassword} + onBlur={() => markTouched("password")} + /> - <div> - <div class="flex items-center justify-between"> - <label - htmlFor="name" - class="block text-sm font-medium leading-6 text-gray-900" - > - <i18n.Translate>Full name</i18n.Translate> - <b class="text-[red]"> *</b> - </label> - </div> - <div class="mt-2"> - <input - type="text" - name="name" - id="name" - class="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" - value={name ?? ""} - enterkeyhint="next" - placeholder="John Doe" - autocomplete="name" - required - onInput={(e): void => { - setName(e.currentTarget.value); - }} - /> - </div> - </div> + <PasswordField + id="register-confirmation" + name="password-confirmation" + label={i18n.str`Confirm password`} + value={confirmation} + error={confirmationError} + showError={submitted || !!touched.confirmation} + autoComplete="new-password" + inputRef={confirmationRef} + enterKeyHint="done" + onInput={setConfirmation} + onBlur={() => markTouched("confirmation")} + /> - <div class="flex w-full justify-between"> - <a - name="cancel" - href={routeCancel.url({})} - class="ring-1 ring-gray-600 rounded-md bg-white disabled:bg-gray-300 px-3 py-1.5 text-sm font-semibold leading-6 text-black shadow-sm hover:bg-white-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2" - > - <i18n.Translate>Cancel</i18n.Translate> - </a> - <AsyncButton - submit - name="register" - class="rounded-md bg-indigo-600 disabled:bg-gray-300 px-3 py-1.5 text-sm font-semibold leading-6 text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" - disabled={!!errors || !reg} - onClick={() => register.run(reg!)} - > - <i18n.Translate>Register</i18n.Translate> - </AsyncButton> - </div> - </form> + <p class="text-sm text-gray-600"> + <i18n.Translate> + Use between 8 and 64 characters. A longer, unique password is + easier to keep secure. + </i18n.Translate> + </p> - {settings.allowRandomAccountCreation && ( - <p class="mt-10 text-center text-sm text-gray-500 border-t"> - <AsyncButton - 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> - </AsyncButton> - </p> + <button + type="submit" + class="flex w-full justify-center rounded-md bg-primary px-3 py-2 text-sm font-semibold text-onPrimary shadow-sm hover:bg-primaryHover hover:text-white focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary disabled:cursor-wait disabled:bg-gray-300" + disabled={running} + aria-busy={running} + > + {running ? ( + <i18n.Translate>Creating account…</i18n.Translate> + ) : ( + <i18n.Translate>Create account</i18n.Translate> )} - </div> - </div> - </Fragment> + </button> + </form> + </AuthCard> ); } -function capitalizeFirstLetter(str: string) { - return str.charAt(0).toUpperCase() + str.slice(1); +RegistrationPage.SCREEN_ID = TALER_SCREEN_ID; +export function RegistrationPage({ + routeLogin, + onAutoLoginSuccess, + onAutoLoginFailure, +}: { + routeLogin: RouteDefinition; + onAutoLoginSuccess(username: string): void; + onAutoLoginFailure(username: string): void; +}): VNode { + const { i18n } = useTranslationContext(); + const { + config, + lib: { bank: api }, + } = useBankCoreApiContext(); + const [registrationError, setRegistrationError] = + useState<TranslatedString>(); + const login = useLoginOperation({ + onSuccess: onAutoLoginSuccess, + onFailure: onAutoLoginFailure, + }); + + const registration = useAsyncAction< + Awaited<ReturnType<typeof api.createAccount>>, + [TalerCorebankApi.RegisterAccountRequest] + >((ct, account) => api.createAccount(undefined, account), { + onResult: async (result, account) => { + if (result.type === "ok") { + setRegistrationError(undefined); + await login.run(account.username, account.password, []); + return; + } + let message: TranslatedString; + switch (result.case) { + case HttpStatusCode.BadRequest: + message = i18n.str`Check the account details and try again.`; + break; + case HttpStatusCode.Unauthorized: + message = i18n.str`You are not authorized to create this account.`; + break; + case TalerErrorCode.BANK_UNALLOWED_DEBIT: + message = i18n.str`Registration is unavailable because the bank cannot grant the account credit.`; + break; + case TalerErrorCode.BANK_RESERVED_USERNAME_CONFLICT: + message = i18n.str`That username is reserved. Choose another username.`; + break; + case TalerErrorCode.BANK_REGISTER_USERNAME_REUSE: + message = i18n.str`That username is already taken.`; + break; + case TalerErrorCode.BANK_REGISTER_PAYTO_URI_REUSE: + message = i18n.str`That account ID is already taken.`; + break; + case TalerErrorCode.BANK_MISSING_TAN_INFO: + message = i18n.str`The bank is missing information for the selected authentication channel.`; + break; + case TalerErrorCode.BANK_TAN_CHANNEL_NOT_SUPPORTED: + message = i18n.str`The selected authentication channel is not supported.`; + break; + case TalerErrorCode.BANK_NON_ADMIN_PATCH_DEBT_LIMIT: + message = i18n.str`Only an administrator may set the debt limit.`; + break; + case TalerErrorCode.BANK_NON_ADMIN_SET_CONVERSION_RATE_CLASS: + message = i18n.str`Only an administrator may select a conversion rate class.`; + break; + case TalerErrorCode.BANK_CONVERSION_RATE_CLASS_UNKNOWN: + message = i18n.str`The selected conversion rate class does not exist.`; + break; + case TalerErrorCode.BANK_NON_ADMIN_SET_TAN_CHANNEL: + message = i18n.str`Only an administrator may enable two-factor authentication while creating an account.`; + break; + case TalerErrorCode.BANK_PASSWORD_TOO_SHORT: + message = i18n.str`The password must contain at least 8 characters.`; + break; + case TalerErrorCode.BANK_PASSWORD_TOO_LONG: + message = i18n.str`The password must not exceed ${PASSWORD_MAX_LENGTH} characters.`; + break; + default: + assertUnreachable(result); + } + setRegistrationError(message); + }, + onError: () => { + setRegistrationError( + i18n.str`Could not reach the bank. Check your connection and try again.`, + ); + }, + }); + + if (!config.allow_registrations) { + return ( + <AuthCard + title={i18n.str`Account registration is unavailable`} + subtitle={i18n.str`This bank is not accepting new account registrations.`} + footer={ + <a + href={routeLogin.url({})} + class="font-semibold text-primaryDark hover:text-onPrimaryContainer hover:underline" + > + <i18n.Translate>Return to sign in</i18n.Translate> + </a> + } + > + <AuthAlert type="warning"> + <i18n.Translate> + Contact the bank administrator if you need an account. + </i18n.Translate> + </AuthAlert> + </AuthCard> + ); + } + + return ( + <RegistrationFormView + routeLogin={routeLogin} + running={registration.running || login.running} + operationError={registrationError ?? login.error} + onSubmit={(values) => { + setRegistrationError(undefined); + login.clearError(); + return registration.run(values); + }} + /> + ); } diff --git a/packages/libeufin-bank-webui/src/pages/SolveMFA.tsx b/packages/libeufin-bank-webui/src/pages/SolveMFA.tsx @@ -16,6 +16,7 @@ import { ShowInputErrorLabel, Time, undefinedIfEmpty, + useAsyncAction, useBankCoreApiContext, useNotificationContext, useNotifiedOperation, @@ -24,6 +25,8 @@ import { import { ComponentChildren, Fragment, h, VNode } from "preact"; import { useCallback, useEffect, useRef, useState } from "preact/hooks"; import { useBankChallengeHandlerContext } from "../context/challenge.js"; +import { AuthAlert } from "./AuthForm.js"; +import { OperationError } from "../components/OperationError.js"; import { doAutoFocus } from "./PaytoWireTransferForm.js"; export interface Props { @@ -52,11 +55,12 @@ function SolveChallenge({ }): VNode { const { i18n } = useTranslationContext(); const [tanCode, setTanCode] = useState<string>(); + const [submitted, setSubmitted] = useState(false); + const [verificationError, setVerificationError] = + useState<TranslatedString>(); const { lib: { bank: api }, } = useBankCoreApiContext(); - const { showError } = useNotificationContext(); - const [showExpired, setExpired] = useState( expiration !== undefined && AbsoluteTime.isExpired(expiration), ); @@ -93,33 +97,48 @@ function SolveChallenge({ }; }, [expiration, handleTerminal, showExpired]); - // i18n.str`confirm MFA challenge`, - const doVerification = useNotifiedOperation< + const doVerification = useAsyncAction< Awaited<ReturnType<typeof api.confirmChallenge>>, [string] >( (ct, tan: string) => api.confirmChallenge(username, challenge.challenge_id, { tan }), { - onSuccess: onSolved, - onFail: showError(i18n.str`Failed to verify the code.`, (fail) => { - switch (fail.case) { + onResult: (result) => { + if (result.type === "ok") { + setVerificationError(undefined); + onSolved(); + return; + } + let message: TranslatedString; + switch (result.case) { case TalerErrorCode.BANK_TRANSACTION_NOT_FOUND: - return i18n.str`Unknown challenge.`; + message = i18n.str`Unknown challenge.`; + break; case HttpStatusCode.Unauthorized: - return i18n.str`Failed to validate the verification code.`; + message = i18n.str`Failed to validate the verification code.`; + break; case HttpStatusCode.TooManyRequests: - return i18n.str`Too many challenges are active right now, you must wait or confirm current challenges.`; + message = i18n.str`Too many challenges are active. Wait or confirm an existing challenge.`; + break; case TalerErrorCode.BANK_TAN_CHALLENGE_FAILED: - return i18n.str`Wrong authentication number.`; + message = i18n.str`The verification code is incorrect.`; + break; case TalerErrorCode.BANK_TAN_CHALLENGE_EXPIRED: setExpired(true); void handleTerminal(); - return i18n.str`Expired challenge.`; + message = i18n.str`The verification code has expired.`; + break; default: - assertUnreachable(fail); + assertUnreachable(result); } - }), + setVerificationError(message); + }, + onError: () => { + setVerificationError( + i18n.str`Could not reach the bank. Check your connection and try again.`, + ); + }, }, ); @@ -127,9 +146,9 @@ function SolveChallenge({ <Fragment> <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"> + <h2 class="text-base font-semibold leading-7 text-onBackground"> <span - class="text-sm text-black font-semibold leading-6 " + class="text-sm text-onBackground font-semibold leading-6 " id="dialog-title" > <i18n.Translate> @@ -159,21 +178,28 @@ function SolveChallenge({ </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-onBackground/5 sm:rounded-xl md:col-span-2"> <div class="px-4 mt-4 "> <form class="space-y-6" noValidate onSubmit={(e) => { e.preventDefault(); + setSubmitted(true); + if (!tanCode || showExpired || doVerification.running) return; + setVerificationError(undefined); + void doVerification.run(tanCode); }} autoCapitalize="none" autoCorrect="off" > + {verificationError ? ( + <AuthAlert type="error">{verificationError}</AuthAlert> + ) : undefined} <div> <label htmlFor={`tan-${challenge.challenge_id}`} - class="block text-sm font-medium leading-6 text-gray-900" + class="block text-sm font-medium leading-6 text-onBackground" > <i18n.Translate>Code</i18n.Translate> </label> @@ -183,70 +209,84 @@ function SolveChallenge({ type="text" name="authentication-code" id={`tan-${challenge.challenge_id}`} - class="block w-full disabled:bg-gray-200 rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" + class="block w-full disabled:bg-gray-200 rounded-md border-0 py-1.5 text-onBackground shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-primary sm:text-sm sm:leading-6" value={tanCode ?? ""} - enterkeyhint="next" + enterkeyhint="done" placeholder="T-12345678" autocomplete="one-time-code" title={i18n.str`Authentication code`} required + aria-invalid={ + (submitted || tanCode !== undefined) && !!errors + } + aria-describedby={ + (submitted || tanCode !== undefined) && errors + ? `tan-${challenge.challenge_id}-error` + : undefined + } onInput={(e): void => { setTanCode(e.currentTarget.value); }} /> - <ShowInputErrorLabel - message={errors?.code} - isDirty={tanCode !== undefined} - /> + <div id={`tan-${challenge.challenge_id}-error`}> + <ShowInputErrorLabel + message={errors?.code} + isDirty={submitted || tanCode !== undefined} + /> + </div> </div> </div> + {expiration.t_ms === "never" ? undefined : ( + <p class="text-gray-400 text-sm mt-2"> + <i18n.Translate> + It will expire at{" "} + <Time format="HH:mm" timestamp={expiration} /> + </i18n.Translate> + </p> + )} + {showExpired ? ( + <p class="text-sm"> + <i18n.Translate> + This challenge is terminal and cannot be retransmitted. + </i18n.Translate> + </p> + ) : undefined} + + {manualRestart && ( + <button + type="button" + class="mt-3 rounded-md bg-primary px-3 py-2 text-sm font-semibold text-onPrimary" + onClick={() => void handleTerminal(true)} + > + <i18n.Translate>Restart authentication</i18n.Translate> + </button> + )} + + <div class="mt-6 mb-4 flex justify-between"> + <button + type="button" + name="cancel" + class="text-sm font-semibold leading-6 text-onBackground" + onClick={onCancel} + > + <i18n.Translate>Back</i18n.Translate> + </button> + + <button + type="submit" + name="verify" + class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-primary px-3 py-2 text-sm font-semibold text-onPrimary shadow-sm hover:bg-primaryHover hover:text-white focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary" + disabled={showExpired || doVerification.running} + aria-busy={doVerification.running} + > + {doVerification.running ? ( + <i18n.Translate>Verifying…</i18n.Translate> + ) : ( + <i18n.Translate>Verify</i18n.Translate> + )} + </button> + </div> </form> - {expiration.t_ms === "never" ? undefined : ( - <p class="text-gray-400 text-sm mt-2"> - <i18n.Translate> - It will expire at{" "} - <Time format="HH:mm" timestamp={expiration} /> - </i18n.Translate> - </p> - )} - {showExpired ? ( - <p class="text-sm"> - <i18n.Translate> - This challenge is terminal and cannot be retransmitted. - </i18n.Translate> - </p> - ) : undefined} - - {manualRestart && ( - <button - type="button" - class="mt-3 rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white" - onClick={() => void handleTerminal(true)} - > - <i18n.Translate>Restart authentication</i18n.Translate> - </button> - )} - - <div class="mt-6 mb-4 flex justify-between"> - <button - type="button" - name="cancel" - class="text-sm font-semibold leading-6 text-gray-900" - onClick={onCancel} - > - <i18n.Translate>Back</i18n.Translate> - </button> - - <AsyncButton - 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" - disabled={!!errors || showExpired} - onClick={() => doVerification.run(tanCode!)} - > - <i18n.Translate>Verify</i18n.Translate> - </AsyncButton> - </div> </div> </div> </div> @@ -271,7 +311,7 @@ export function SolveChallengeDialog({ 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 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-onBackground/50"></div> <div 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 : ( @@ -441,9 +481,9 @@ function SolveMFAChallenges({ <Fragment> <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 px-0"> - <h2 class="text-base font-semibold leading-7 text-gray-900"> + <h2 class="text-base font-semibold leading-7 text-onBackground"> <span - class="text-sm text-black font-semibold leading-6 " + class="text-sm text-onBackground font-semibold leading-6 " id="dialog-title" > <i18n.Translate> @@ -460,19 +500,19 @@ function SolveMFAChallenges({ </p> </div> - <div class="bg-white shadow-sm ring-1 ring-gray-900/5 rounded-xl md:col-span-2"> + <div class="bg-white shadow-sm ring-1 ring-onBackground/5 rounded-xl md:col-span-2"> <div class="px-4 mt-4 "> <div class="w-full"> <div class="border-gray-100"> - <h2 class="text-base font-semibold leading-10 text-gray-900"> - <span class=" text-black font-semibold leading-6 "> + <h2 class="text-base font-semibold leading-10 text-onBackground"> + <span class=" text-onBackground font-semibold leading-6 "> {description} </span> </h2> </div> </div> - <h2 class="text-base leading-7 text-gray-900 "> + <h2 class="text-base leading-7 text-onBackground "> <span class="text-sm leading-6"> {currentChallenge.challenges.length === 1 ? ( <i18n.Translate> @@ -509,7 +549,7 @@ function SolveMFAChallenges({ > <dl class="divide-y divide-gray-100"> <div class="px-4 py-2 sm:grid sm:gap-4 sm:px-0"> - <dt class="text-sm font-medium leading-6 text-gray-900"> + <dt class="text-sm font-medium leading-6 text-onBackground"> {((ch: TanChannel): VNode => { switch (ch) { case TanChannel.SMS: @@ -532,7 +572,7 @@ function SolveMFAChallenges({ <div class="flex justify-between"> <AsyncButton name="cancel" - class="text-sm font-semibold leading-6 text-gray-900" + class="text-sm font-semibold leading-6 text-onBackground" disabled={noNeedToComplete} onClick={() => selectChallenge.run(challenge)} > @@ -542,7 +582,7 @@ function SolveMFAChallenges({ <AsyncButton 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" + class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-primary px-3 py-2 text-sm font-semibold text-onPrimary shadow-sm hover:bg-primaryHover hover:text-white focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary" disabled={alreadySent || noNeedToComplete} onClick={() => sendMessage.run(challenge)} > @@ -565,11 +605,12 @@ function SolveMFAChallenges({ ); })} + <OperationError class="mt-4" /> <div class="mt-6 mb-4 flex justify-between"> <button type="button" name="cancel" - class="text-sm font-semibold leading-6 text-gray-900" + class="text-sm font-semibold leading-6 text-onBackground" onClick={onCancel} > <i18n.Translate>Cancel</i18n.Translate> @@ -578,7 +619,7 @@ function SolveMFAChallenges({ <AsyncButton 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" + class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-primary px-3 py-2 text-sm font-semibold text-onPrimary shadow-sm hover:bg-primaryHover hover:text-white focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary" disabled={!hasSolvedEnough} onClick={() => onCompleted.run(solved)} > diff --git a/packages/libeufin-bank-webui/src/pages/WalletWithdrawForm.tsx b/packages/libeufin-bank-webui/src/pages/WalletWithdrawForm.tsx @@ -1,6 +1,6 @@ /* This file is part of GNU Taler - (C) 2022-2024 Taler Systems S.A. + (C) 2022-2026 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 @@ -25,7 +25,6 @@ import { assertUnreachable, } from "@gnu-taler/taler-util"; import { - Attention, AsyncButton, RenderAmount, RouteDefinition, @@ -39,107 +38,156 @@ import { VNode, h } from "preact"; import { forwardRef } from "preact/compat"; import { useState } from "preact/hooks"; import { useSettingsContext } from "../context/settings.js"; +import { OperationError } from "../components/OperationError.js"; import { useBankState } from "../hooks/bank-state.js"; import { usePreferences } from "../hooks/preferences.js"; import { useSessionState } from "../hooks/session.js"; import { undefinedIfEmpty } from "../utils.js"; import { OperationState } from "./OperationState/index.js"; import { InputAmount, doAutoFocus } from "./PaytoWireTransferForm.js"; +import { WithdrawalProgress } from "./WithdrawalProgress.js"; import { IntAmountJson } from "./regional/CreateCashout.js"; +import { + maximumWithdrawalAmount, + validateWithdrawalAmount, +} from "./withdrawal-amount.js"; const RefAmount = forwardRef(InputAmount); -export function isWithdrawalWithinLimit( - limit: IntAmountJson, - amount: AmountJson, -): boolean { - return !limit.negative && !limit.saturated && Amounts.cmp(limit, amount) >= 0; +function parseConfiguredAmount( + value: string | undefined, + currency: string, +): AmountJson | undefined { + if (!value) return undefined; + const parsed = Amounts.parse(value); + if (!parsed || parsed.currency.toUpperCase() !== currency.toUpperCase()) { + return undefined; + } + return parsed; } -function OldWithdrawalForm({ +export function WithdrawalAmountForm({ onOperationCreated, limit, - balance, - routeCancel, focus, }: { limit: IntAmountJson; - balance: AmountJson; focus?: boolean; onOperationCreated: (wopid: string) => void; - routeCancel: RouteDefinition; }): VNode { const { i18n } = useTranslationContext(); const settings = useSettingsContext(); - const [preference] = usePreferences(); - + const [preference, updatePreference] = usePreferences(); const [, updateBankState] = useBankState(); const { lib: { bank: api }, config, + url: backendUrl, } = useBankCoreApiContext(); - const { state: credentials } = useSessionState(); const creds = credentials.status !== "loggedIn" ? undefined : credentials; + const { showError, displayError } = useNotificationContext(); + + const wireFee = parseConfiguredAmount( + config.wire_transfer_fees, + limit.currency, + ); + const minimum = parseConfiguredAmount( + config.min_wire_transfer_amount, + limit.currency, + ); + const configuredMaximum = parseConfiguredAmount( + config.max_wire_transfer_amount, + limit.currency, + ); + const invalidConfiguration = + (!!config.wire_transfer_fees && !wireFee) || + (!!config.min_wire_transfer_amount && !minimum) || + (!!config.max_wire_transfer_amount && !configuredMaximum) || + (!!minimum && + !!configuredMaximum && + Amounts.cmp(minimum, configuredMaximum) > 0); + const fee = wireFee ?? Amounts.zeroOfCurrency(limit.currency); + const maximum = maximumWithdrawalAmount(limit, fee, configuredMaximum); + const hasAvailableAmount = + !Amounts.isZero(maximum) && + (!minimum || Amounts.cmp(maximum, minimum) >= 0); + const initialAmount = `${settings.defaultSuggestedAmount ?? 1}`; + const parsedInitialAmount = Amounts.parse( + `${limit.currency}:${initialAmount}`, + ); + const initialAmountIsValid = + parsedInitialAmount && + !validateWithdrawalAmount(parsedInitialAmount, minimum, maximum); const [amountStr, setAmountStr] = useState<string | undefined>( - `${settings.defaultSuggestedAmount ?? 1}`, + initialAmountIsValid ? initialAmount : undefined, ); - const { showError, displayError } = useNotificationContext(); + const [amountTouched, setAmountTouched] = useState(false); const trimmedAmountStr = amountStr?.trim(); - const parsedAmount = trimmedAmountStr ? Amounts.parse(`${limit.currency}:${trimmedAmountStr}`) : undefined; - - const errors = undefinedIfEmpty({ - amount: - trimmedAmountStr == null - ? i18n.str`Required` - : !parsedAmount - ? i18n.str`Invalid` - : !isWithdrawalWithinLimit(limit, parsedAmount) - ? i18n.str`Balance is not enough` - : undefined, - }); + const amountValidation = parsedAmount + ? validateWithdrawalAmount(parsedAmount, minimum, maximum) + : undefined; + const amountError = !amountTouched + ? undefined + : !trimmedAmountStr + ? i18n.str`Enter an amount.` + : !parsedAmount + ? i18n.str`Enter a valid amount.` + : amountValidation === "zero" + ? i18n.str`The amount must be greater than zero.` + : amountValidation === "below-minimum" + ? i18n.str`The amount is below the bank's minimum.` + : amountValidation === "above-maximum" + ? i18n.str`The amount exceeds what is available to withdraw.` + : undefined; + const errors = undefinedIfEmpty({ amount: amountError }); + const totalResult = parsedAmount ? Amounts.add(parsedAmount, fee) : undefined; + const totalDebit = + totalResult && !totalResult.saturated ? totalResult.amount : undefined; + const canSubmit = + !!creds && + !invalidConfiguration && + !!parsedAmount && + !amountValidation && + !!totalDebit; // i18n.str`create withdrawal`, const start = useNotifiedOperation< Awaited<ReturnType<typeof api.createWithdrawal>>, [UserAndToken, AmountString] >( - (ct, creds: UserAndToken, amount: AmountString) => - api.createWithdrawal( - creds, - preference.fastWithdrawalForm - ? { suggested_amount: amount } - : { amount: amount }, - ), + (ct, auth: UserAndToken, amount: AmountString) => + api.createWithdrawal(auth, { amount }), { onSuccess: (success) => { const uri = TalerUris.parse(success.taler_withdraw_uri); if (uri.tag === "error" || uri.value.type !== TalerUriAction.Withdraw) { // Translators: taler://withdraw is a protocol URI, not an email or phone address. - const invalidWithdrawUriTitle = i18n.str`The server replied with an invalid taler://withdraw URI`; + const title = i18n.str`The server replied with an invalid taler://withdraw URI`; // Translators: The placeholder is the malformed taler://withdraw URI returned by the bank. - const invalidWithdrawUriDetail = i18n.str`Withdraw URI: ${success.taler_withdraw_uri}`; - displayError(invalidWithdrawUriTitle, invalidWithdrawUriDetail); + const detail = i18n.str`Withdraw URI: ${success.taler_withdraw_uri}`; + displayError(title, detail); return; - } else { - updateBankState( - "currentWithdrawalOperationId", - uri.value.withdrawalOperationId, - ); - onOperationCreated(uri.value.withdrawalOperationId); } + updateBankState("activeWithdrawal", { + operationId: uri.value.withdrawalOperationId, + username: creds!.username, + backendBaseUrl: backendUrl.href, + confirmationDeferred: false, + }); + onOperationCreated(uri.value.withdrawalOperationId); }, 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`; + return i18n.str`Please sign in again.`; case HttpStatusCode.NotFound: return i18n.str`Account not found`; default: @@ -149,200 +197,276 @@ function OldWithdrawalForm({ }, ); + function submit(): Promise<void> | undefined { + setAmountTouched(true); + if (!canSubmit || !creds || !parsedAmount) return undefined; + return start.run(creds, Amounts.stringify(parsedAmount)); + } + return ( - <form - class="bg-white shadow-sm ring-1 ring-gray-900/5 sm:rounded-xl md:col-span-2 mt-4" - autoCapitalize="none" - autoCorrect="off" - onSubmit={(e) => { - e.preventDefault(); - }} + <section + class="mx-auto mt-6 max-w-3xl" + aria-labelledby="wallet-withdrawal-heading" > - <div class="px-4 py-6 "> - <div class="grid max-w-xs grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6"> - <div class="sm:col-span-5"> - <label for="withdraw-amount">{i18n.str`Amount`}</label> - <RefAmount - currency={limit.currency} - value={amountStr} - name="withdraw-amount" - onChange={(v) => { - setAmountStr(v); - }} - ref={focus ? doAutoFocus : undefined} - /> + <WithdrawalProgress current={1} /> + <h1 + id="wallet-withdrawal-heading" + class="mt-6 text-xl font-semibold text-brand" + > + <i18n.Translate>Withdraw to Taler Wallet</i18n.Translate> + </h1> + <p class="mt-2 text-sm leading-6 text-secondary"> + <i18n.Translate> + Choose an amount. Next, open your wallet and return here to review and + confirm the withdrawal. + </i18n.Translate> + </p> + + {invalidConfiguration ? ( + <div + class="mt-6 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800" + role="alert" + > + <div class="font-semibold"> + <i18n.Translate> + Unable to calculate the withdrawal amount + </i18n.Translate> </div> - <ShowInputErrorLabel - message={errors?.amount} - isDirty={amountStr !== undefined} - /> - </div> - <p class="mt-2 text-sm text-gray-500"> - <i18n.Translate> - Current balance is{" "} - <RenderAmount - value={balance} - spec={config.currency_specification} - /> - </i18n.Translate> - </p> - {Amounts.cmp(limit, balance) > 0 ? ( - <p class="mt-2 text-sm text-gray-900"> + <p class="mt-1"> <i18n.Translate> - You can withdraw up to{" "} - <RenderAmount - value={limit} - spec={config.currency_specification} - /> + The bank's withdrawal limits or fee are configured incorrectly. </i18n.Translate> </p> - ) : undefined} - <div class="mt-4"> - <div class="sm:inline"> - <button - type="button" - name="set 50" - class=" inline-flex px-6 py-4 text-sm items-center rounded-l-md bg-white text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:z-10" - onClick={(e) => { - e.preventDefault(); - setAmountStr("50.00"); - }} - > - 50.00 - </button> - <button - type="button" - name="set 25" - class=" -ml-px -mr-px inline-flex px-6 py-4 text-sm items-center rounded-r-md sm:rounded-none bg-white text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:z-10" - onClick={(e) => { - e.preventDefault(); - setAmountStr("25.00"); - }} - > - 25.00 - </button> - </div> - <div class="mt-4 sm:inline"> - <button - type="button" - name="set 10" - class=" -ml-px -mr-px inline-flex px-6 py-4 text-sm items-center rounded-l-md sm:rounded-none bg-white text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:z-10" - onClick={(e) => { - e.preventDefault(); - setAmountStr("10.00"); - }} - > - 10.00 - </button> - <button - type="button" - name="set 5" - class=" inline-flex px-6 py-4 text-sm items-center rounded-r-md bg-white text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:z-10" - onClick={(e) => { - e.preventDefault(); - setAmountStr("5.00"); - }} - > - 5.00 - </button> - </div> </div> - </div> - <div class="flex items-center justify-between gap-x-6 border-t border-gray-900/10 px-4 py-4 sm:px-8"> - <a - href={routeCancel.url({})} - name="cancel" - class="text-sm font-semibold leading-6 text-gray-900" - > - <i18n.Translate>Cancel</i18n.Translate> - </a> - <AsyncButton - submit - name="continue" - class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" - // disabled={isRawPayto ? !!errorsPayto : !!errorsWire} - disabled={!parsedAmount || !creds || !!errors} - onClick={() => start.run(creds!, Amounts.stringify(parsedAmount!))} + ) : ( + <form + class="mt-6 overflow-hidden rounded-xl border border-outlineVariant bg-white shadow-sm" + autoCapitalize="none" + autoCorrect="off" + onSubmit={(event) => { + event.preventDefault(); + void submit(); + }} > - <i18n.Translate>Continue</i18n.Translate> - </AsyncButton> - </div> - </form> + <div class="p-5 sm:p-6"> + <div class="rounded-lg bg-secondaryContainer px-4 py-4"> + <div class="text-sm font-medium text-secondary"> + <i18n.Translate>Available to withdraw</i18n.Translate> + </div> + <div class="mt-1 text-2xl font-semibold text-brand"> + <RenderAmount + value={maximum} + spec={config.currency_specification} + /> + </div> + </div> + + {!hasAvailableAmount ? ( + <div + class="mt-4 rounded-lg border border-warning/60 bg-warningContainer px-4 py-3 text-sm text-onWarningContainer" + role="status" + > + <i18n.Translate> + There is not enough available balance to cover a withdrawal + and the bank fee. + </i18n.Translate> + </div> + ) : undefined} + + <div class="mt-6 max-w-sm"> + <div + id="withdraw-amount-label" + class="text-sm font-medium text-onBackground" + > + <i18n.Translate>Amount to your Taler Wallet</i18n.Translate> + </div> + <RefAmount + currency={limit.currency} + value={amountStr} + name="withdraw-amount" + ariaLabelledby="withdraw-amount-label" + onChange={ + hasAvailableAmount + ? (value) => { + setAmountStr(value); + setAmountTouched(true); + } + : undefined + } + ref={focus ? doAutoFocus : undefined} + /> + <ShowInputErrorLabel + message={errors?.amount} + isDirty={amountTouched} + /> + {!Amounts.isZero(maximum) ? ( + <button + type="button" + class="mt-2 text-sm font-medium text-primaryDark hover:underline" + onClick={() => { + setAmountStr(Amounts.stringifyValue(maximum)); + setAmountTouched(true); + }} + > + <i18n.Translate>Use maximum available</i18n.Translate> + </button> + ) : undefined} + {minimum && !Amounts.isZero(minimum) ? ( + <p class="mt-2 text-xs text-secondary"> + <i18n.Translate>Minimum amount</i18n.Translate>:{" "} + <RenderAmount + value={minimum} + spec={config.currency_specification} + /> + </p> + ) : undefined} + </div> + + <dl class="mt-6 divide-y divide-onBackground/10 text-sm"> + <div class="flex items-center justify-between gap-4 py-3"> + <dt class="text-secondary"> + <i18n.Translate>Wallet receives</i18n.Translate> + </dt> + <dd class="font-medium text-onBackground"> + {parsedAmount ? ( + <RenderAmount + value={parsedAmount} + spec={config.currency_specification} + /> + ) : ( + <span aria-hidden="true">—</span> + )} + </dd> + </div> + <div class="flex items-center justify-between gap-4 py-3"> + <dt class="text-secondary"> + <i18n.Translate>Bank fee</i18n.Translate> + </dt> + <dd class="font-medium text-onBackground"> + {Amounts.isZero(fee) ? ( + <i18n.Translate>No fee</i18n.Translate> + ) : ( + <RenderAmount + value={fee} + spec={config.currency_specification} + /> + )} + </dd> + </div> + <div class="flex items-center justify-between gap-4 py-3 text-base"> + <dt class="font-semibold text-onBackground"> + <i18n.Translate>Total debited</i18n.Translate> + </dt> + <dd class="font-semibold text-onBackground"> + {totalDebit ? ( + <RenderAmount + value={totalDebit} + spec={config.currency_specification} + /> + ) : ( + <span aria-hidden="true">—</span> + )} + </dd> + </div> + </dl> + + <p class="mt-4 text-sm leading-6 text-secondary"> + <i18n.Translate> + No money moves until you review and confirm the withdrawal. + </i18n.Translate> + </p> + + {preference.showInstallWallet ? ( + <div class="mt-4 flex items-start justify-between gap-4 border-t border-onBackground/10 pt-4 text-sm text-secondary"> + <p> + <i18n.Translate>Need a Taler Wallet?</i18n.Translate>{" "} + <a + target="_blank" + name="wallet page" + rel="noreferrer noopener" + class="font-semibold text-primaryDark hover:underline" + href="https://taler.net/en/wallet.html" + > + <i18n.Translate>Get the wallet</i18n.Translate> + </a> + </p> + <button + type="button" + class="shrink-0 text-secondary hover:text-onBackground" + aria-label={i18n.str`Dismiss wallet installation hint`} + onClick={() => updatePreference("showInstallWallet", false)} + > + <span aria-hidden="true">×</span> + </button> + </div> + ) : undefined} + </div> + + <div class="border-t border-onBackground/10 bg-background px-5 py-4 sm:px-6"> + <OperationError class="mb-4" /> + <div class="flex justify-end"> + <AsyncButton + submit + name="continue" + class="w-full cursor-pointer rounded-md bg-primary px-4 py-2 text-sm font-semibold text-onPrimary shadow-sm hover:bg-primaryHover hover:text-white disabled:cursor-default disabled:opacity-50 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary sm:w-auto" + disabled={!canSubmit} + onClick={(event) => { + event.preventDefault(); + return submit(); + }} + > + <i18n.Translate>Continue to wallet</i18n.Translate> + </AsyncButton> + </div> + </div> + </form> + )} + </section> ); } export function WalletWithdrawForm({ focus, limit, - balance, routeCancel, onOperationCreated, onOperationAborted, }: { limit: IntAmountJson; - balance: AmountJson; focus?: boolean; - onOperationCreated: (wopid: string) => void; onOperationAborted: () => void; routeCancel: RouteDefinition; }): VNode { - const { i18n } = useTranslationContext(); - const [pref, updatePref] = usePreferences(); - - return ( - <div class="grid grid-cols-1 gap-x-8 gap-y-8 pt-6 md:grid-cols-3 bg-gray-100 my-4 px-4 pb-4 rounded-lg"> - <div class="px-4 sm:px-0"> - <h2 class="text-base font-semibold leading-7 text-gray-900"> - <i18n.Translate>Use your Taler wallet</i18n.Translate> - </h2> - <p class="mt-1 text-sm text-gray-500"> - <i18n.Translate> - After using your wallet you will need to authorize or cancel the - operation on this site. - </i18n.Translate> - </p> - </div> + const [preference] = usePreferences(); + const [bankState] = useBankState(); + const { state: credentials } = useSessionState(); + const { url: backendUrl } = useBankCoreApiContext(); + const activeWithdrawal = bankState.activeWithdrawal; + const activeOperationId = + activeWithdrawal && + credentials.status === "loggedIn" && + activeWithdrawal.username === credentials.username && + activeWithdrawal.backendBaseUrl === backendUrl.href + ? activeWithdrawal.operationId + : undefined; - <div class="col-span-2"> - {pref.showInstallWallet && ( - <Attention - title={i18n.str`You need a Taler wallet`} - onClose={() => { - updatePref("showInstallWallet", false); - }} - > - <i18n.Translate> - If you don't have one yet you can follow the instructions in - </i18n.Translate>{" "} - <a - target="_blank" - name="wallet page" - rel="noreferrer noopener" - class="font-semibold text-blue-700 hover:text-blue-600" - href="https://taler.net/en/wallet.html" - > - <i18n.Translate>this page</i18n.Translate> - </a> - </Attention> - )} + if (activeOperationId) { + return <div />; + } - {!pref.fastWithdrawalForm ? ( - <OldWithdrawalForm - focus={focus} - limit={limit} - balance={balance} - routeCancel={routeCancel} - onOperationCreated={onOperationCreated} - /> - ) : ( - <OperationState - focus={focus} - routeClose={routeCancel} - onAbort={onOperationAborted} - /> - )} - </div> - </div> + return preference.fastWithdrawalForm ? ( + <OperationState + focus={focus} + routeClose={routeCancel} + onAbort={onOperationAborted} + onOperationCreated={onOperationCreated} + /> + ) : ( + <WithdrawalAmountForm + focus={focus} + limit={limit} + onOperationCreated={onOperationCreated} + /> ); } diff --git a/packages/libeufin-bank-webui/src/pages/WalletWithdrawal.stories.tsx b/packages/libeufin-bank-webui/src/pages/WalletWithdrawal.stories.tsx @@ -0,0 +1,64 @@ +/* + This file is part of GNU Taler + (C) 2026 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. +*/ + +import * as tests from "@gnu-taler/web-util/testing"; +import { WithdrawalAmountForm } from "./WalletWithdrawForm.js"; + +export default { + title: "wallet withdrawal amount", +}; + +const defaultProps = { + limit: { + currency: "ASR", + value: 95, + fraction: 0, + negative: false, + saturated: false, + }, + onOperationCreated: () => undefined, +}; + +export const WithBankFee = tests.createExample( + WithdrawalAmountForm, + defaultProps, + { + loggedIn: true, + config: { wire_transfer_fees: "ASR:0.10" }, + }, +); + +export const WithConfiguredRange = tests.createExample( + WithdrawalAmountForm, + defaultProps, + { + loggedIn: true, + config: { + wire_transfer_fees: "ASR:0.10", + min_wire_transfer_amount: "ASR:5", + max_wire_transfer_amount: "ASR:25", + }, + }, +); + +export const FeeConsumesAvailableBalance = tests.createExample( + WithdrawalAmountForm, + { + ...defaultProps, + limit: { + ...defaultProps.limit, + value: 0, + fraction: 50000000, + }, + }, + { + loggedIn: true, + config: { wire_transfer_fees: "ASR:1" }, + }, +); diff --git a/packages/libeufin-bank-webui/src/pages/WalletWithdrawal.tsx b/packages/libeufin-bank-webui/src/pages/WalletWithdrawal.tsx @@ -0,0 +1,94 @@ +/* + This file is part of GNU Taler + (C) 2026 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. +*/ + +import { + Amounts, + HttpStatusCode, + TalerError, + assertUnreachable, +} from "@gnu-taler/taler-util"; +import { + Attention, + Loading, + RouteDefinition, + useTranslationContext, +} from "@gnu-taler/web-util/browser"; +import { VNode, h } from "preact"; +import { RetryableError } from "../components/RetryableError.js"; +import { + revalidateAccountDetails, + useAccountDetails, +} from "../hooks/account.js"; +import { IntAmounts } from "./regional/CreateCashout.js"; +import { WalletWithdrawForm } from "./WalletWithdrawForm.js"; + +export function WalletWithdrawal({ + account, + routeCancel, + onOperationCreated, +}: { + account: string; + routeCancel: RouteDefinition; + onOperationCreated(operationId: string): void; +}): VNode { + const { i18n } = useTranslationContext(); + const result = useAccountDetails(account); + if (!result) return <Loading />; + if (result instanceof TalerError) { + return ( + <RetryableError + error={result} + title={i18n.str`Failed to load account details.`} + onRetry={() => void revalidateAccountDetails()} + /> + ); + } + if (result.type === "fail") { + switch (result.case) { + case HttpStatusCode.Unauthorized: + case HttpStatusCode.NotFound: + return ( + <Attention + type="danger" + title={i18n.str`Unable to start a withdrawal.`} + > + <i18n.Translate>Please sign in again.</i18n.Translate> + </Attention> + ); + default: + assertUnreachable(result); + } + } + const amount = Amounts.parseOrThrow(result.body.balance.amount); + const isDebit = result.body.balance.credit_debit_indicator === "debit"; + const signed = IntAmounts.toIntAmount(amount, isDebit); + const limit = signed.increment( + Amounts.parseOrThrow(result.body.debit_threshold), + ).result; + + return ( + <section class="mt-2"> + <a + href={routeCancel.url({})} + class="inline-flex items-center text-sm font-semibold text-primaryDark hover:underline" + > + <span class="mr-1" aria-hidden="true"> + ← + </span> + <i18n.Translate>Back to overview</i18n.Translate> + </a> + <WalletWithdrawForm + limit={limit} + onOperationCreated={onOperationCreated} + onOperationAborted={() => undefined} + routeCancel={routeCancel} + /> + </section> + ); +} diff --git a/packages/libeufin-bank-webui/src/pages/WireTransfer.tsx b/packages/libeufin-bank-webui/src/pages/WireTransfer.tsx @@ -86,27 +86,37 @@ export function WireTransfer({ const balance = IntAmounts.toIntAmount(balanceAbs, isBalanceNegative); const limit = balance.increment(debitThreshold).result; - const positiveBalance = balance.getResultZeroIfNegative(); - return ( - <div class="px-4 mt-8"> - <div class="sm:flex sm:items-center mb-4"> - <div class="sm:flex-auto"> - <h1 class="text-base font-semibold leading-6 text-gray-900"> - <i18n.Translate>Make a wire transfer</i18n.Translate> - </h1> - </div> + <section class="mt-2"> + {routeCancel ? ( + <a + href={routeCancel.url({})} + class="inline-flex items-center text-sm font-semibold text-brand hover:underline" + > + <span class="mr-1" aria-hidden="true"> + ← + </span> + <i18n.Translate>Back to overview</i18n.Translate> + </a> + ) : undefined} + <div class="mx-auto mt-6 max-w-3xl"> + <h1 class="text-2xl font-semibold text-brand"> + <i18n.Translate>Send bank transfer</i18n.Translate> + </h1> + <p class="mt-2 text-sm text-gray-600"> + <i18n.Translate> + Send money from this account to another bank account. + </i18n.Translate> + </p> + <PaytoWireTransferForm + withAccount={toAccount} + withAmount={withAmount} + withSubject={withSubject} + limit={limit} + onSuccess={onSuccess} + routeCancel={routeCancel} + /> </div> - - <PaytoWireTransferForm - withAccount={toAccount} - withAmount={withAmount} - balance={positiveBalance} - withSubject={withSubject} - limit={limit} - onSuccess={onSuccess} - routeCancel={routeCancel} - /> - </div> + </section> ); } diff --git a/packages/libeufin-bank-webui/src/pages/WithdrawalOperationPage.tsx b/packages/libeufin-bank-webui/src/pages/WithdrawalOperationPage.tsx @@ -22,20 +22,38 @@ import { WithdrawalOperation } from "./OperationState/index.js"; export function WithdrawalOperationPage({ operationId, onOperationAborted, + onContinueLater, routeClose, }: { operationId: string; onOperationAborted: () => void; + onContinueLater?: () => void; routeClose: RouteDefinition; }): VNode { - const [, updateBankState] = useBankState(); + const [bankState, updateBankState] = useBankState(); return ( <WithdrawalOperation operationId={operationId} + clearWhenTerminal onAbort={() => { - updateBankState("currentWithdrawalOperationId", undefined); + if (bankState.activeWithdrawal?.operationId === operationId) { + updateBankState("activeWithdrawal", undefined); + } onOperationAborted(); }} + onContinueLater={ + onContinueLater + ? () => { + if (bankState.activeWithdrawal?.operationId === operationId) { + updateBankState("activeWithdrawal", { + ...bankState.activeWithdrawal, + confirmationDeferred: true, + }); + } + onContinueLater(); + } + : undefined + } routeClose={routeClose} /> ); diff --git a/packages/libeufin-bank-webui/src/pages/WithdrawalProgress.tsx b/packages/libeufin-bank-webui/src/pages/WithdrawalProgress.tsx @@ -0,0 +1,78 @@ +/* + This file is part of GNU Taler + (C) 2026 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. +*/ + +import { useTranslationContext } from "@gnu-taler/web-util/browser"; +import { VNode, h } from "preact"; + +export type WithdrawalProgressMode = "amount-first" | "wallet-first"; + +export function WithdrawalProgress({ + current, + mode = "amount-first", +}: { + current: 1 | 2 | 3; + mode?: WithdrawalProgressMode; +}): VNode { + const { i18n } = useTranslationContext(); + const amount = i18n.str`Choose amount`; + const wallet = i18n.str`Open wallet`; + const steps = + mode === "amount-first" + ? [amount, wallet, i18n.str`Review`] + : [wallet, amount, i18n.str`Review`]; + + return ( + <nav aria-label={i18n.str`Withdrawal progress`}> + <ol class="grid grid-cols-3 text-xs sm:text-sm"> + {steps.map((label, index) => { + const number = index + 1; + const active = number === current; + const complete = number < current; + return ( + <li + key={label} + class="relative flex min-w-0 flex-col items-center px-1 text-center" + aria-current={active ? "step" : undefined} + > + {index > 0 ? ( + <span + class={`absolute right-1/2 top-4 h-px w-full -translate-y-1/2 ${number <= current ? "bg-primary/60" : "bg-outlineVariant"}`} + aria-hidden="true" + /> + ) : undefined} + <span + class={`relative z-10 flex h-8 w-8 items-center justify-center rounded-full border text-sm font-semibold ${ + active + ? "border-primary bg-primary text-onPrimary" + : complete + ? "border-brand bg-brand text-onBrand" + : "border-outlineVariant bg-white text-secondary" + }`} + aria-hidden="true" + > + {complete ? "✓" : number} + </span> + <span + class={`mt-2 leading-5 ${ + active + ? "font-semibold text-onBackground" + : complete + ? "font-medium text-onBackground" + : "text-secondary" + }`} + > + {label} + </span> + </li> + ); + })} + </ol> + </nav> + ); +} diff --git a/packages/libeufin-bank-webui/src/pages/account/CashoutListForAccount.tsx b/packages/libeufin-bank-webui/src/pages/account/CashoutListForAccount.tsx @@ -13,73 +13,51 @@ You should have received a copy of the GNU General Public License along with GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ -import { useTranslationContext } from "@gnu-taler/web-util/browser"; +import { + RouteDefinition, + useTranslationContext, +} from "@gnu-taler/web-util/browser"; +import { TranslatedString } from "@gnu-taler/taler-util"; import { Fragment, VNode, h } from "preact"; import { Cashouts } from "../../components/Cashouts/index.js"; -import { useSessionState } from "../../hooks/session.js"; -import { ProfileNavigation } from "../ProfileNavigation.js"; -import { CreateCashout } from "../regional/CreateCashout.js"; -import { RouteDefinition } from "@gnu-taler/web-util/browser"; interface Props { account: string; - routeClose: RouteDefinition; - - onCashout: () => void; routeCashoutDetails: RouteDefinition<{ cid: string }>; - routeMyAccountDetails: RouteDefinition; - routeMyAccountDelete: RouteDefinition; - routeMyAccountPassword: RouteDefinition; - routeMyAccountCashout: RouteDefinition; - routeConversionConfig: RouteDefinition; + routeBack: RouteDefinition; + backLabel: TranslatedString; } export function CashoutListForAccount({ account, - - onCashout, routeCashoutDetails, - routeMyAccountCashout, - routeMyAccountDelete, - routeMyAccountDetails, - routeConversionConfig, - routeMyAccountPassword, - routeClose, + routeBack, + backLabel, }: Props): VNode { const { i18n } = useTranslationContext(); - const { state: credentials } = useSessionState(); - - const accountIsTheCurrentUser = - credentials.status === "loggedIn" - ? credentials.username === account - : false; - return ( <Fragment> - {accountIsTheCurrentUser ? ( - <ProfileNavigation - current="cashouts" - routeMyAccountCashout={routeMyAccountCashout} - routeMyAccountDelete={routeMyAccountDelete} - routeMyAccountDetails={routeMyAccountDetails} - routeMyAccountPassword={routeMyAccountPassword} - routeConversionConfig={routeConversionConfig} - /> - ) : ( - <h1 class="text-base font-semibold leading-6 text-gray-900"> - <i18n.Translate>Cashout for account {account}</i18n.Translate> + <a + href={routeBack.url({})} + class="inline-flex items-center text-sm font-semibold text-brand hover:underline" + > + <span class="mr-1" aria-hidden="true"> + ← + </span> + {backLabel} + </a> + <div class="mt-6"> + <h1 class="text-2xl font-semibold text-brand"> + <i18n.Translate>Cashout history</i18n.Translate> </h1> - )} - - <CreateCashout - focus - routeClose={routeClose} - onCashout={onCashout} - account={account} - /> - - <Cashouts account={account} routeCashoutDetails={routeCashoutDetails} /> + <p class="mt-2 text-sm text-gray-600"> + <i18n.Translate> + Review previous transfers to your configured cashout account. + </i18n.Translate> + </p> + <Cashouts account={account} routeCashoutDetails={routeCashoutDetails} /> + </div> </Fragment> ); } diff --git a/packages/libeufin-bank-webui/src/pages/account/MerchantIntegration.stories.tsx b/packages/libeufin-bank-webui/src/pages/account/MerchantIntegration.stories.tsx @@ -0,0 +1,50 @@ +/* + This file is part of GNU Taler + (C) 2026 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. +*/ + +import { urlPattern } from "@gnu-taler/web-util/browser"; +import * as tests from "@gnu-taler/web-util/testing"; +import { MerchantIntegrationView } from "./MerchantIntegration.js"; + +export default { + title: "merchant integration", +}; + +const routeMyAccountDetails = urlPattern( + /^\/my-profile$/, + () => "#/my-profile", +); +const routeMyAccountMerchantIntegration = urlPattern( + /^\/my-merchant-integration$/, + () => "#/my-merchant-integration", +); +const routeMyAccountPassword = urlPattern( + /^\/my-password$/, + () => "#/my-password", +); +const routeOverview = urlPattern(/^\/account$/, () => "#/account"); + +export const TalerBankAccount = tests.createExample(MerchantIntegrationView, { + account: "alice", + accountData: { + name: "Alice Example", + balance: { + amount: "ASR:42", + credit_debit_indicator: "credit", + }, + payto_uri: "payto://x-taler-bank/bank.example/alice", + debit_threshold: "ASR:10", + is_public: false, + is_taler_exchange: false, + status: "active", + } as any, + routeOverview, + routeMyAccountDetails, + routeMyAccountMerchantIntegration, + routeMyAccountPassword, +}); diff --git a/packages/libeufin-bank-webui/src/pages/account/MerchantIntegration.tsx b/packages/libeufin-bank-webui/src/pages/account/MerchantIntegration.tsx @@ -0,0 +1,220 @@ +/* + This file is part of GNU Taler + (C) 2026 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. +*/ + +import { + HttpStatusCode, + Paytos, + TalerCorebankApi, + TalerError, + assertUnreachable, +} from "@gnu-taler/taler-util"; +import { + Attention, + Loading, + RouteDefinition, + useBankCoreApiContext, + useTranslationContext, +} from "@gnu-taler/web-util/browser"; +import { Fragment, VNode, h } from "preact"; +import { RetryableError } from "../../components/RetryableError.js"; +import { + revalidateAccountDetails, + useAccountDetails, +} from "../../hooks/account.js"; +import { LoginForm } from "../LoginForm.js"; +import { ProfileNavigation } from "../ProfileNavigation.js"; +import { ReadOnlyDetail, ReadOnlyDetails } from "./ReadOnlyDetails.js"; + +export interface MerchantIntegrationRoutes { + routeOverview: RouteDefinition; + routeMyAccountDetails: RouteDefinition; + routeMyAccountMerchantIntegration: RouteDefinition; + routeMyAccountPassword: RouteDefinition; +} + +export function MerchantIntegration({ + account, + routeMyAccountDetails, + routeMyAccountMerchantIntegration, + routeMyAccountPassword, + routeOverview, +}: MerchantIntegrationRoutes & { account: string }): VNode { + const { i18n } = useTranslationContext(); + const result = useAccountDetails(account); + + if (!result) { + return <Loading />; + } + if (result instanceof TalerError) { + return ( + <Fragment> + <RetryableError + error={result} + title={i18n.str`Failed to load account details.`} + onRetry={() => void revalidateAccountDetails()} + /> + <LoginForm currentUser={account} /> + </Fragment> + ); + } + if (result.type === "fail") { + switch (result.case) { + case HttpStatusCode.Unauthorized: + case HttpStatusCode.NotFound: + return <LoginForm currentUser={account} />; + default: + assertUnreachable(result); + } + } + + return ( + <MerchantIntegrationView + account={account} + accountData={result.body} + routeOverview={routeOverview} + routeMyAccountDetails={routeMyAccountDetails} + routeMyAccountMerchantIntegration={routeMyAccountMerchantIntegration} + routeMyAccountPassword={routeMyAccountPassword} + /> + ); +} + +export function MerchantIntegrationView({ + account, + accountData, + routeMyAccountDetails, + routeMyAccountMerchantIntegration, + routeMyAccountPassword, + routeOverview, +}: MerchantIntegrationRoutes & { + account: string; + accountData: TalerCorebankApi.AccountData; +}): VNode { + const { i18n } = useTranslationContext(); + const { + lib: { bank }, + } = useBankCoreApiContext(); + const parsedPayto = Paytos.fromString(accountData.payto_uri); + const payto = + parsedPayto.tag === "error" || !parsedPayto.value.targetType + ? undefined + : parsedPayto.value; + const revenueURL = bank.getRevenueAPI(account); + revenueURL.username = account; + + return ( + <Fragment> + <ProfileNavigation + current="merchant-integration" + routeOverview={routeOverview} + routeMyAccountDetails={routeMyAccountDetails} + routeMyAccountMerchantIntegration={routeMyAccountMerchantIntegration} + routeMyAccountPassword={routeMyAccountPassword} + /> + + <div class="grid grid-cols-1 gap-x-8 gap-y-8 rounded-lg bg-gray-100 px-4 pb-4 pt-6 md:grid-cols-3"> + <div class="px-4 sm:px-0"> + <h2 class="text-base font-semibold leading-7 text-onBackground"> + <i18n.Translate>Merchant integration</i18n.Translate> + </h2> + <p class="mt-2 text-sm text-gray-500"> + <i18n.Translate> + Use this information to link your Taler Merchant Backoffice + account with the current bank account. You can start by copying + the values, then go to your merchant backoffice service provider, + login into your account and look for the "import" button in the + "bank account" section. + </i18n.Translate> + </p> + </div> + + {accountData.is_taler_exchange || account === "admin" || !payto ? ( + <div class="md:col-span-2"> + <Attention title={i18n.str`Not available`} type="info"> + <i18n.Translate> + Merchant integration is not available for this account. + </i18n.Translate> + </Attention> + </div> + ) : ( + <ReadOnlyDetails> + <ReadOnlyDetail + label={i18n.str`Account type`} + value={payto.targetType} + description={i18n.str`Method to use for wire transfer.`} + /> + {(() => { + switch (payto.targetType) { + case "iban": + return ( + <ReadOnlyDetail + label={i18n.str`IBAN`} + value={payto.iban} + copyValue={payto.iban} + description={i18n.str`International Bank Account Number.`} + /> + ); + case "x-taler-bank": + return ( + <Fragment> + <ReadOnlyDetail + label={i18n.str`Bank host`} + value={payto.host} + copyValue={payto.host} + description={i18n.str`Bank host where the service is located.`} + /> + <ReadOnlyDetail + label={i18n.str`Account name`} + value={payto.account} + copyValue={payto.account} + description={i18n.str`Bank account identifier for wire transfers.`} + /> + </Fragment> + ); + case "bitcoin": + return ( + <ReadOnlyDetail + label={i18n.str`Address`} + value={payto.address} + copyValue={payto.address} + description={i18n.str`Bitcoin address for this account.`} + /> + ); + default: + return ( + <ReadOnlyDetail + label={i18n.str`Account address`} + value={accountData.payto_uri} + copyValue={accountData.payto_uri} + /> + ); + } + })()} + <ReadOnlyDetail + label={i18n.str`Owner's name`} + value={accountData.name} + copyValue={accountData.name} + description={i18n.str`Legal name of the person holding the account.`} + /> + <ReadOnlyDetail + label={i18n.str`Account info URL`} + value={revenueURL.href} + copyValue={revenueURL.href} + description={i18n.str`From where the merchant can download information about incoming wire transfers to this account.`} + /> + </ReadOnlyDetails> + )} + </div> + </Fragment> + ); +} diff --git a/packages/libeufin-bank-webui/src/pages/account/ReadOnlyDetails.tsx b/packages/libeufin-bank-webui/src/pages/account/ReadOnlyDetails.tsx @@ -0,0 +1,64 @@ +/* + This file is part of GNU Taler + (C) 2026 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. +*/ + +import { CopyButton, useTranslationContext } from "@gnu-taler/web-util/browser"; +import { ComponentChildren, VNode, h } from "preact"; + +export function ReadOnlyDetails({ + children, +}: { + children: ComponentChildren; +}): VNode { + return ( + <div class="overflow-hidden bg-white shadow-sm ring-1 ring-onBackground/5 sm:rounded-xl md:col-span-2"> + <dl class="divide-y divide-onBackground/10">{children}</dl> + </div> + ); +} + +export function ReadOnlyDetail({ + label, + value, + copyValue, + description, +}: { + label: string; + value: ComponentChildren; + copyValue?: string; + description?: ComponentChildren; +}): VNode { + const { i18n } = useTranslationContext(); + return ( + <div class="px-4 py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-8"> + <dt class="text-sm font-medium text-onBackground">{label}</dt> + <dd class="mt-1 min-w-0 text-sm text-secondary sm:col-span-2 sm:mt-0"> + <div class="flex min-w-0 items-start justify-between gap-3"> + <span class="min-w-0 break-all text-onBackground">{value}</span> + {copyValue === undefined ? undefined : ( + <CopyButton + class="inline-flex shrink-0 items-center gap-1 rounded-md p-2 text-secondary hover:bg-primary/10 hover:text-primaryDark focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary" + getContent={() => copyValue} + > + <span class="sr-only"> + <i18n.Translate>Copy</i18n.Translate> + </span> + </CopyButton> + )} + </div> + {description === undefined ? undefined : ( + <p class="mt-1 text-gray-500">{description}</p> + )} + </dd> + </div> + ); +} diff --git a/packages/libeufin-bank-webui/src/pages/account/ShowAccountDetails.tsx b/packages/libeufin-bank-webui/src/pages/account/ShowAccountDetails.tsx @@ -15,6 +15,7 @@ */ import { AccessToken, + Amounts, HttpStatusCode, TalerCorebankApi, TalerError, @@ -24,8 +25,8 @@ import { import { Attention, AsyncButton, - CopyButton, Loading, + RenderAmount, RouteDefinition, useBankCoreApiContext, useNotificationContext, @@ -35,7 +36,6 @@ import { 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 { revalidateAccountDetails, @@ -46,24 +46,24 @@ import { AccountForm } from "../admin/AccountForm.js"; import { LoginForm } from "../LoginForm.js"; import { ProfileNavigation } from "../ProfileNavigation.js"; import { RetryableError } from "../../components/RetryableError.js"; +import { OperationError } from "../../components/OperationError.js"; +import { ReadOnlyDetail, ReadOnlyDetails } from "./ReadOnlyDetails.js"; export function ShowAccountDetails({ account, routeClose, onUpdateSuccess, - routeMyAccountCashout, routeMyAccountDelete, routeMyAccountDetails, + routeMyAccountMerchantIntegration, routeMyAccountPassword, - routeConversionConfig, }: { routeClose: RouteDefinition; routeMyAccountDetails: RouteDefinition; routeMyAccountDelete: RouteDefinition; + routeMyAccountMerchantIntegration: RouteDefinition; routeMyAccountPassword: RouteDefinition; - routeMyAccountCashout: RouteDefinition; - routeConversionConfig: RouteDefinition; onUpdateSuccess: () => void; account: string; @@ -73,6 +73,7 @@ export function ShowAccountDetails({ const sessionToken = credentials.status !== "loggedIn" ? undefined : credentials.token; const { + config, lib: { bank }, } = useBankCoreApiContext(); const accountIsTheCurrentUser = @@ -185,27 +186,23 @@ export function ShowAccountDetails({ } } - const url = bank.getRevenueAPI(account); - const baseURL = url.href; - const revenueURL = new URL(baseURL); - revenueURL.username = account; - const ac = Paytos.fromString(result.body.payto_uri); - const payto = - ac.tag === "error" || !ac.value.targetType ? undefined : ac.value; + const userIsAdmin = + credentials.status === "loggedIn" && credentials.isUserAdministrator; + const nameIsEditable = config.allow_edit_name || userIsAdmin; + const cashoutIsEditable = config.allow_edit_cashout_payto_uri || userIsAdmin; return ( <Fragment> {accountIsTheCurrentUser ? ( <ProfileNavigation current="details" - routeMyAccountCashout={routeMyAccountCashout} - routeMyAccountDelete={routeMyAccountDelete} - routeConversionConfig={routeConversionConfig} + routeOverview={routeClose} routeMyAccountDetails={routeMyAccountDetails} + routeMyAccountMerchantIntegration={routeMyAccountMerchantIntegration} routeMyAccountPassword={routeMyAccountPassword} /> ) : ( - <h1 class="text-base font-semibold leading-6 text-gray-900"> + <h1 class="text-base font-semibold leading-6 text-onBackground"> <i18n.Translate>Account "{account}"</i18n.Translate> </h1> )} @@ -216,12 +213,53 @@ export function ShowAccountDetails({ </Attention> )} + <div class="my-4 grid grid-cols-1 gap-x-8 gap-y-8 rounded-lg bg-gray-100 px-4 pb-4 pt-6 md:grid-cols-3"> + <div class="px-4 sm:px-0"> + <h2 class="text-base font-semibold leading-7 text-onBackground"> + <i18n.Translate>Account details</i18n.Translate> + </h2> + </div> + <ReadOnlyDetails> + <ReadOnlyDetail label={i18n.str`Login username`} value={account} /> + {nameIsEditable ? undefined : ( + <ReadOnlyDetail + label={i18n.str`Full name`} + value={result.body.name} + /> + )} + <ReadOnlyDetail + label={i18n.str`Account address`} + value={result.body.payto_uri} + copyValue={result.body.payto_uri} + description={i18n.str`Copy and share this account address to receive transfers.`} + /> + {!config.allow_conversion || cashoutIsEditable ? undefined : ( + <ReadOnlyDetail + label={i18n.str`Cashout account`} + value={result.body.cashout_payto_uri ?? i18n.str`Not configured`} + copyValue={result.body.cashout_payto_uri} + /> + )} + {userIsAdmin ? undefined : ( + <ReadOnlyDetail + label={i18n.str`Max debt`} + value={ + <RenderAmount + value={Amounts.parseOrThrow(result.body.debit_threshold)} + spec={config.currency_specification} + /> + } + /> + )} + </ReadOnlyDetails> + </div> + <div class="grid grid-cols-1 gap-x-8 gap-y-8 pt-6 md:grid-cols-3 bg-gray-100 my-4 px-4 pb-4 rounded-lg"> <div class="px-4 sm:px-0"> - <h2 class="text-base font-semibold leading-7 text-gray-900"> + <h2 class="text-base font-semibold leading-7 text-onBackground"> <div class="flex items-center justify-between"> <span class="flex flex-grow flex-col"> - <span class="text-sm text-black font-semibold leading-6 "> + <span class="text-sm text-onBackground font-semibold leading-6 "> <i18n.Translate>Change details</i18n.Translate> </span> </span> @@ -234,301 +272,55 @@ export function ShowAccountDetails({ username={account} template={result.body} purpose="update" + hideNonEditableFields onChange={(a) => setSubmitAccount(a)} > - <div class="flex items-center justify-between gap-x-6 border-t border-gray-900/10 px-4 py-4 sm:px-8"> - <a - href={routeClose.url({})} - name="cancel" - class="text-sm font-semibold leading-6 text-gray-900" - > - <i18n.Translate>Cancel</i18n.Translate> - </a> - <AsyncButton - submit - name="update" - class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" - disabled={!sessionToken || !submitAccount} - onClick={() => update.run(account, sessionToken!, submitAccount!)} - > - <i18n.Translate>Update</i18n.Translate> - </AsyncButton> + <div class="border-t border-onBackground/10 px-4 py-4 sm:px-8"> + <OperationError class="mb-4" /> + <div class="flex items-center justify-between gap-x-6"> + <a + href={routeClose.url({})} + name="cancel" + class="text-sm font-semibold leading-6 text-onBackground" + > + <i18n.Translate>Cancel</i18n.Translate> + </a> + <AsyncButton + submit + name="update" + class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-primary px-3 py-2 text-sm font-semibold text-onPrimary shadow-sm hover:bg-primaryHover hover:text-white focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary" + disabled={!sessionToken || !submitAccount} + onClick={() => + update.run(account, sessionToken!, submitAccount!) + } + > + <i18n.Translate>Update</i18n.Translate> + </AsyncButton> + </div> </div> </AccountForm> </div> - {result.body.is_taler_exchange || account === "admin" ? undefined : ( - <div class="grid grid-cols-1 gap-x-8 gap-y-8 pt-6 md:grid-cols-3 bg-gray-100 my-4 px-4 pb-4 rounded-lg"> - <div class="px-4 sm:px-0"> - <h2 class="text-base font-semibold leading-7 text-gray-900"> - <div class="flex items-center justify-between"> - <span class="flex flex-grow flex-col"> - <span class="text-sm text-black font-semibold leading-6 "> - <i18n.Translate>Merchant integration</i18n.Translate> - </span> - </span> - </div> - </h2> - <p class="mt-2 text-sm text-gray-500"> - <i18n.Translate> - Use this information to link your Taler Merchant Backoffice - account with the current bank account. You can start by copying - the values, then go to your merchant backoffice service - provider, login into your account and look for the "import" - button in the "bank account" section. - </i18n.Translate> - </p> - </div> - - {payto !== undefined && ( - <div class="bg-white shadow-sm ring-1 ring-gray-900/5 sm:rounded-xl md:col-span-2"> - <div class="px-4 py-6 sm:p-8"> - <div class="grid max-w-2xl grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6"> - <div class="sm:col-span-5"> - <label - class="block text-sm font-medium leading-6 text-gray-900" - for="account-type" - > - {i18n.str`Account type`} - </label> - <div class="mt-2"> - <input - type="text" - class="block w-full disabled:bg-gray-100 rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 data-[error=true]:ring-red-500 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" - name="account-type" - id="account-type" - disabled={true} - value={payto.targetType} - autocomplete="off" - /> - </div> - <p class="mt-2 text-sm text-gray-500"> - <i18n.Translate> - Method to use for wire transfer. - </i18n.Translate> - </p> - </div> - {((payto) => { - switch (payto.targetType) { - case "iban": { - return ( - <div class="sm:col-span-5"> - <label - class="block text-sm font-medium leading-6 text-gray-900" - for="bitcoin-address" - > - {i18n.str`IBAN`} - </label> - <div class="mt-2"> - <div class="flex justify-between"> - <input - type="text" - class="block w-full disabled:bg-gray-100 rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 data-[error=true]:ring-red-500 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" - name="iban" - id="iban" - disabled={true} - value={payto.iban} - autocomplete="off" - /> - <CopyButton - class="p-2 rounded-full text-black shadow-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 " - getContent={() => payto.iban} - /> - </div> - </div> - <p class="mt-2 text-sm text-gray-500"> - <i18n.Translate> - International Bank Account Number. - </i18n.Translate> - </p> - </div> - ); - } - case "x-taler-bank": { - return ( - <Fragment> - <div class="sm:col-span-5"> - <label - class="block text-sm font-medium leading-6 text-gray-900" - for="account-host" - > - {i18n.str`Account name`} - </label> - <div class="mt-2"> - <div class="flex justify-between"> - <input - type="text" - class="block w-full disabled:bg-gray-100 rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 data-[error=true]:ring-red-500 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" - name="account-host" - id="account-host" - disabled={true} - value={payto.host} - autocomplete="off" - /> - </div> - <CopyButton - class="p-2 rounded-full text-black shadow-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 " - getContent={() => payto.host} - /> - </div> - - <p class="mt-2 text-sm text-gray-500"> - <i18n.Translate> - Bank host where the service is located. - </i18n.Translate> - </p> - </div> - <div class="sm:col-span-5"> - <label - class="block text-sm font-medium leading-6 text-gray-900" - for="account-name" - > - {i18n.str`Account name`} - </label> - <div class="mt-2"> - <div class="flex justify-between"> - <input - type="text" - class="block w-full disabled:bg-gray-100 rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 data-[error=true]:ring-red-500 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" - name="account-name" - id="account-name" - disabled={true} - value={payto.account} - autocomplete="off" - /> - </div> - <CopyButton - class="p-2 rounded-full text-black shadow-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 " - getContent={() => payto.account} - /> - </div> - - <p class="mt-2 text-sm text-gray-500"> - <i18n.Translate> - Bank account identifier for wire transfers. - </i18n.Translate> - </p> - </div> - </Fragment> - ); - } - case "bitcoin": { - return ( - <div class="sm:col-span-5"> - <label - class="block text-sm font-medium leading-6 text-gray-900" - for="iban" - > - {i18n.str`Address`} - </label> - <div class="mt-2"> - <input - type="text" - class="block w-full disabled:bg-gray-100 rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 data-[error=true]:ring-red-500 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" - name="bitcoin-address" - id="bitcoin-address" - disabled={true} - value={payto.address} - autocomplete="off" - /> - <CopyButton - class="p-2 rounded-full text-black shadow-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 " - getContent={() => payto.address} - /> - </div> - <p class="mt-2 text-sm text-gray-500"> - <i18n.Translate> - Bitcoin address for this account. - </i18n.Translate> - </p> - </div> - ); - } - default: - return ( - <i18n.Translate> - Unsupported account type "{payto.targetType}" - </i18n.Translate> - ); - } - })(payto)} - - <div class="sm:col-span-5"> - <label - class="block text-sm font-medium leading-6 text-gray-900" - for="iban" - > - {i18n.str`Owner's name`} - </label> - <div class="mt-2"> - <div class="flex justify-between"> - <input - type="text" - class="block w-full disabled:bg-gray-100 rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 data-[error=true]:ring-red-500 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" - name="iban" - id="iban" - disabled={true} - value={result.body.name} - autocomplete="off" - /> - <CopyButton - class="p-2 rounded-full text-black shadow-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 " - getContent={() => result.body.name} - /> - </div> - </div> - <p class="mt-2 text-sm text-gray-500"> - <i18n.Translate> - Legal name of the person holding the account. - </i18n.Translate> - </p> - </div> - <div class="sm:col-span-5"> - <label - class="block text-sm font-medium leading-6 text-gray-900" - for="iban" - > - {i18n.str`Account info URL`} - </label> - <div class="mt-2"> - <div class="flex justify-between"> - <input - type="text" - class="block w-full disabled:bg-gray-100 rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 data-[error=true]:ring-red-500 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" - name="iban" - id="iban" - disabled={true} - value={revenueURL.href} - autocomplete="off" - /> - <CopyButton - class="p-2 rounded-full text-black shadow-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 " - getContent={() => revenueURL.href} - /> - </div> - </div> - <p class="mt-2 text-sm text-gray-500"> - <i18n.Translate> - From where the merchant can download information about - incoming wire transfers to this account. - </i18n.Translate> - </p> - </div> - </div> - </div> - <div class="flex items-center justify-between gap-x-6 border-t border-gray-900/10 px-4 py-4 sm:px-8"> - <a - href={routeClose.url({})} - name="cancel" - class="text-sm font-semibold leading-6 text-gray-900" - > - <i18n.Translate>Cancel</i18n.Translate> - </a> - <span></span> - </div> - </div> - )} - </div> - )} + {accountIsTheCurrentUser && + config.allow_deletions && + result.body.status !== "deleted" ? ( + <section class="mt-8 rounded-lg border border-red-200 bg-red-50 p-5"> + <h2 class="text-base font-semibold text-red-900"> + <i18n.Translate>Delete account</i18n.Translate> + </h2> + <p class="mt-2 text-sm text-red-800"> + <i18n.Translate> + Permanently remove this bank account. The account must have a zero + balance before it can be deleted. + </i18n.Translate> + </p> + <a + href={routeMyAccountDelete.url({})} + class="mt-4 inline-flex rounded-md bg-red-700 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-600 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-700" + > + <i18n.Translate>Delete account</i18n.Translate> + </a> + </section> + ) : undefined} </Fragment> ); } diff --git a/packages/libeufin-bank-webui/src/pages/account/UpdateAccountPassword.tsx b/packages/libeufin-bank-webui/src/pages/account/UpdateAccountPassword.tsx @@ -33,8 +33,8 @@ import { Fragment, h, VNode } from "preact"; import { useState } from "preact/hooks"; import { useBankChallengeHandlerContext } from "../../context/challenge.js"; import { useSessionState } from "../../hooks/session.js"; +import { OperationError } from "../../components/OperationError.js"; import { undefinedIfEmpty } from "../../utils.js"; -import { doAutoFocus } from "../PaytoWireTransferForm.js"; import { ProfileNavigation } from "../ProfileNavigation.js"; export function UpdateAccountPassword({ @@ -42,20 +42,14 @@ export function UpdateAccountPassword({ routeClose, onUpdateSuccess, - routeMyAccountCashout, - routeMyAccountDelete, routeMyAccountDetails, + routeMyAccountMerchantIntegration, routeMyAccountPassword, - routeConversionConfig, - focus, }: { routeClose: RouteDefinition; routeMyAccountDetails: RouteDefinition; - routeMyAccountDelete: RouteDefinition; + routeMyAccountMerchantIntegration: RouteDefinition; routeMyAccountPassword: RouteDefinition; - routeMyAccountCashout: RouteDefinition; - routeConversionConfig: RouteDefinition; - focus?: boolean; onUpdateSuccess: () => void; account: string; @@ -163,26 +157,32 @@ export function UpdateAccountPassword({ {accountIsTheCurrentUser ? ( <ProfileNavigation current="credentials" - routeMyAccountCashout={routeMyAccountCashout} - routeMyAccountDelete={routeMyAccountDelete} + routeOverview={routeClose} routeMyAccountDetails={routeMyAccountDetails} + routeMyAccountMerchantIntegration={routeMyAccountMerchantIntegration} routeMyAccountPassword={routeMyAccountPassword} - routeConversionConfig={routeConversionConfig} /> - ) : ( - <h1 class="text-base font-semibold leading-6 text-gray-900"> - <i18n.Translate>Account "{accountName}"</i18n.Translate> - </h1> - )} + ) : undefined} - <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"> + <section class="mx-auto mt-6 max-w-2xl"> + <div> + <h1 class="text-2xl font-semibold text-brand"> <i18n.Translate>Update password</i18n.Translate> - </h2> + </h1> + <p class="mt-2 text-sm text-gray-600"> + {accountIsTheCurrentUser ? ( + <i18n.Translate> + Choose a new password for your bank account. + </i18n.Translate> + ) : ( + <i18n.Translate> + Choose a new password for account "{accountName}". + </i18n.Translate> + )} + </p> </div> <form - class="bg-white shadow-sm ring-1 ring-gray-900/5 sm:rounded-xl md:col-span-2" + class="mt-6 overflow-hidden rounded-xl border border-onBackground/10 bg-white shadow-sm" autoCapitalize="none" autoCorrect="off" onSubmit={(e) => { @@ -194,8 +194,8 @@ export function UpdateAccountPassword({ {accountIsTheCurrentUser ? ( <div class="sm:col-span-5"> <label - class="block text-sm font-medium leading-6 text-gray-900" - for="password" + class="block text-sm font-medium leading-6 text-onBackground" + for="current-password" > {i18n.str`Current password`} <b class="text-[red]"> *</b> @@ -203,8 +203,7 @@ export function UpdateAccountPassword({ <div class="mt-2"> <input type="password" - ref={focus ? doAutoFocus : undefined} - class="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 data-[error=true]:ring-red-500 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" + class="block w-full rounded-md border-0 py-1.5 text-onBackground shadow-sm ring-1 ring-inset ring-gray-300 data-[error=true]:ring-red-500 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-primary sm:text-sm sm:leading-6" name="current" id="current-password" data-error={!!errors?.current && current !== undefined} @@ -212,7 +211,7 @@ export function UpdateAccountPassword({ onChange={(e) => { setCurrent(e.currentTarget.value); }} - autocomplete="off" + autocomplete="current-password" /> <ShowInputErrorLabel message={errors?.current} @@ -229,7 +228,7 @@ export function UpdateAccountPassword({ <div class="sm:col-span-5"> <label - class="block text-sm font-medium leading-6 text-gray-900" + class="block text-sm font-medium leading-6 text-onBackground" for="password" > {i18n.str`New password`} @@ -238,7 +237,7 @@ export function UpdateAccountPassword({ <div class="mt-2"> <input type="password" - class="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 data-[error=true]:ring-red-500 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" + class="block w-full rounded-md border-0 py-1.5 text-onBackground shadow-sm ring-1 ring-inset ring-gray-300 data-[error=true]:ring-red-500 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-primary sm:text-sm sm:leading-6" name="password" id="password" data-error={!!errors?.password && password !== undefined} @@ -246,7 +245,7 @@ export function UpdateAccountPassword({ onChange={(e) => { setPassword(e.currentTarget.value); }} - autocomplete="off" + autocomplete="new-password" /> <ShowInputErrorLabel message={errors?.password} @@ -257,16 +256,16 @@ export function UpdateAccountPassword({ <div class="sm:col-span-5"> <label - class="block text-sm font-medium leading-6 text-gray-900" + class="block text-sm font-medium leading-6 text-onBackground" for="repeat" > - {i18n.str`Type it again`} + {i18n.str`Confirm new password`} <b class="text-[red]"> *</b> </label> <div class="mt-2"> <input type="password" - class="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 data-[error=true]:ring-red-500 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" + class="block w-full rounded-md border-0 py-1.5 text-onBackground shadow-sm ring-1 ring-inset ring-gray-300 data-[error=true]:ring-red-500 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-primary sm:text-sm sm:leading-6" name="repeat" id="repeat" data-error={!!errors?.repeat && repeat !== undefined} @@ -275,7 +274,7 @@ export function UpdateAccountPassword({ setRepeat(e.currentTarget.value); }} // placeholder="" - autocomplete="off" + autocomplete="new-password" /> <ShowInputErrorLabel message={errors?.repeat} @@ -283,33 +282,36 @@ export function UpdateAccountPassword({ /> </div> <p class="mt-2 text-sm text-gray-500"> - <i18n.Translate>Repeat the same password</i18n.Translate> + <i18n.Translate>Enter the new password again.</i18n.Translate> </p> </div> </div> </div> - <div class="flex items-center justify-between gap-x-6 border-t border-gray-900/10 px-4 py-4 sm:px-8"> - <a - href={routeClose.url({})} - name="cancel" - class="text-sm font-semibold leading-6 text-gray-900" - > - <i18n.Translate>Cancel</i18n.Translate> - </a> - <AsyncButton - submit - name="change" - class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" - disabled={!passwordChange || !token || !!errors} - onClick={() => - update.run(accountName, token!, passwordChange!, []) - } - > - <i18n.Translate>Change</i18n.Translate> - </AsyncButton> + <div class="border-t border-onBackground/10 bg-background/60 px-5 py-4 sm:px-8"> + <OperationError class="mb-4" /> + <div class="flex items-center justify-between gap-x-6"> + <a + href={routeClose.url({})} + name="cancel" + class="text-sm font-semibold leading-6 text-onBackground" + > + <i18n.Translate>Cancel</i18n.Translate> + </a> + <AsyncButton + submit + name="change" + class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-primary px-3 py-2 text-sm font-semibold text-onPrimary shadow-sm hover:bg-primaryHover hover:text-white focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary" + disabled={!passwordChange || !token || !!errors} + onClick={() => + update.run(accountName, token!, passwordChange!, []) + } + > + <i18n.Translate>Update password</i18n.Translate> + </AsyncButton> + </div> </div> </form> - </div> + </section> </Fragment> ); } diff --git a/packages/libeufin-bank-webui/src/pages/admin/AccountForm.stories.tsx b/packages/libeufin-bank-webui/src/pages/admin/AccountForm.stories.tsx @@ -47,6 +47,15 @@ export const Update = tests.createExample(AccountForm, { children: undefined, }); +export const UpdateEditableFieldsOnly = tests.createExample(AccountForm, { + template: accountTemplate, + username: "alice", + purpose: "update", + hideNonEditableFields: true, + onChange: () => undefined, + children: undefined, +}); + export const Show = tests.createExample(AccountForm, { template: accountTemplate, username: "alice", diff --git a/packages/libeufin-bank-webui/src/pages/admin/AccountForm.tsx b/packages/libeufin-bank-webui/src/pages/admin/AccountForm.tsx @@ -119,9 +119,11 @@ export function AccountForm<PurposeType extends keyof ChangeByPurposeType>({ purpose, onChange, focus, + hideNonEditableFields = false, children, }: { focus?: boolean; + hideNonEditableFields?: boolean; children: ComponentChildren; username?: string; template: TalerCorebankApi.AccountData | undefined; @@ -403,7 +405,7 @@ export function AccountForm<PurposeType extends keyof ChangeByPurposeType>({ } return ( <form - class="bg-white shadow-sm ring-1 ring-gray-900/5 sm:rounded-xl md:col-span-2" + class="bg-white shadow-sm ring-1 ring-onBackground/5 sm:rounded-xl md:col-span-2" autoCapitalize="none" autoCorrect="off" onSubmit={(e) => { @@ -412,84 +414,85 @@ export function AccountForm<PurposeType extends keyof ChangeByPurposeType>({ > <div class="px-4 py-6 sm:p-8"> <div class="grid max-w-2xl grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6"> - <div class="sm:col-span-5"> - <label - class="block text-sm font-medium leading-6 text-gray-900" - for="username" - > - {i18n.str`Login username`} - {editableUsername && <b class="text-[red]"> *</b>} - </label> - <div class="mt-2"> - <input - ref={focus && purpose === "create" ? doAutoFocus : undefined} - type="text" - class="block w-full disabled:bg-gray-100 rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 data-[error=true]:ring-red-500 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" - name="username" - id="username" - data-error={!!errors?.username && form.username !== undefined} - disabled={!editableUsername} - value={form.username ?? defaultValue.username} - onChange={(e) => { - form.username = e.currentTarget.value; - updateForm(structuredClone(form)); - }} - // placeholder="" - autocomplete="off" - /> - <ShowInputErrorLabel - message={errors?.username} - isDirty={form.username !== undefined} - /> + {hideNonEditableFields && !editableUsername ? undefined : ( + <div class="sm:col-span-5"> + <label + class="block text-sm font-medium leading-6 text-onBackground" + for="username" + > + {i18n.str`Login username`} + {editableUsername && <b class="text-[red]"> *</b>} + </label> + <div class="mt-2"> + <input + ref={focus && purpose === "create" ? doAutoFocus : undefined} + type="text" + class="block w-full disabled:bg-gray-100 rounded-md border-0 py-1.5 text-onBackground shadow-sm ring-1 ring-inset ring-gray-300 data-[error=true]:ring-red-500 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-primary sm:text-sm sm:leading-6" + name="username" + id="username" + data-error={!!errors?.username && form.username !== undefined} + disabled={!editableUsername} + value={form.username ?? defaultValue.username} + onChange={(e) => { + form.username = e.currentTarget.value; + updateForm(structuredClone(form)); + }} + // placeholder="" + autocomplete="off" + /> + <ShowInputErrorLabel + message={errors?.username} + isDirty={form.username !== undefined} + /> + </div> + <p class="mt-2 text-sm text-gray-500"> + <i18n.Translate>Account ID for authentication</i18n.Translate> + </p> </div> - <p class="mt-2 text-sm text-gray-500"> - <i18n.Translate>Account ID for authentication</i18n.Translate> - </p> - </div> + )} - <div class="sm:col-span-5"> - <label - class="block text-sm font-medium leading-6 text-gray-900" - for="name" - > - {i18n.str`Full name`} - {editableName && <b class="text-[red]"> *</b>} - </label> - <div class="mt-2"> - <input - type="text" - class="block w-full disabled:bg-gray-100 rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 data-[error=true]:ring-red-500 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" - name="name" - data-error={!!errors?.name && form.name !== undefined} - id="name" - disabled={!editableName} - value={form.name ?? defaultValue.name} - onChange={(e) => { - form.name = e.currentTarget.value; - updateForm(structuredClone(form)); - }} - // placeholder="" - autocomplete="off" - /> - <ShowInputErrorLabel - message={errors?.name} - isDirty={form.name !== undefined} - /> + {hideNonEditableFields && !editableName ? undefined : ( + <div class="sm:col-span-5"> + <label + class="block text-sm font-medium leading-6 text-onBackground" + for="name" + > + {i18n.str`Full name`} + {editableName && <b class="text-[red]"> *</b>} + </label> + <div class="mt-2"> + <input + type="text" + class="block w-full disabled:bg-gray-100 rounded-md border-0 py-1.5 text-onBackground shadow-sm ring-1 ring-inset ring-gray-300 data-[error=true]:ring-red-500 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-primary sm:text-sm sm:leading-6" + name="name" + data-error={!!errors?.name && form.name !== undefined} + id="name" + disabled={!editableName} + value={form.name ?? defaultValue.name} + onChange={(e) => { + form.name = e.currentTarget.value; + updateForm(structuredClone(form)); + }} + // placeholder="" + autocomplete="off" + /> + <ShowInputErrorLabel + message={errors?.name} + isDirty={form.name !== undefined} + /> + </div> + <p class="mt-2 text-sm text-gray-500"> + <i18n.Translate>Name of the account holder</i18n.Translate> + </p> </div> - <p class="mt-2 text-sm text-gray-500"> - <i18n.Translate>Name of the account holder</i18n.Translate> - </p> - </div> + )} - {purpose === "create" ? undefined : ( + {purpose === "create" || + (hideNonEditableFields && !editableAccount) ? undefined : ( <TextField id="internal-account" label={i18n.str`Internal account`} - help={ - purpose === "create" - ? i18n.str`If this field is empty, a random account ID will be assigned` - : i18n.str`You can copy and share this IBAN number in order to receive wire transfers to your bank account` - } + help={i18n.str`Copy and share this account address to receive transfers.`} error={errors?.payto_uri} onChange={(e) => { form.payto_uri = e as PaytoString; @@ -497,7 +500,7 @@ export function AccountForm<PurposeType extends keyof ChangeByPurposeType>({ }} rightIcons={ <CopyButton - class="p-2 rounded-full text-black shadow-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 " + class="p-2 rounded-full text-onBackground shadow-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 " getContent={() => form.payto_uri ?? defaultValue.payto_uri ?? "" } @@ -510,7 +513,7 @@ export function AccountForm<PurposeType extends keyof ChangeByPurposeType>({ <div class="sm:col-span-5"> <label - class="block text-sm font-medium leading-6 text-gray-900" + class="block text-sm font-medium leading-6 text-onBackground" for="email" > {i18n.str`Email`} @@ -518,7 +521,7 @@ export function AccountForm<PurposeType extends keyof ChangeByPurposeType>({ <div class="mt-2"> <input type="email" - class="block w-full disabled:bg-gray-100 rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 data-[error=true]:ring-red-500 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" + class="block w-full disabled:bg-gray-100 rounded-md border-0 py-1.5 text-onBackground shadow-sm ring-1 ring-inset ring-gray-300 data-[error=true]:ring-red-500 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-primary sm:text-sm sm:leading-6" name="email" id="email" data-error={!!errors?.email && form.email !== undefined} @@ -544,7 +547,7 @@ export function AccountForm<PurposeType extends keyof ChangeByPurposeType>({ <div class="sm:col-span-5"> <label - class="block text-sm font-medium leading-6 text-gray-900" + class="block text-sm font-medium leading-6 text-onBackground" for="phone" > {i18n.str`Phone`} @@ -552,7 +555,7 @@ export function AccountForm<PurposeType extends keyof ChangeByPurposeType>({ <div class="mt-2"> <input type="text" - class="block w-full disabled:bg-gray-100 rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 data-[error=true]:ring-red-500 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" + class="block w-full disabled:bg-gray-100 rounded-md border-0 py-1.5 text-onBackground shadow-sm ring-1 ring-inset ring-gray-300 data-[error=true]:ring-red-500 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-primary sm:text-sm sm:leading-6" name="phone" id="phone" disabled={purpose === "show"} @@ -580,7 +583,7 @@ export function AccountForm<PurposeType extends keyof ChangeByPurposeType>({ config.supported_tan_channels.length === 0 ? undefined : ( <div class="sm:col-span-5"> <label - class="block text-sm font-medium leading-6 text-gray-900" + class="block text-sm font-medium leading-6 text-onBackground" for="channel" > {i18n.str`Enable second factor authentication`} @@ -603,7 +606,7 @@ export function AccountForm<PurposeType extends keyof ChangeByPurposeType>({ data-selected={effectiveForm.tan_channels?.includes( TanChannel.EMAIL, )} - class="relative flex data-[disabled=false]:cursor-pointer rounded-lg border bg-white data-[disabled=true]:bg-gray-200 p-4 shadow-sm focus:outline-none border-gray-300 data-[selected=true]:ring-2 data-[selected=true]:ring-indigo-600" + class="relative flex data-[disabled=false]:cursor-pointer rounded-lg border bg-white data-[disabled=true]:bg-gray-200 p-4 shadow-sm focus:outline-none border-gray-300 data-[selected=true]:ring-2 data-[selected=true]:ring-primary" > <input type="checkbox" @@ -621,7 +624,7 @@ export function AccountForm<PurposeType extends keyof ChangeByPurposeType>({ <span class="flex flex-col"> <span id="tan-email-label" - class="block text-sm font-medium text-gray-900 " + class="block text-sm font-medium text-onBackground " > <i18n.Translate>Using email</i18n.Translate> </span> @@ -634,7 +637,7 @@ export function AccountForm<PurposeType extends keyof ChangeByPurposeType>({ data-selected={effectiveForm.tan_channels?.includes( TanChannel.EMAIL, )} - class="h-5 w-5 text-indigo-600 data-[selected=false]:hidden" + class="h-5 w-5 text-primary data-[selected=false]:hidden" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" @@ -656,7 +659,7 @@ export function AccountForm<PurposeType extends keyof ChangeByPurposeType>({ data-selected={effectiveForm.tan_channels?.includes( TanChannel.SMS, )} - class="relative flex data-[disabled=false]:cursor-pointer rounded-lg border data-[disabled=true]:bg-gray-200 p-4 shadow-sm focus:outline-none border-gray-300 data-[selected=true]:ring-2 data-[selected=true]:ring-indigo-600" + class="relative flex data-[disabled=false]:cursor-pointer rounded-lg border data-[disabled=true]:bg-gray-200 p-4 shadow-sm focus:outline-none border-gray-300 data-[selected=true]:ring-2 data-[selected=true]:ring-primary" > <input type="checkbox" @@ -674,7 +677,7 @@ export function AccountForm<PurposeType extends keyof ChangeByPurposeType>({ <span class="flex flex-col"> <span id="tan-sms-label" - class="block text-sm font-medium text-gray-900" + class="block text-sm font-medium text-onBackground" > <i18n.Translate>Using SMS</i18n.Translate> </span> @@ -687,7 +690,7 @@ export function AccountForm<PurposeType extends keyof ChangeByPurposeType>({ data-selected={effectiveForm.tan_channels?.includes( TanChannel.SMS, )} - class="h-5 w-5 text-indigo-600 data-[selected=false]:hidden" + class="h-5 w-5 text-primary data-[selected=false]:hidden" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" @@ -705,7 +708,7 @@ export function AccountForm<PurposeType extends keyof ChangeByPurposeType>({ </div> )} - {isCashoutEnabled && ( + {isCashoutEnabled && (!hideNonEditableFields || editableCashout) && ( <TextField id="cashout-account" label={i18n.str`Cashout account`} @@ -723,45 +726,47 @@ export function AccountForm<PurposeType extends keyof ChangeByPurposeType>({ /> )} - <div class="sm:col-span-5"> - <label - for="debit" - class="block text-sm font-medium leading-6 text-gray-900" - >{i18n.str`Max debt`}</label> - <InputAmount - name="debit" - left - currency={config.currency} - value={form.debit_threshold ?? defaultValue.debit_threshold} - onChange={ - !editableThreshold - ? undefined - : (e) => { - form.debit_threshold = e as AmountString; - updateForm(structuredClone(form)); - } - } - /> - <ShowInputErrorLabel - message={ - errors?.debit_threshold - ? String(errors?.debit_threshold) - : undefined - } - isDirty={form.debit_threshold !== undefined} - /> - <p class="mt-2 text-sm text-gray-500"> - <i18n.Translate> - How much the balance can go below zero. - </i18n.Translate> - </p> - </div> + {hideNonEditableFields && !editableThreshold ? undefined : ( + <div class="sm:col-span-5"> + <label + for="debit" + class="block text-sm font-medium leading-6 text-onBackground" + >{i18n.str`Max debt`}</label> + <InputAmount + name="debit" + left + currency={config.currency} + value={form.debit_threshold ?? defaultValue.debit_threshold} + onChange={ + !editableThreshold + ? undefined + : (e) => { + form.debit_threshold = e as AmountString; + updateForm(structuredClone(form)); + } + } + /> + <ShowInputErrorLabel + message={ + errors?.debit_threshold + ? String(errors?.debit_threshold) + : undefined + } + isDirty={form.debit_threshold !== undefined} + /> + <p class="mt-2 text-sm text-gray-500"> + <i18n.Translate> + How much the balance can go below zero. + </i18n.Translate> + </p> + </div> + )} <div class="sm:col-span-5"> <div class="flex items-center justify-between"> <span class="flex flex-grow flex-col"> <span - class="text-sm text-black font-medium leading-6 " + class="text-sm text-onBackground font-medium leading-6 " id="public-account-label" > <i18n.Translate>Is this account public?</i18n.Translate> @@ -774,7 +779,7 @@ export function AccountForm<PurposeType extends keyof ChangeByPurposeType>({ data-enabled={ (form.isPublic ?? defaultValue.isPublic) ? "true" : "false" } - class="bg-indigo-600 data-[enabled=false]:bg-gray-200 relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-indigo-600 focus:ring-offset-2" + class="bg-primary data-[enabled=false]:bg-gray-200 relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2" role="switch" aria-checked={!!(form.isPublic ?? defaultValue.isPublic)} aria-labelledby="public-account-label" @@ -811,7 +816,7 @@ export function AccountForm<PurposeType extends keyof ChangeByPurposeType>({ <div class="flex items-center justify-between"> <span class="flex flex-grow flex-col"> <span - class="text-sm text-black font-medium leading-6 " + class="text-sm text-onBackground font-medium leading-6 " id="exchange-account-label" > <i18n.Translate> @@ -827,7 +832,7 @@ export function AccountForm<PurposeType extends keyof ChangeByPurposeType>({ ? "true" : "false" } - class="bg-indigo-600 data-[enabled=false]:bg-gray-200 relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-indigo-600 focus:ring-offset-2" + class="bg-primary data-[enabled=false]:bg-gray-200 relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2" role="switch" aria-checked={!!(form.isExchange ?? defaultValue.isExchange)} aria-labelledby="exchange-account-label" diff --git a/packages/libeufin-bank-webui/src/pages/admin/AccountList.tsx b/packages/libeufin-bank-webui/src/pages/admin/AccountList.tsx @@ -27,6 +27,7 @@ import { useTranslationContext, } from "@gnu-taler/web-util/browser"; import { Fragment, VNode, h } from "preact"; +import { useState } from "preact/hooks"; import { revalidateBusinessAccounts, @@ -40,6 +41,8 @@ interface Props { routeShowAccount: RouteDefinition<{ account: string }>; routeRemoveAccount: RouteDefinition<{ account: string }>; routeUpdatePasswordAccount: RouteDefinition<{ account: string }>; + routePublicAccounts: RouteDefinition; + showPublicAccounts: boolean; } export function AccountList({ @@ -47,8 +50,12 @@ export function AccountList({ routeRemoveAccount, routeShowAccount, routeUpdatePasswordAccount, + routePublicAccounts, + showPublicAccounts, }: Props): VNode { - const result = useBusinessAccounts(); + const [query, setQuery] = useState(""); + const [submittedQuery, setSubmittedQuery] = useState<string>(); + const result = useBusinessAccounts(submittedQuery); const { i18n } = useTranslationContext(); const { config } = useBankCoreApiContext(); @@ -79,20 +86,68 @@ export function AccountList({ <div class="px-4 sm:px-6 lg:px-8 mt-8"> <div class="sm:flex sm:items-center"> <div class="sm:flex-auto"> - <h1 class="text-base font-semibold leading-6 text-gray-900"> + <h1 class="text-base font-semibold leading-6 text-onBackground"> <i18n.Translate>Accounts</i18n.Translate> </h1> </div> - <div class="mt-4 sm:ml-16 sm:mt-0 sm:flex-none"> + <div class="mt-4 flex flex-wrap gap-3 sm:ml-16 sm:mt-0 sm:flex-none"> + {showPublicAccounts ? ( + <a + href={routePublicAccounts.url({})} + class="block rounded-md border border-outlineVariant bg-white px-3 py-2 text-center text-sm font-semibold text-onBackground hover:bg-gray-50" + > + <i18n.Translate>Public accounts</i18n.Translate> + </a> + ) : undefined} <a href={routeCreate.url({})} name="create account" - class="block rounded-md bg-indigo-600 px-3 py-2 text-center text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" + class="block rounded-md bg-primary px-3 py-2 text-center text-sm font-semibold text-onPrimary shadow-sm hover:bg-primaryHover hover:text-white focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary" > <i18n.Translate>Create account</i18n.Translate> </a> </div> </div> + <form + class="mt-5 flex max-w-xl flex-col gap-3 sm:flex-row" + onSubmit={(event) => { + event.preventDefault(); + setSubmittedQuery(query.trim() || undefined); + }} + > + <div class="grow"> + <label class="sr-only" htmlFor="account-search"> + {i18n.str`Search accounts`} + </label> + <input + id="account-search" + type="search" + name="account search" + value={query} + onInput={(event) => setQuery(event.currentTarget.value)} + placeholder={i18n.str`Search by username or name`} + class="block w-full rounded-md border-gray-300 shadow-sm focus:border-primary focus:ring-primary" + /> + </div> + <button + type="submit" + class="rounded-md bg-primary px-4 py-2 text-sm font-semibold text-onPrimary hover:bg-primaryHover hover:text-white" + > + <i18n.Translate>Search</i18n.Translate> + </button> + {submittedQuery ? ( + <button + type="button" + class="rounded-md border border-outlineVariant px-4 py-2 text-sm font-semibold" + onClick={() => { + setQuery(""); + setSubmittedQuery(undefined); + }} + > + <i18n.Translate>Clear</i18n.Translate> + </button> + ) : undefined} + </form> <div class="mt-4 flow-root"> <div class="-mx-4 -my-2 overflow-x-auto sm:-mx-6 lg:-mx-8"> <div class="inline-block min-w-full py-2 align-middle sm:px-6 lg:px-8"> @@ -104,15 +159,15 @@ export function AccountList({ <tr> <th scope="col" - class="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 sm:pl-0" + class="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-onBackground sm:pl-0" >{i18n.str`Username`}</th> <th scope="col" - class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900" + class="px-3 py-3.5 text-left text-sm font-semibold text-onBackground" >{i18n.str`Name`}</th> <th scope="col" - class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900" + class="px-3 py-3.5 text-left text-sm font-semibold text-onBackground" >{i18n.str`Balance`}</th> <th scope="col" class="relative py-3.5 pl-3 pr-4 sm:pr-0"> <span class="sr-only">{i18n.str`Actions`}</span> @@ -135,13 +190,13 @@ export function AccountList({ class="data-[status=deleted]:bg-gray-100" data-status={item.status} > - <td class="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-gray-900 sm:pl-0"> + <td class="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-onBackground sm:pl-0"> <a name={`show account ${item.username}`} href={routeShowAccount.url({ account: item.username, })} - class="text-indigo-600 hover:text-indigo-900" + class="text-primaryDark hover:text-onPrimaryContainer" > {item.username} </a> @@ -184,7 +239,7 @@ export function AccountList({ href={routeUpdatePasswordAccount.url({ account: item.username, })} - class="text-indigo-600 hover:text-indigo-900" + class="text-primaryDark hover:text-onPrimaryContainer" > <i18n.Translate> Change password @@ -198,7 +253,7 @@ export function AccountList({ href={routeRemoveAccount.url({ account: item.username, })} - class="text-indigo-600 hover:text-indigo-900" + class="text-primaryDark hover:text-onPrimaryContainer" > <i18n.Translate>Remove</i18n.Translate> </a> @@ -220,17 +275,17 @@ export function AccountList({ <div class="flex flex-1 justify-between sm:justify-end"> <button type="button" - name="first page" - class="relative disabled:bg-gray-100 disabled:text-gray-500 inline-flex items-center rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus-visible:outline-offset-0" - disabled={!result.loadFirst} - onClick={result.loadFirst} + name="previous page" + class="relative disabled:bg-gray-100 disabled:text-gray-500 inline-flex items-center rounded-md bg-white px-3 py-2 text-sm font-semibold text-onBackground ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus-visible:outline-offset-0" + disabled={!result.loadPrev} + onClick={result.loadPrev} > - <i18n.Translate>First page</i18n.Translate> + <i18n.Translate>Previous</i18n.Translate> </button> <button type="button" name="next page" - class="relative disabled:bg-gray-100 disabled:text-gray-500 ml-3 inline-flex items-center rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus-visible:outline-offset-0" + class="relative disabled:bg-gray-100 disabled:text-gray-500 ml-3 inline-flex items-center rounded-md bg-white px-3 py-2 text-sm font-semibold text-onBackground ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus-visible:outline-offset-0" disabled={!result.loadNext} onClick={result.loadNext} > diff --git a/packages/libeufin-bank-webui/src/pages/admin/AdminAccountSummary.tsx b/packages/libeufin-bank-webui/src/pages/admin/AdminAccountSummary.tsx @@ -0,0 +1,75 @@ +/* + This file is part of GNU Taler + (C) 2026 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. +*/ + +import { Amounts, TalerError } from "@gnu-taler/taler-util"; +import { + Loading, + RenderAmount, + RouteDefinition, + useBankCoreApiContext, + useTranslationContext, +} from "@gnu-taler/web-util/browser"; +import { VNode, h } from "preact"; +import { RetryableError } from "../../components/RetryableError.js"; +import { + revalidateAccountDetails, + useAccountDetails, +} from "../../hooks/account.js"; + +export function AdminAccountSummary({ + routeTransfer, +}: { + routeTransfer: RouteDefinition; +}): VNode { + const { i18n } = useTranslationContext(); + const { config } = useBankCoreApiContext(); + const result = useAccountDetails("admin"); + if (!result) return <Loading />; + if (result instanceof TalerError) { + return ( + <RetryableError + error={result} + title={i18n.str`Failed to load the admin account.`} + onRetry={() => void revalidateAccountDetails()} + /> + ); + } + if (result.type === "fail") return <div />; + return ( + <section + class="mt-8 rounded-lg border border-outlineVariant p-5" + aria-labelledby="admin-account-heading" + > + <div class="flex flex-wrap items-center justify-between gap-4"> + <div> + <h2 + id="admin-account-heading" + class="font-semibold text-onBackground" + > + <i18n.Translate>Admin account</i18n.Translate> + </h2> + <p class="mt-1 text-2xl font-bold"> + <RenderAmount + value={Amounts.parseOrThrow(result.body.balance.amount)} + negative={result.body.balance.credit_debit_indicator === "debit"} + withSign + spec={config.currency_specification} + /> + </p> + </div> + <a + href={routeTransfer.url({})} + class="rounded-md bg-primary px-3 py-2 text-sm font-semibold text-onPrimary hover:bg-primaryHover hover:text-white" + > + <i18n.Translate>Send bank transfer</i18n.Translate> + </a> + </div> + </section> + ); +} diff --git a/packages/libeufin-bank-webui/src/pages/admin/AdminHome.tsx b/packages/libeufin-bank-webui/src/pages/admin/AdminHome.tsx @@ -44,61 +44,32 @@ import { } from "../../hooks/regional.js"; import { RetryableError } from "../../components/RetryableError.js"; -import { WireTransfer } from "../WireTransfer.js"; -import { AccountList } from "./AccountList.js"; -import { ConversionClassList } from "./ConversionClassList.js"; +import { AdminAccountSummary } from "./AdminAccountSummary.js"; /** * Query account information and show QR code if there is pending withdrawal */ interface Props { routeDownloadStats: RouteDefinition; - routeCreateWireTransfer: RouteDefinition<{ - account?: string; - subject?: string; - amount?: string; - }>; - - routeCreateAccount: RouteDefinition; - routeRemoveAccount: RouteDefinition<{ account: string }>; - routeShowAccount: RouteDefinition<{ account: string }>; - routeUpdatePasswordAccount: RouteDefinition<{ account: string }>; - routeShowCashoutsAccount: RouteDefinition<{ account: string }>; - - routeCreateConversionRateClass: RouteDefinition; - routeShowConversionRateClass: RouteDefinition<{ classId: string }>; + routeCreateWireTransfer: RouteDefinition; + routeActivity: RouteDefinition; } export function AdminHome({ - routeCreateAccount, - routeRemoveAccount, - routeShowAccount, - routeUpdatePasswordAccount, routeDownloadStats, routeCreateWireTransfer, - routeCreateConversionRateClass, - routeShowConversionRateClass, + routeActivity, }: Props): VNode { - const { config } = useBankCoreApiContext(); + const { i18n } = useTranslationContext(); return ( <Fragment> <Metrics routeDownloadStats={routeDownloadStats} /> - <WireTransfer /> + <AdminAccountSummary routeTransfer={routeCreateWireTransfer} /> <Transactions account="admin" - routeCreateWireTransfer={routeCreateWireTransfer} + variant="recent" + title={i18n.str`Admin account activity`} + routeFullHistory={routeActivity} /> - <AccountList - routeCreate={routeCreateAccount} - routeRemoveAccount={routeRemoveAccount} - routeShowAccount={routeShowAccount} - routeUpdatePasswordAccount={routeUpdatePasswordAccount} - /> - {!config.allow_conversion ? undefined : ( - <ConversionClassList - routeCreate={routeCreateConversionRateClass} - routeShowDetails={routeShowConversionRateClass} - /> - )} </Fragment> ); } @@ -338,26 +309,26 @@ function Metrics({ } } return ( - <div class="px-4 mt-4"> + <section class="mt-2" aria-labelledby="bank-activity-heading"> <div class="sm:flex sm:items-center mb-4"> <div class="sm:flex-auto"> - <h1 class="text-base font-semibold leading-6 text-gray-900"> - <i18n.Translate>Transaction volume report</i18n.Translate> + <h1 + id="bank-activity-heading" + class="text-xl font-semibold leading-6 text-onBackground" + > + <i18n.Translate>Bank activity</i18n.Translate> </h1> </div> </div> <div class="sm:hidden"> - <label - htmlFor="tabs" - class="sr-only" - > + <label htmlFor="tabs" class="sr-only"> <i18n.Translate>Select a section</i18n.Translate> </label> <select id="tabs" name="tabs" - class="block w-full rounded-md border-gray-300 focus:border-indigo-500 focus:ring-indigo-500" + class="block w-full rounded-md border-gray-300 focus:border-primaryHover focus:ring-primaryHover" onChange={(e) => { setMetricType( parseInt( @@ -398,7 +369,7 @@ function Metrics({ <div class="hidden sm:block"> {/* FIXME: This should be LINKS */} <nav - class="isolate flex divide-x divide-gray-200 rounded-lg shadow" + class="isolate flex divide-x divide-gray-200 overflow-hidden rounded-lg border border-outlineVariant" aria-label={i18n.str`Tabs`} > <button @@ -411,7 +382,7 @@ function Metrics({ data-selected={ metricType == TalerCorebankApi.MonitorTimeframeParam.hour } - class="rounded-l-lg text-gray-500 hover:text-gray-700 data-[selected=true]:text-gray-900 group relative min-w-0 flex-1 overflow-hidden bg-white py-4 px-4 text-center text-sm font-medium hover:bg-gray-50 focus:z-10" + class="text-gray-500 hover:text-gray-700 data-[selected=true]:text-primaryDark group relative min-w-0 flex-1 overflow-hidden bg-white px-3 py-2 text-center text-sm font-medium hover:bg-gray-50 focus:z-10" > <span> <i18n.Translate>Last hour</i18n.Translate> @@ -421,7 +392,7 @@ function Metrics({ data-selected={ metricType == TalerCorebankApi.MonitorTimeframeParam.hour } - class="bg-transparent data-[selected=true]:bg-indigo-500 absolute inset-x-0 bottom-0 h-0.5" + class="bg-transparent data-[selected=true]:bg-primaryHover absolute inset-x-0 bottom-0 h-0.5" ></span> </button> <button @@ -434,7 +405,7 @@ function Metrics({ data-selected={ metricType == TalerCorebankApi.MonitorTimeframeParam.day } - class=" text-gray-500 hover:text-gray-700 data-[selected=true]:text-gray-900 group relative min-w-0 flex-1 overflow-hidden bg-white py-4 px-4 text-center text-sm font-medium hover:bg-gray-50 focus:z-10" + class="text-gray-500 hover:text-gray-700 data-[selected=true]:text-primaryDark group relative min-w-0 flex-1 overflow-hidden bg-white px-3 py-2 text-center text-sm font-medium hover:bg-gray-50 focus:z-10" > <span> <i18n.Translate>Previous day</i18n.Translate> @@ -444,7 +415,7 @@ function Metrics({ data-selected={ metricType == TalerCorebankApi.MonitorTimeframeParam.day } - class="bg-transparent data-[selected=true]:bg-indigo-500 absolute inset-x-0 bottom-0 h-0.5" + class="bg-transparent data-[selected=true]:bg-primaryHover absolute inset-x-0 bottom-0 h-0.5" ></span> </button> <button @@ -457,7 +428,7 @@ function Metrics({ data-selected={ metricType == TalerCorebankApi.MonitorTimeframeParam.month } - class="rounded-r-lg text-gray-500 hover:text-gray-700 data-[selected=true]:text-gray-900 group relative min-w-0 flex-1 overflow-hidden bg-white py-4 px-4 text-center text-sm font-medium hover:bg-gray-50 focus:z-10" + class="text-gray-500 hover:text-gray-700 data-[selected=true]:text-primaryDark group relative min-w-0 flex-1 overflow-hidden bg-white px-3 py-2 text-center text-sm font-medium hover:bg-gray-50 focus:z-10" > <span> <i18n.Translate>Last month</i18n.Translate> @@ -467,7 +438,7 @@ function Metrics({ data-selected={ metricType == TalerCorebankApi.MonitorTimeframeParam.month } - class="bg-transparent data-[selected=true]:bg-indigo-500 absolute inset-x-0 bottom-0 h-0.5" + class="bg-transparent data-[selected=true]:bg-primaryHover absolute inset-x-0 bottom-0 h-0.5" ></span> </button> <button @@ -480,7 +451,7 @@ function Metrics({ data-selected={ metricType == TalerCorebankApi.MonitorTimeframeParam.year } - class="rounded-r-lg text-gray-500 hover:text-gray-700 data-[selected=true]:text-gray-900 group relative min-w-0 flex-1 overflow-hidden bg-white py-4 px-4 text-center text-sm font-medium hover:bg-gray-50 focus:z-10" + class="text-gray-500 hover:text-gray-700 data-[selected=true]:text-primaryDark group relative min-w-0 flex-1 overflow-hidden bg-white px-3 py-2 text-center text-sm font-medium hover:bg-gray-50 focus:z-10" > <span> <i18n.Translate>Last Year</i18n.Translate> @@ -490,14 +461,14 @@ function Metrics({ data-selected={ metricType == TalerCorebankApi.MonitorTimeframeParam.year } - class="bg-transparent data-[selected=true]:bg-indigo-500 absolute inset-x-0 bottom-0 h-0.5" + class="bg-transparent data-[selected=true]:bg-primaryHover absolute inset-x-0 bottom-0 h-0.5" ></span> </button> </nav> </div> <div class="w-full flex justify-between"> - <h1 class="text-base text-gray-900 mt-5"> + <h1 class="text-base text-onBackground mt-5"> {i18n.str`Trading volume from ${getDateForTimeframeStart( params.current, metricType, @@ -509,14 +480,14 @@ function Metrics({ )}`} </h1> </div> - <dl class="mt-5 grid grid-cols-1 md:grid-cols-2 divide-y divide-gray-200 overflow-hidden rounded-lg bg-white shadow-lg md:divide-x md:divide-y-0"> + <dl class="mt-4 grid grid-cols-1 gap-3 sm:grid-cols-2"> {!respInfo || resp.current.body.type !== "with-conversions" || resp.previous.body.type !== "with-conversions" ? undefined : ( <Fragment> - <div class="px-4 py-5 sm:p-6"> - <dt class="text-base font-normal text-gray-900"> - <i18n.Translate>Cashin</i18n.Translate> + <div class="rounded-lg border border-outlineVariant px-4 py-4"> + <dt class="text-base font-normal text-onBackground"> + <i18n.Translate>Cash in</i18n.Translate> <div class="text-xs text-gray-500"> <i18n.Translate> Transferred from an external account to an account in this @@ -530,9 +501,9 @@ function Metrics({ spec={respInfo.body.fiat_currency_specification} /> </div> - <div class="px-4 py-5 sm:p-6"> - <dt class="text-base font-normal text-gray-900"> - <i18n.Translate>Cashout</i18n.Translate> + <div class="rounded-lg border border-outlineVariant px-4 py-4"> + <dt class="text-base font-normal text-onBackground"> + <i18n.Translate>Cash out</i18n.Translate> </dt> <div class="text-xs text-gray-500"> <i18n.Translate> @@ -548,9 +519,9 @@ function Metrics({ </div> </Fragment> )} - <div class="px-4 py-5 sm:p-6"> - <dt class="text-base font-normal text-gray-900"> - <i18n.Translate>Payin</i18n.Translate> + <div class="rounded-lg border border-outlineVariant px-4 py-4"> + <dt class="text-base font-normal text-onBackground"> + <i18n.Translate>Taler pay-in</i18n.Translate> <div class="text-xs text-gray-500"> <i18n.Translate> Transferred from an account to a Taler exchange. @@ -562,10 +533,17 @@ function Metrics({ previous={resp.previous.body.talerInVolume} spec={config.currency_specification} /> + <div class="mt-3 border-t border-outlineVariant pt-2 text-sm text-secondary"> + <i18n.Translate>Transfers</i18n.Translate> + <MetricValueNumber + current={resp.current.body.talerInCount} + previous={resp.previous.body.talerInCount} + /> + </div> </div> - <div class="px-4 py-5 sm:p-6"> - <dt class="text-base font-normal text-gray-900"> - <i18n.Translate>Payout</i18n.Translate> + <div class="rounded-lg border border-outlineVariant px-4 py-4"> + <dt class="text-base font-normal text-onBackground"> + <i18n.Translate>Taler payout</i18n.Translate> <div class="text-xs text-gray-500"> <i18n.Translate> Transferred from a Taler exchange to another account. @@ -577,46 +555,25 @@ function Metrics({ previous={resp.previous.body.talerOutVolume} spec={config.currency_specification} /> - </div> - <div class="px-4 py-5 sm:p-6"> - <dt class="text-base font-normal text-gray-900"> - <i18n.Translate>Payin</i18n.Translate> - <div class="text-xs text-gray-500"> - <i18n.Translate> - Transferred from an account to a Taler exchange. - </i18n.Translate> - </div> - </dt> - <MetricValueNumber - current={resp.current.body.talerInCount} - previous={resp.previous.body.talerInCount} - /> - </div> - <div class="px-4 py-5 sm:p-6"> - <dt class="text-base font-normal text-gray-900"> - <i18n.Translate>Payout</i18n.Translate> - <div class="text-xs text-gray-500"> - <i18n.Translate> - Transferred from a Taler exchange to another account. - </i18n.Translate> - </div> - </dt> - <MetricValueNumber - current={resp.current.body.talerOutCount} - previous={resp.previous.body.talerOutCount} - /> + <div class="mt-3 border-t border-outlineVariant pt-2 text-sm text-secondary"> + <i18n.Translate>Transfers</i18n.Translate> + <MetricValueNumber + current={resp.current.body.talerOutCount} + previous={resp.previous.body.talerOutCount} + /> + </div> </div> </dl> <div class="flex justify-end mt-4"> <a href={routeDownloadStats.url({})} name="download stats" - class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" + class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-primary px-3 py-2 text-sm font-semibold text-onPrimary shadow-sm hover:bg-primaryHover hover:text-white focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary" > <i18n.Translate>Download stats as CSV</i18n.Translate> </a> </div> - </div> + </section> ); } @@ -654,7 +611,7 @@ function MetricValueAmount({ return ( <Fragment> <dd class="mt-1 block "> - <div class="flex justify-start text-2xl items-baseline font-semibold text-indigo-600"> + <div class="flex justify-start text-2xl items-baseline font-semibold text-primary"> {!current ? ( "-" ) : ( @@ -666,7 +623,7 @@ function MetricValueAmount({ )} </div> <div class="flex flex-col"> - <div class="flex justify-end items-baseline text-2xl font-semibold text-indigo-600"> + <div class="flex justify-end items-baseline text-2xl font-semibold text-primary"> <small class="ml-2 text-sm font-medium text-gray-500"> <i18n.Translate>previous</i18n.Translate>{" "} {!previous ? ( @@ -771,11 +728,11 @@ function MetricValueNumber({ return ( <Fragment> <dd class="mt-1 block "> - <div class="flex justify-start text-2xl items-baseline font-semibold text-indigo-600"> + <div class="flex justify-start text-2xl items-baseline font-semibold text-primary"> {current === undefined ? "-" : current} </div> <div class="flex flex-col"> - <div class="flex justify-end items-baseline text-2xl font-semibold text-indigo-600"> + <div class="flex justify-end items-baseline text-2xl font-semibold text-primary"> <small class="ml-2 text-sm font-medium text-gray-500"> <i18n.Translate>previous</i18n.Translate>{" "} {previous === undefined ? "-" : previous} diff --git a/packages/libeufin-bank-webui/src/pages/admin/ConversionClassList.tsx b/packages/libeufin-bank-webui/src/pages/admin/ConversionClassList.tsx @@ -42,11 +42,13 @@ import { RetryableError } from "../../components/RetryableError.js"; interface Props { routeCreate: RouteDefinition; routeShowDetails: RouteDefinition<{ classId: string }>; + routeDefault: RouteDefinition; } export function ConversionClassList({ routeCreate, routeShowDetails, + routeDefault, }: Props): VNode { const result = useConversionRateClasses(); const { i18n } = useTranslationContext(); @@ -112,29 +114,48 @@ export function ConversionClassList({ return ( <Fragment> - <div class="px-4 sm:px-6 lg:px-8 mt-8"> + <div class="mt-2"> <div class="sm:flex sm:items-center"> <div class="sm:flex-auto"> - <h1 class="text-base font-semibold leading-6 text-gray-900"> + <h1 class="text-2xl font-semibold text-brand"> <i18n.Translate>Conversion rate classes</i18n.Translate> </h1> + <p class="mt-2 text-sm text-gray-600"> + <i18n.Translate> + Configure different cash-in and cashout terms for groups of + accounts. + </i18n.Translate> + </p> </div> - <div class="mt-4 sm:ml-16 sm:mt-0 sm:flex-none"> + <div class="mt-4 flex flex-wrap gap-3 sm:ml-16 sm:mt-0 sm:flex-none"> + <a + href={routeDefault.url({})} + class="block rounded-md bg-white px-3 py-2 text-center text-sm font-semibold text-onBackground shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50" + > + <i18n.Translate>Default conversion rate</i18n.Translate> + </a> <a href={routeCreate.url({})} name="create account" - class="block rounded-md bg-indigo-600 px-3 py-2 text-center text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" + class="block rounded-md bg-primary px-3 py-2 text-center text-sm font-semibold text-onPrimary shadow-sm hover:bg-primaryHover hover:text-white focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary" > <i18n.Translate>Create conversion rate class</i18n.Translate> </a> </div> </div> - <div class="mt-4 flow-root"> - <div class="-mx-4 -my-2 overflow-x-auto sm:-mx-6 lg:-mx-8"> - <div class="inline-block min-w-full py-2 align-middle sm:px-6 lg:px-8"> + <div class="mt-6 flow-root"> + <div class="overflow-x-auto"> + <div class="inline-block min-w-full py-2 align-middle"> {!classes.length ? ( - <div> - <i18n.Translate>No conversion rate class</i18n.Translate> + <div class="rounded-lg border border-onBackground/10 bg-white p-6 text-sm text-gray-600"> + <div class="font-semibold text-onBackground"> + <i18n.Translate>No conversion rate classes</i18n.Translate> + </div> + <p class="mt-1"> + <i18n.Translate> + All accounts currently use the default conversion rate. + </i18n.Translate> + </p> </div> ) : ( <table class="min-w-full divide-y divide-gray-300"> @@ -142,19 +163,19 @@ export function ConversionClassList({ <tr> <th scope="col" - class="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 sm:pl-0" + class="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-onBackground sm:pl-0" >{i18n.str`Name`}</th> <th scope="col" - class="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 sm:pl-0" + class="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-onBackground sm:pl-0" >{i18n.str`Description`}</th> <th scope="col" - class="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 sm:pl-0" + class="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-onBackground sm:pl-0" >{i18n.str`Cashin`}</th> <th scope="col" - class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900" + class="px-3 py-3.5 text-left text-sm font-semibold text-onBackground" >{i18n.str`Cashout`}</th> </tr> </thead> @@ -162,7 +183,7 @@ export function ConversionClassList({ {classes.map((row) => { return ( <tr key={row.conversion_rate_class_id} class=""> - <td class="whitespace-nowrap py-3 pl-4 pr-3 text-sm font-medium text-gray-900 sm:pl-0"> + <td class="whitespace-nowrap py-3 pl-4 pr-3 text-sm font-medium text-onBackground sm:pl-0"> <a href={routeShowDetails.url({ classId: String(row.conversion_rate_class_id), @@ -247,31 +268,33 @@ export function ConversionClassList({ </table> )} </div> - <nav - class="flex items-center justify-between border-t border-gray-200 bg-white px-4 py-3 sm:px-6 rounded-lg" - aria-label={i18n.str`Pagination`} - > - <div class="flex flex-1 justify-between sm:justify-end"> - <button - type="button" - name="first page" - class="relative disabled:bg-gray-100 disabled:text-gray-500 inline-flex items-center rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus-visible:outline-offset-0" - disabled={!result.loadFirst} - onClick={result.loadFirst} - > - <i18n.Translate>First page</i18n.Translate> - </button> - <button - type="button" - name="next page" - class="relative disabled:bg-gray-100 disabled:text-gray-500 ml-3 inline-flex items-center rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus-visible:outline-offset-0" - disabled={!result.loadNext} - onClick={result.loadNext} - > - <i18n.Translate>Next</i18n.Translate> - </button> - </div> - </nav> + {result.loadFirst || result.loadNext ? ( + <nav + class="flex items-center justify-between border-t border-gray-200 bg-white px-4 py-3 sm:px-6 rounded-lg" + aria-label={i18n.str`Pagination`} + > + <div class="flex flex-1 justify-between sm:justify-end"> + <button + type="button" + name="first page" + class="relative disabled:bg-gray-100 disabled:text-gray-500 inline-flex items-center rounded-md bg-white px-3 py-2 text-sm font-semibold text-onBackground ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus-visible:outline-offset-0" + disabled={!result.loadFirst} + onClick={result.loadFirst} + > + <i18n.Translate>First page</i18n.Translate> + </button> + <button + type="button" + name="next page" + class="relative disabled:bg-gray-100 disabled:text-gray-500 ml-3 inline-flex items-center rounded-md bg-white px-3 py-2 text-sm font-semibold text-onBackground ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus-visible:outline-offset-0" + disabled={!result.loadNext} + onClick={result.loadNext} + > + <i18n.Translate>Next</i18n.Translate> + </button> + </div> + </nav> + ) : undefined} </div> </div> </div> diff --git a/packages/libeufin-bank-webui/src/pages/admin/ConversionRateClassForm.tsx b/packages/libeufin-bank-webui/src/pages/admin/ConversionRateClassForm.tsx @@ -196,7 +196,7 @@ export function ConversionRateClassForm( } return ( <form - class="bg-white shadow-sm ring-1 ring-gray-900/5 sm:rounded-xl md:col-span-2" + class="mt-6 overflow-hidden rounded-xl border border-onBackground/10 bg-white shadow-sm" autoCapitalize="none" autoCorrect="off" onSubmit={(e) => { @@ -207,8 +207,8 @@ export function ConversionRateClassForm( <div class="grid max-w-2xl grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6"> <div class="sm:col-span-5"> <label - class="block text-sm font-medium leading-6 text-gray-900" - for="username" + class="block text-sm font-medium leading-6 text-onBackground" + for="class-name" > {i18n.str`Name`} {editableForm && <b class="text-[red]"> *</b>} @@ -217,9 +217,9 @@ export function ConversionRateClassForm( <input ref={focus ? doAutoFocus : undefined} type="text" - class="block w-full disabled:bg-gray-100 rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 data-[error=true]:ring-red-500 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" - name="username" - id="username" + class="block w-full disabled:bg-gray-100 rounded-md border-0 py-1.5 text-onBackground shadow-sm ring-1 ring-inset ring-gray-300 data-[error=true]:ring-red-500 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-primary sm:text-sm sm:leading-6" + name="class-name" + id="class-name" data-error={!!errors?.name && form.name !== undefined} disabled={!editableForm} value={form.name ?? ""} @@ -242,8 +242,8 @@ export function ConversionRateClassForm( <div class="sm:col-span-5"> <label - class="block text-sm font-medium leading-6 text-gray-900" - for="username" + class="block text-sm font-medium leading-6 text-onBackground" + for="class-description" > {i18n.str`Description`} </label> @@ -251,9 +251,9 @@ export function ConversionRateClassForm( <input ref={focus ? doAutoFocus : undefined} type="text" - class="block w-full disabled:bg-gray-100 rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 data-[error=true]:ring-red-500 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" - name="username" - id="username" + class="block w-full disabled:bg-gray-100 rounded-md border-0 py-1.5 text-onBackground shadow-sm ring-1 ring-inset ring-gray-300 data-[error=true]:ring-red-500 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-primary sm:text-sm sm:leading-6" + name="class-description" + id="class-description" data-error={ !!errors?.description && form.description !== undefined } @@ -309,7 +309,7 @@ export function ConversionRateClassForm( <Fragment> <div class="sm:col-span-5"> <label - class="block text-sm font-medium leading-6 text-gray-900" + class="block text-sm font-medium leading-6 text-onBackground" for="channel" > {i18n.str`Cashin rounding mode`} @@ -349,7 +349,7 @@ export function ConversionRateClassForm( defaultValue.cashin_rounding_mode) === ROUNDING_MODE } - class="relative flex data-[disabled=false]:cursor-pointer rounded-lg border bg-white data-[disabled=true]:bg-gray-200 p-4 shadow-sm focus:outline-none border-gray-300 data-[selected=true]:ring-2 data-[selected=true]:ring-indigo-600" + class="relative flex data-[disabled=false]:cursor-pointer rounded-lg border bg-white data-[disabled=true]:bg-gray-200 p-4 shadow-sm focus:outline-none border-gray-300 data-[selected=true]:ring-2 data-[selected=true]:ring-primary" > <input type="radio" @@ -361,7 +361,7 @@ export function ConversionRateClassForm( <span class="flex flex-col"> <span id="project-type-0-label" - class="block text-sm font-medium text-gray-900 " + class="block text-sm font-medium text-onBackground " > {LABEL} </span> @@ -373,7 +373,7 @@ export function ConversionRateClassForm( defaultValue.cashin_rounding_mode) === ROUNDING_MODE } - class="h-5 w-5 text-indigo-600 data-[selected=false]:hidden" + class="h-5 w-5 text-primary data-[selected=false]:hidden" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" @@ -395,7 +395,7 @@ export function ConversionRateClassForm( <div class="sm:col-span-5"> <label for="cashin_fee" - class="block text-sm font-medium leading-6 text-gray-900" + class="block text-sm font-medium leading-6 text-onBackground" >{i18n.str`Cashin fee`}</label> <InputAmount name="cashin_fee" @@ -425,7 +425,7 @@ export function ConversionRateClassForm( <div class="sm:col-span-5"> <label for="debit" - class="block text-sm font-medium leading-6 text-gray-900" + class="block text-sm font-medium leading-6 text-onBackground" >{i18n.str`Cashin min amount`}</label> <InputAmount name="debit" @@ -462,7 +462,7 @@ export function ConversionRateClassForm( <Fragment> <div class="sm:col-span-5"> <label - class="block text-sm font-medium leading-6 text-gray-900" + class="block text-sm font-medium leading-6 text-onBackground" for="channel" > {i18n.str`Cashout rounding mode`} @@ -502,7 +502,7 @@ export function ConversionRateClassForm( defaultValue.cashout_rounding_mode) === ROUNDING_MODE } - class="relative flex data-[disabled=false]:cursor-pointer rounded-lg border bg-white data-[disabled=true]:bg-gray-200 p-4 shadow-sm focus:outline-none border-gray-300 data-[selected=true]:ring-2 data-[selected=true]:ring-indigo-600" + class="relative flex data-[disabled=false]:cursor-pointer rounded-lg border bg-white data-[disabled=true]:bg-gray-200 p-4 shadow-sm focus:outline-none border-gray-300 data-[selected=true]:ring-2 data-[selected=true]:ring-primary" > <input type="radio" @@ -514,7 +514,7 @@ export function ConversionRateClassForm( <span class="flex flex-col"> <span id="project-type-0-label" - class="block text-sm font-medium text-gray-900 " + class="block text-sm font-medium text-onBackground " > {LABEL} </span> @@ -526,7 +526,7 @@ export function ConversionRateClassForm( defaultValue.cashout_rounding_mode) === ROUNDING_MODE } - class="h-5 w-5 text-indigo-600 data-[selected=false]:hidden" + class="h-5 w-5 text-primary data-[selected=false]:hidden" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" @@ -548,7 +548,7 @@ export function ConversionRateClassForm( <div class="sm:col-span-5"> <label for="cashout_min_amount" - class="block text-sm font-medium leading-6 text-gray-900" + class="block text-sm font-medium leading-6 text-onBackground" >{i18n.str`Cashout min amount`}</label> <InputAmount name="cashout_min_amount" @@ -581,7 +581,7 @@ export function ConversionRateClassForm( <div class="sm:col-span-5"> <label for="debit" - class="block text-sm font-medium leading-6 text-gray-900" + class="block text-sm font-medium leading-6 text-onBackground" >{i18n.str`Cashout fee`}</label> <InputAmount name="debit" diff --git a/packages/libeufin-bank-webui/src/pages/admin/CreateNewAccount.tsx b/packages/libeufin-bank-webui/src/pages/admin/CreateNewAccount.tsx @@ -31,6 +31,7 @@ import { import { Fragment, VNode, h } from "preact"; import { useState } from "preact/hooks"; import { useSessionState } from "../../hooks/session.js"; +import { OperationError } from "../../components/OperationError.js"; import { AccountForm } from "./AccountForm.js"; export function CreateNewAccount({ @@ -112,7 +113,7 @@ export function CreateNewAccount({ <a href={routeCancel.url({})} name="close" - class="inline-flex w-full justify-center rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" + class="inline-flex w-full justify-center rounded-md bg-primary px-3 py-2 text-sm font-semibold text-onPrimary shadow-sm hover:bg-primaryHover hover:text-white focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary" > <i18n.Translate>Close</i18n.Translate> </a> @@ -122,40 +123,62 @@ 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"> - <div class="px-4 sm:px-0"> - <h2 class="text-base font-semibold leading-7 text-gray-900"> - <i18n.Translate>New bank account</i18n.Translate> - </h2> - </div> - <AccountForm - template={undefined} - purpose="create" - onChange={(a) => { - setSubmitAccount(a); - }} + <section class="mt-2"> + <a + href={routeCancel.url({})} + class="inline-flex items-center text-sm font-semibold text-brand hover:underline" > - <div class="flex items-center justify-between gap-x-6 border-t border-gray-900/10 px-4 py-4 sm:px-8"> - <a - href={routeCancel.url({})} - name="cancel" - class="text-sm font-semibold leading-6 text-gray-900" - > - <i18n.Translate>Cancel</i18n.Translate> - </a> - <AsyncButton - submit - name="create" - class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" - disabled={!submitAccount || !token} - onClick={() => - create.run({ type: "bearer", token: token! }, submitAccount!) - } + <span class="mr-1" aria-hidden="true"> + ← + </span> + <i18n.Translate>Back to accounts</i18n.Translate> + </a> + <div class="mx-auto mt-6 max-w-4xl"> + <h1 class="text-2xl font-semibold text-brand"> + <i18n.Translate>New bank account</i18n.Translate> + </h1> + <p class="mt-2 text-sm text-gray-600"> + <i18n.Translate> + Create an account and configure its initial access and profile. + </i18n.Translate> + </p> + <div class="mt-6"> + <AccountForm + template={undefined} + purpose="create" + onChange={(a) => { + setSubmitAccount(a); + }} > - <i18n.Translate>Create</i18n.Translate> - </AsyncButton> + <div class="border-t border-onBackground/10 bg-background/60 px-5 py-4 sm:px-8"> + <OperationError class="mb-4" /> + <div class="flex items-center justify-between gap-x-6"> + <a + href={routeCancel.url({})} + name="cancel" + class="text-sm font-semibold leading-6 text-onBackground hover:underline" + > + <i18n.Translate>Cancel</i18n.Translate> + </a> + <AsyncButton + submit + name="create" + class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-primary px-3 py-2 text-sm font-semibold text-onPrimary shadow-sm hover:bg-primaryHover hover:text-white focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary" + disabled={!submitAccount || !token} + onClick={() => + create.run( + { type: "bearer", token: token! }, + submitAccount!, + ) + } + > + <i18n.Translate>Create account</i18n.Translate> + </AsyncButton> + </div> + </div> + </AccountForm> </div> - </AccountForm> - </div> + </div> + </section> ); } diff --git a/packages/libeufin-bank-webui/src/pages/admin/DownloadStats.tsx b/packages/libeufin-bank-webui/src/pages/admin/DownloadStats.tsx @@ -37,6 +37,7 @@ import { VNode, h } from "preact"; import { useState } from "preact/hooks"; import { useSessionState } from "../../hooks/session.js"; import { getTimeframesForDate } from "./AdminHome.js"; +import { OperationError } from "../../components/OperationError.js"; interface Props { routeCancel: RouteDefinition; @@ -117,16 +118,28 @@ 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"> - <div class="px-4 sm:px-0"> - <h2 class="text-base font-semibold leading-7 text-gray-900"> - <i18n.Translate>Download bank stats</i18n.Translate> - </h2> - </div> + <section class="mt-2"> + <a + href={routeCancel.url({})} + class="inline-flex items-center text-sm font-semibold text-brand hover:underline" + > + <span class="mr-1" aria-hidden="true"> + ← + </span> + <i18n.Translate>Back to dashboard</i18n.Translate> + </a> + <div class="mx-auto mt-6 max-w-2xl"> + <h1 class="text-2xl font-semibold text-brand"> + <i18n.Translate>Download bank statistics</i18n.Translate> + </h1> + <p class="mt-2 text-sm text-gray-600"> + <i18n.Translate> + Choose which reporting periods to include in the CSV file. + </i18n.Translate> + </p> <form - class="bg-white shadow-sm ring-1 ring-gray-900/5 sm:rounded-xl md:col-span-2" + class="mt-6 overflow-hidden rounded-xl border border-onBackground/10 bg-white shadow-sm" autoCapitalize="none" autoCorrect="off" onSubmit={(e) => { @@ -139,7 +152,7 @@ export function DownloadStats({ routeCancel }: Props): VNode { <div class="flex items-center justify-between"> <span class="flex flex-grow flex-col"> <span - class="text-sm text-black font-medium leading-6 " + class="text-sm text-onBackground font-medium leading-6 " id="hour-metric-label" > <i18n.Translate>Include hour metric</i18n.Translate> @@ -149,7 +162,7 @@ export function DownloadStats({ routeCancel }: Props): VNode { type="button" name={`hour switch`} data-enabled={options.hourMetric} - class="bg-indigo-600 data-[enabled=false]:bg-gray-200 relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-indigo-600 focus:ring-offset-2" + class="bg-primary data-[enabled=false]:bg-gray-200 relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2" role="switch" aria-checked={options.hourMetric} aria-labelledby="hour-metric-label" @@ -172,7 +185,7 @@ export function DownloadStats({ routeCancel }: Props): VNode { <div class="flex items-center justify-between"> <span class="flex flex-grow flex-col"> <span - class="text-sm text-black font-medium leading-6 " + class="text-sm text-onBackground font-medium leading-6 " id="day-metric-label" > <i18n.Translate>Include day metric</i18n.Translate> @@ -182,7 +195,7 @@ export function DownloadStats({ routeCancel }: Props): VNode { type="button" name={`day switch`} data-enabled={!!options.dayMetric} - class="bg-indigo-600 data-[enabled=false]:bg-gray-200 relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-indigo-600 focus:ring-offset-2" + class="bg-primary data-[enabled=false]:bg-gray-200 relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2" role="switch" aria-checked={options.dayMetric} aria-labelledby="day-metric-label" @@ -202,7 +215,7 @@ export function DownloadStats({ routeCancel }: Props): VNode { <div class="flex items-center justify-between"> <span class="flex flex-grow flex-col"> <span - class="text-sm text-black font-medium leading-6 " + class="text-sm text-onBackground font-medium leading-6 " id="month-metric-label" > <i18n.Translate>Include month metric</i18n.Translate> @@ -212,7 +225,7 @@ export function DownloadStats({ routeCancel }: Props): VNode { type="button" name={`month switch`} data-enabled={!!options.monthMetric} - class="bg-indigo-600 data-[enabled=false]:bg-gray-200 relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-indigo-600 focus:ring-offset-2" + class="bg-primary data-[enabled=false]:bg-gray-200 relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2" role="switch" aria-checked={options.monthMetric} aria-labelledby="month-metric-label" @@ -235,7 +248,7 @@ export function DownloadStats({ routeCancel }: Props): VNode { <div class="flex items-center justify-between"> <span class="flex flex-grow flex-col"> <span - class="text-sm text-black font-medium leading-6 " + class="text-sm text-onBackground font-medium leading-6 " id="year-metric-label" > <i18n.Translate>Include year metric</i18n.Translate> @@ -245,7 +258,7 @@ export function DownloadStats({ routeCancel }: Props): VNode { type="button" name={`year switch`} data-enabled={!!options.yearMetric} - class="bg-indigo-600 data-[enabled=false]:bg-gray-200 relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-indigo-600 focus:ring-offset-2" + class="bg-primary data-[enabled=false]:bg-gray-200 relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2" role="switch" aria-checked={options.yearMetric} aria-labelledby="year-metric-label" @@ -268,7 +281,7 @@ export function DownloadStats({ routeCancel }: Props): VNode { <div class="flex items-center justify-between"> <span class="flex flex-grow flex-col"> <span - class="text-sm text-black font-medium leading-6 " + class="text-sm text-onBackground font-medium leading-6 " id="include-header-label" > <i18n.Translate>Include table header</i18n.Translate> @@ -278,7 +291,7 @@ export function DownloadStats({ routeCancel }: Props): VNode { type="button" name={`header switch`} data-enabled={!!options.includeHeader} - class="bg-indigo-600 data-[enabled=false]:bg-gray-200 relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-indigo-600 focus:ring-offset-2" + class="bg-primary data-[enabled=false]:bg-gray-200 relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2" role="switch" aria-checked={options.includeHeader} aria-labelledby="include-header-label" @@ -301,7 +314,7 @@ export function DownloadStats({ routeCancel }: Props): VNode { <div class="flex items-center justify-between"> <span class="flex flex-grow flex-col"> <span - class="text-sm text-black font-medium leading-6 " + class="text-sm text-onBackground font-medium leading-6 " id="compare-previous-label" > <i18n.Translate> @@ -313,7 +326,7 @@ export function DownloadStats({ routeCancel }: Props): VNode { type="button" name={`compare switch`} data-enabled={!!options.compareWithPrevious} - class="bg-indigo-600 data-[enabled=false]:bg-gray-200 relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-indigo-600 focus:ring-offset-2" + class="bg-primary data-[enabled=false]:bg-gray-200 relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2" role="switch" aria-checked={options.compareWithPrevious} aria-labelledby="compare-previous-label" @@ -336,7 +349,7 @@ export function DownloadStats({ routeCancel }: Props): VNode { <div class="flex items-center justify-between"> <span class="flex flex-grow flex-col"> <span - class="text-sm text-black font-medium leading-6 " + class="text-sm text-onBackground font-medium leading-6 " id="end-on-failure-label" > <i18n.Translate>Fail on first error</i18n.Translate> @@ -346,7 +359,7 @@ export function DownloadStats({ routeCancel }: Props): VNode { type="button" name={`fail switch`} data-enabled={!!options.endOnFirstFail} - class="bg-indigo-600 data-[enabled=false]:bg-gray-200 relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-indigo-600 focus:ring-offset-2" + class="bg-primary data-[enabled=false]:bg-gray-200 relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2" role="switch" aria-checked={options.endOnFirstFail} aria-labelledby="end-on-failure-label" @@ -368,80 +381,86 @@ export function DownloadStats({ routeCancel }: Props): VNode { </div> </div> - <div class="flex items-center justify-between gap-x-6 border-t border-gray-900/10 px-4 py-4 sm:px-8"> - <a - name="cancel" - href={routeCancel.url({})} - class="text-sm font-semibold leading-6 text-gray-900" - > - <i18n.Translate>Cancel</i18n.Translate> - </a> - <AsyncButton - submit - name="download" - class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" - disabled={lastStep !== undefined || !creds} - onClick={() => download.run(creds!.token)} - > - <i18n.Translate>Download</i18n.Translate> - </AsyncButton> + <div class="border-t border-onBackground/10 bg-background/60 px-5 py-4 sm:px-8"> + <OperationError class="mb-4" /> + <div class="flex items-center justify-between gap-x-6"> + <a + name="cancel" + href={routeCancel.url({})} + class="text-sm font-semibold leading-6 text-onBackground" + > + <i18n.Translate>Cancel</i18n.Translate> + </a> + <AsyncButton + submit + name="download" + class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-primary px-3 py-2 text-sm font-semibold text-onPrimary shadow-sm hover:bg-primaryHover hover:text-white focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary" + disabled={lastStep !== undefined || !creds} + onClick={() => download.run(creds!.token)} + > + <i18n.Translate>Download</i18n.Translate> + </AsyncButton> + </div> </div> </form> - </div> - {!lastStep || lastStep.step === lastStep.total ? ( - <div class="h-5 mb-5" /> - ) : ( - <div> - <div class="relative mb-5 h-5 rounded-full bg-gray-200"> - <div - class={`h-full animate-pulse rounded-full bg-blue-500 w-[${Math.round( - (lastStep.step / lastStep.total) * 100, - )}%]`} - > - <span class="absolute inset-0 flex items-center justify-center text-xs font-semibold text-white"> - <i18n.Translate> - downloading...{" "} - {Math.round((lastStep.step / lastStep.total) * 100)} - </i18n.Translate> - </span> + {!lastStep || lastStep.step === lastStep.total ? ( + <div class="h-5 mb-5" /> + ) : ( + <div> + <div class="relative mb-5 h-5 rounded-full bg-gray-200"> + <div + class="h-full animate-pulse rounded-full bg-primary" + style={{ + width: `${Math.round( + (lastStep.step / lastStep.total) * 100, + )}%`, + }} + > + <span class="absolute inset-0 flex items-center justify-center text-xs font-semibold text-white"> + <i18n.Translate> + downloading...{" "} + {Math.round((lastStep.step / lastStep.total) * 100)} + </i18n.Translate> + </span> + </div> </div> </div> - </div> - )} - {!downloaded ? ( - <div class="h-5 mb-5" /> - ) : ( - <a - href={ - "data:text/plain;charset=utf-8," + - encodeURIComponent(downloaded.csv) - } - name="save file" - download={"bank-stats.csv"} - > - <Attention - type={downloaded.failures.length ? "warning" : undefined} - title={ - downloaded.failures.length - ? i18n.str`Download completed with missing data` - : i18n.str`Download completed` + )} + {!downloaded ? ( + <div class="h-5 mb-5" /> + ) : ( + <a + href={ + "data:text/plain;charset=utf-8," + + encodeURIComponent(downloaded.csv) } + name="save file" + download={"bank-stats.csv"} > - {downloaded.failures.length ? ( - <i18n.Translate> - {downloaded.failures.length} statistics requests failed. The - file contains only the data that could be retrieved. Click here - to save it. - </i18n.Translate> - ) : ( - <i18n.Translate> - Click here to save the file on your computer. - </i18n.Translate> - )} - </Attention> - </a> - )} - </div> + <Attention + type={downloaded.failures.length ? "warning" : undefined} + title={ + downloaded.failures.length + ? i18n.str`Download completed with missing data` + : i18n.str`Download completed` + } + > + {downloaded.failures.length ? ( + <i18n.Translate> + {downloaded.failures.length} statistics requests failed. The + file contains only the data that could be retrieved. Click + here to save it. + </i18n.Translate> + ) : ( + <i18n.Translate> + Click here to save the file on your computer. + </i18n.Translate> + )} + </Attention> + </a> + )} + </div> + </section> ); } diff --git a/packages/libeufin-bank-webui/src/pages/admin/RemoveAccount.tsx b/packages/libeufin-bank-webui/src/pages/admin/RemoveAccount.tsx @@ -41,6 +41,7 @@ import { useAccountDetails, } from "../../hooks/account.js"; import { useSessionState } from "../../hooks/session.js"; +import { OperationError } from "../../components/OperationError.js"; import { undefinedIfEmpty } from "../../utils.js"; import { LoginForm } from "../LoginForm.js"; import { doAutoFocus } from "../PaytoWireTransferForm.js"; @@ -170,7 +171,7 @@ export function RemoveAccount({ <a href={routeCancel.url({})} name="close" - class="inline-flex w-full justify-center rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" + class="inline-flex w-full justify-center rounded-md bg-primary px-3 py-2 text-sm font-semibold text-onPrimary shadow-sm hover:bg-primaryHover hover:text-white focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary" > <i18n.Translate>Close</i18n.Translate> </a> @@ -180,22 +181,33 @@ export function RemoveAccount({ } return ( - <div> - <Attention - type="warning" - title={i18n.str`You are going to remove the account`} + <section class="mt-2"> + <a + href={routeCancel.url({})} + class="inline-flex items-center text-sm font-semibold text-brand hover:underline" > - <i18n.Translate>This step can't be undone.</i18n.Translate> - </Attention> - - <div class="grid grid-cols-1 gap-x-8 gap-y-8 pt-6 md:grid-cols-3 bg-gray-100 my-4 px-4 pb-4 rounded-lg"> - <div class="px-4 sm:px-0"> - <h2 class="text-base font-semibold leading-7 text-gray-900"> - <i18n.Translate>Deleting account "{account}"</i18n.Translate> - </h2> + <span class="mr-1" aria-hidden="true"> + ← + </span> + <i18n.Translate>Go back</i18n.Translate> + </a> + <div class="mx-auto mt-6 max-w-2xl"> + <h1 class="text-2xl font-semibold text-brand"> + <i18n.Translate>Delete account "{account}"</i18n.Translate> + </h1> + <div class="mt-6"> + <Attention + type="warning" + title={i18n.str`This action cannot be undone`} + > + <i18n.Translate> + All account data will be permanently removed. + </i18n.Translate> + </Attention> </div> + <form - class="bg-white shadow-sm ring-1 ring-gray-900/5 sm:rounded-xl md:col-span-2" + class="mt-6 overflow-hidden rounded-xl border border-onBackground/10 bg-white shadow-sm" autoCapitalize="none" autoCorrect="off" onSubmit={(e) => { @@ -206,7 +218,7 @@ export function RemoveAccount({ <div class="grid max-w-2xl grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6"> <div class="sm:col-span-5"> <label - class="block text-sm font-medium leading-6 text-gray-900" + class="block text-sm font-medium leading-6 text-onBackground" for="password" > {i18n.str`Verification`} @@ -215,7 +227,7 @@ export function RemoveAccount({ <input ref={focus ? doAutoFocus : undefined} type="text" - class="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 data-[error=true]:ring-red-500 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" + class="block w-full rounded-md border-0 py-1.5 text-onBackground shadow-sm ring-1 ring-inset ring-gray-300 data-[error=true]:ring-red-500 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-primary sm:text-sm sm:leading-6" name="password" id="password" data-error={ @@ -241,28 +253,31 @@ export function RemoveAccount({ </div> </div> </div> - <div class="flex items-center justify-between gap-x-6 border-t border-gray-900/10 px-4 py-4 sm:px-8"> - <a - href={routeCancel.url({})} - name="cancel" - class="text-sm font-semibold leading-6 text-gray-900" - > - <i18n.Translate>Cancel</i18n.Translate> - </a> - <AsyncButton - submit - name="delete" - class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-red-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-600" - disabled={!!errors || !token} - onClick={() => - deleteAccount.run({ username: account, token: token! }) - } - > - <i18n.Translate>Delete</i18n.Translate> - </AsyncButton> + <div class="border-t border-onBackground/10 bg-background/60 px-5 py-4 sm:px-8"> + <OperationError class="mb-4" /> + <div class="flex items-center justify-between gap-x-6"> + <a + href={routeCancel.url({})} + name="cancel" + class="text-sm font-semibold leading-6 text-onBackground" + > + <i18n.Translate>Cancel</i18n.Translate> + </a> + <AsyncButton + submit + name="delete" + class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-red-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-600" + disabled={!!errors || !token} + onClick={() => + deleteAccount.run({ username: account, token: token! }) + } + > + <i18n.Translate>Delete account</i18n.Translate> + </AsyncButton> + </div> </div> </form> </div> - </div> + </section> ); } diff --git a/packages/libeufin-bank-webui/src/pages/auth-validation.test.ts b/packages/libeufin-bank-webui/src/pages/auth-validation.test.ts @@ -0,0 +1,63 @@ +/* + This file is part of GNU Taler + (C) 2026 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. +*/ + +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { + PASSWORD_MAX_LENGTH, + PASSWORD_MIN_LENGTH, + USERNAME_MAX_LENGTH, + isBlankName, + validatePassword, + validateUsername, +} from "./auth-validation.js"; + +describe("authentication form validation", () => { + it("matches the Core Bank username grammar and length", () => { + assert.equal(validateUsername(""), "missing"); + assert.equal(validateUsername("alice@example"), "invalid"); + assert.equal(validateUsername("alice.test_~"), undefined); + assert.equal(validateUsername("a".repeat(USERNAME_MAX_LENGTH)), undefined); + assert.equal( + validateUsername("a".repeat(USERNAME_MAX_LENGTH + 1)), + "too-long", + ); + }); + + it("enforces registration password boundaries", () => { + assert.equal(validatePassword("", true), "missing"); + assert.equal( + validatePassword("a".repeat(PASSWORD_MIN_LENGTH - 1), true), + "too-short", + ); + assert.equal( + validatePassword("a".repeat(PASSWORD_MIN_LENGTH), true), + undefined, + ); + assert.equal( + validatePassword("a".repeat(PASSWORD_MAX_LENGTH), true), + undefined, + ); + assert.equal( + validatePassword("a".repeat(PASSWORD_MAX_LENGTH + 1), true), + "too-long", + ); + }); + + it("can validate presence without imposing creation-time length rules", () => { + assert.equal(validatePassword("short", false), undefined); + assert.equal(validatePassword("", false), "missing"); + }); + + it("rejects empty and whitespace-only names", () => { + assert.equal(isBlankName(""), true); + assert.equal(isBlankName(" \n"), true); + assert.equal(isBlankName("Alice Example"), false); + }); +}); diff --git a/packages/libeufin-bank-webui/src/pages/auth-validation.ts b/packages/libeufin-bank-webui/src/pages/auth-validation.ts @@ -0,0 +1,43 @@ +/* + This file is part of GNU Taler + (C) 2026 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. +*/ + +export const USERNAME_MAX_LENGTH = 126; +export const PASSWORD_MIN_LENGTH = 8; +export const PASSWORD_MAX_LENGTH = 64; + +export type UsernameError = "missing" | "invalid" | "too-long"; +export type PasswordError = "missing" | "too-short" | "too-long"; + +const USERNAME_REGEX = /^[a-zA-Z0-9._~-]+$/; + +export function validateUsername(value: string): UsernameError | undefined { + if (!value) return "missing"; + if (value.length > USERNAME_MAX_LENGTH) return "too-long"; + if (!USERNAME_REGEX.test(value)) return "invalid"; + return undefined; +} + +export function validatePassword( + value: string, + enforceLength: boolean, +): PasswordError | undefined { + if (!value) return "missing"; + if (!enforceLength) return undefined; + if (value.length < PASSWORD_MIN_LENGTH) return "too-short"; + if (value.length > PASSWORD_MAX_LENGTH) return "too-long"; + return undefined; +} + +export function isBlankName(value: string): boolean { + return value.trim().length === 0; +} diff --git a/packages/libeufin-bank-webui/src/pages/index.stories.tsx b/packages/libeufin-bank-webui/src/pages/index.stories.tsx @@ -19,5 +19,9 @@ export * as ptf from "./PaytoWireTransferForm.stories.js"; export * as frame from "./BankFrame.stories.js"; export * as account from "./AccountPage/stories.js"; export * as operation from "./OperationState/stories.js"; +export * as walletWithdrawal from "./WalletWithdrawal.stories.js"; export * as authentication from "./Authentication.stories.js"; export * as accountForm from "./admin/AccountForm.stories.js"; +export * as developerSettings from "./DeveloperSettings.stories.js"; +export * as profileNavigation from "./ProfileNavigation.stories.js"; +export * as merchantIntegration from "./account/MerchantIntegration.stories.js"; diff --git a/packages/libeufin-bank-webui/src/pages/regional/CashoutCreatePage.tsx b/packages/libeufin-bank-webui/src/pages/regional/CashoutCreatePage.tsx @@ -0,0 +1,67 @@ +/* + This file is part of GNU Taler + (C) 2026 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. +*/ + +import { + RouteDefinition, + useTranslationContext, +} from "@gnu-taler/web-util/browser"; +import { VNode, h } from "preact"; +import { CreateCashout } from "./CreateCashout.js"; + +export function CashoutCreatePage({ + account, + routeClose, + routeHistory, + onCashout, +}: { + account: string; + routeClose: RouteDefinition; + routeHistory: RouteDefinition; + onCashout(): void; +}): VNode { + const { i18n } = useTranslationContext(); + return ( + <section class="mt-2" aria-labelledby="cashout-heading"> + <a + href={routeClose.url({})} + class="inline-flex items-center text-sm font-semibold text-brand hover:underline" + > + <span class="mr-1" aria-hidden="true"> + ← + </span> + <i18n.Translate>Back to overview</i18n.Translate> + </a> + <div class="mx-auto mt-6 max-w-3xl"> + <div class="flex flex-wrap items-start justify-between gap-3"> + <div> + <h1 id="cashout-heading" class="text-2xl font-semibold text-brand"> + <i18n.Translate>Cash out</i18n.Translate> + </h1> + <p class="mt-2 text-sm text-gray-600"> + <i18n.Translate> + Convert regional currency and send it to your cashout account. + </i18n.Translate> + </p> + </div> + <a + href={routeHistory.url({})} + class="text-sm font-semibold text-brand hover:underline" + > + <i18n.Translate>View previous cashouts</i18n.Translate> + </a> + </div> + <CreateCashout + account={account} + routeClose={routeClose} + onCashout={onCashout} + /> + </div> + </section> + ); +} diff --git a/packages/libeufin-bank-webui/src/pages/regional/ConversionConfig.tsx b/packages/libeufin-bank-webui/src/pages/regional/ConversionConfig.tsx @@ -57,19 +57,14 @@ import { import { useSessionState } from "../../hooks/session.js"; import { undefinedIfEmpty } from "../../utils.js"; import { InputAmount } from "../PaytoWireTransferForm.js"; -import { ProfileNavigation } from "../ProfileNavigation.js"; import { TalerErrorCode, opFixedSuccess } from "@gnu-taler/taler-util"; import { dummyHttpResponse } from "@gnu-taler/taler-util/http"; import { DescribeConversion } from "../admin/ConversionClassList.js"; import { RetryableError } from "../../components/RetryableError.js"; +import { OperationError } from "../../components/OperationError.js"; interface Props { - routeMyAccountDetails: RouteDefinition; - routeMyAccountDelete: RouteDefinition; - routeMyAccountPassword: RouteDefinition; - routeMyAccountCashout: RouteDefinition; - routeConversionConfig: RouteDefinition; routeCancel: RouteDefinition; onUpdateSuccess: () => void; } @@ -81,11 +76,6 @@ type FormType = { function useComponentState({ routeCancel, - routeConversionConfig, - routeMyAccountCashout, - routeMyAccountDelete, - routeMyAccountDetails, - routeMyAccountPassword, onUpdateSuccess, }: Props): utils.RecursiveState<VNode> { const { i18n } = useTranslationContext(); @@ -310,359 +300,357 @@ function useComponentState({ const both_low = in_ratio < 1 && out_ratio < 1; return ( - <div> - <ProfileNavigation - current="conversion" - routeMyAccountCashout={routeMyAccountCashout} - routeMyAccountDelete={routeMyAccountDelete} - routeMyAccountDetails={routeMyAccountDetails} - routeMyAccountPassword={routeMyAccountPassword} - routeConversionConfig={routeConversionConfig} - /> - - <div class="grid grid-cols-1 gap-x-8 gap-y-8 pt-6 md:grid-cols-3 bg-gray-100 my-4 px-4 pb-4 rounded-lg"> - <div class="px-4 sm:px-0"> - <h2 class="text-base font-semibold leading-7 text-gray-900"> - <i18n.Translate>Conversion</i18n.Translate> - </h2> - <div class="px-2 mt-2 grid grid-cols-1 gap-y-4 sm:gap-x-4"> - <label - aria-label={i18n.str`Details`} - data-enabled={section === "detail"} - class="relative flex cursor-pointer rounded-lg border bg-white p-4 shadow-sm focus:outline-none border-gray-300 data-[enabled=true]:border-indigo-600 data-[enabled=true]:ring-2 data-[enabled=true]:ring-indigo-600" - > - <input - type="radio" - name="project-type" - value="detail" - checked={section === "detail"} - class="sr-only" - onChange={() => { - setSection("detail"); - }} - /> - <span class="flex flex-1"> - <span class="flex flex-col"> - <span class="block text-sm font-medium text-gray-900"> - <i18n.Translate>Details</i18n.Translate> - </span> - </span> - </span> - </label> - - <label - aria-label={i18n.str`Config cashout`} - data-enabled={section === "cashout"} - class="relative flex cursor-pointer rounded-lg border bg-white p-4 shadow-sm focus:outline-none border-gray-300 -- data-[enabled=true]:border-indigo-600 data-[enabled=true]:ring-2 data-[enabled=true]:ring-indigo-600" - > - <input - type="radio" - name="project-type" - value="cashout" - checked={section === "cashout"} - class="sr-only" - onChange={() => { - setSection("cashout"); - }} - /> - <span class="flex flex-1"> - <span class="flex flex-col"> - <span class="block text-sm font-medium text-gray-900"> - <i18n.Translate>Config cashout</i18n.Translate> - </span> - </span> - </span> - </label> - <label - aria-label={i18n.str`Config cashin`} - data-enabled={section === "cashin"} - class="relative flex cursor-pointer rounded-lg border bg-white p-4 shadow-sm focus:outline-none border-gray-300 -- data-[enabled=true]:border-indigo-600 data-[enabled=true]:ring-2 data-[enabled=true]:ring-indigo-600" - > - <input - type="radio" - name="project-type" - value="cashin" - checked={section === "cashin"} - class="sr-only" - onChange={() => { - setSection("cashin"); - }} - /> - <span class="flex flex-1"> - <span class="flex flex-col"> - <span class="block text-sm font-medium text-gray-900"> - <i18n.Translate>Config cashin</i18n.Translate> - </span> - </span> - </span> - </label> - </div> - </div> + <section class="mt-2"> + <a + name="cancel" + href={routeCancel.url({})} + class="inline-flex items-center text-sm font-semibold text-brand hover:underline" + > + <span class="mr-1" aria-hidden="true"> + ← + </span> + <i18n.Translate>Back to conversion settings</i18n.Translate> + </a> + <div class="mx-auto mt-6 max-w-4xl"> + <h1 class="text-2xl font-semibold text-brand"> + <i18n.Translate>Default conversion rate</i18n.Translate> + </h1> + <p class="mt-2 text-sm text-gray-600"> + <i18n.Translate> + Review the default terms or choose a direction to update its + settings. + </i18n.Translate> + </p> + + <div class="mt-6 overflow-hidden rounded-xl border border-onBackground/10 bg-white shadow-sm"> + <fieldset class="border-b border-onBackground/10 bg-primary/5 px-5 py-5 sm:px-8"> + <legend class="sr-only"> + <i18n.Translate>Conversion view</i18n.Translate> + </legend> + <div class="text-sm font-semibold text-onBackground"> + <i18n.Translate>Conversion view</i18n.Translate> + </div> + <div class="mt-3 inline-flex flex-wrap rounded-lg bg-onBackground/5 p-1"> + <label + data-enabled={section === "detail"} + class="cursor-pointer rounded-md px-3 py-2 text-sm font-medium text-gray-600 data-[enabled=true]:bg-white data-[enabled=true]:text-brand data-[enabled=true]:shadow-sm" + > + <input + type="radio" + name="project-type" + value="detail" + checked={section === "detail"} + class="sr-only" + onChange={() => { + setSection("detail"); + }} + /> + <i18n.Translate>Details</i18n.Translate> + </label> - <form - class="bg-white shadow-sm ring-1 ring-gray-900/5 sm:rounded-xl md:col-span-2" - autoCapitalize="none" - autoCorrect="off" - onSubmit={(e) => { - e.preventDefault(); - }} - > - {section == "cashin" && ( - <ConversionForm - id="cashin" - inputCurrency={info.fiat_currency} - outputCurrency={info.regional_currency} - fee={form?.conv?.cashin_fee} - minimum={form?.conv?.cashin_min_amount} - ratio={form?.conv?.cashin_ratio} - rounding={form?.conv?.cashin_rounding_mode} - tiny={form?.conv?.cashin_tiny_amount} - /> - )} - - {section == "cashout" && ( - <Fragment> + <label + data-enabled={section === "cashout"} + class="cursor-pointer rounded-md px-3 py-2 text-sm font-medium text-gray-600 data-[enabled=true]:bg-white data-[enabled=true]:text-brand data-[enabled=true]:shadow-sm" + > + <input + type="radio" + name="project-type" + value="cashout" + checked={section === "cashout"} + class="sr-only" + onChange={() => { + setSection("cashout"); + }} + /> + <i18n.Translate>Cashout settings</i18n.Translate> + </label> + <label + data-enabled={section === "cashin"} + class="cursor-pointer rounded-md px-3 py-2 text-sm font-medium text-gray-600 data-[enabled=true]:bg-white data-[enabled=true]:text-brand data-[enabled=true]:shadow-sm" + > + <input + type="radio" + name="project-type" + value="cashin" + checked={section === "cashin"} + class="sr-only" + onChange={() => { + setSection("cashin"); + }} + /> + <i18n.Translate>Cash-in settings</i18n.Translate> + </label> + </div> + </fieldset> + + <form + class="bg-white" + autoCapitalize="none" + autoCorrect="off" + onSubmit={(e) => { + e.preventDefault(); + }} + > + {section == "cashin" && ( <ConversionForm - id="cashout" - inputCurrency={info.regional_currency} - outputCurrency={info.fiat_currency} - fee={form?.conv?.cashout_fee} - minimum={form?.conv?.cashout_min_amount} - ratio={form?.conv?.cashout_ratio} - rounding={form?.conv?.cashout_rounding_mode} - tiny={form?.conv?.cashout_tiny_amount} + id="cashin" + inputCurrency={info.fiat_currency} + outputCurrency={info.regional_currency} + fee={form?.conv?.cashin_fee} + minimum={form?.conv?.cashin_min_amount} + ratio={form?.conv?.cashin_ratio} + rounding={form?.conv?.cashin_rounding_mode} + tiny={form?.conv?.cashin_tiny_amount} /> - </Fragment> - )} - - {section == "detail" && ( - <Fragment> - <div class="px-6 pt-6"> - <div class="justify-between items-center flex "> - <dt class="text-sm text-gray-600"> - <i18n.Translate>Cashin</i18n.Translate> - </dt> - <dd class="text-sm text-gray-900"> - <DescribeConversion - ratio={info.conversion_rate.cashin_ratio} - fee={info.conversion_rate.cashin_fee} - min={info.conversion_rate.cashin_min_amount} - rounding={info.conversion_rate.cashin_rounding_mode} - minSpec={info.fiat_currency_specification} - feeSpec={info.regional_currency_specification} - /> - </dd> - </div> - </div> + )} + + {section == "cashout" && ( + <Fragment> + <ConversionForm + id="cashout" + inputCurrency={info.regional_currency} + outputCurrency={info.fiat_currency} + fee={form?.conv?.cashout_fee} + minimum={form?.conv?.cashout_min_amount} + ratio={form?.conv?.cashout_ratio} + rounding={form?.conv?.cashout_rounding_mode} + tiny={form?.conv?.cashout_tiny_amount} + /> + </Fragment> + )} - <div class="px-6 pt-6"> - <div class="justify-between items-center flex "> - <dt class="text-sm text-gray-600"> - <i18n.Translate>Cashout</i18n.Translate> - </dt> - <dd class="text-sm text-gray-900"> - <DescribeConversion - ratio={info.conversion_rate.cashout_ratio} - fee={info.conversion_rate.cashout_fee} - min={info.conversion_rate.cashout_min_amount} - rounding={info.conversion_rate.cashout_rounding_mode} - minSpec={info.regional_currency_specification} - feeSpec={info.fiat_currency_specification} - /> - </dd> + {section == "detail" && ( + <Fragment> + <div class="px-6 pt-6"> + <div class="justify-between items-center flex "> + <dt class="text-sm text-gray-600"> + <i18n.Translate>Cashin</i18n.Translate> + </dt> + <dd class="text-sm text-onBackground"> + <DescribeConversion + ratio={info.conversion_rate.cashin_ratio} + fee={info.conversion_rate.cashin_fee} + min={info.conversion_rate.cashin_min_amount} + rounding={info.conversion_rate.cashin_rounding_mode} + minSpec={info.fiat_currency_specification} + feeSpec={info.regional_currency_specification} + /> + </dd> + </div> </div> - </div> - {both_low || both_high ? ( - <div class="p-4"> - <Attention title={i18n.str`Bad ratios`} type="warning"> - <i18n.Translate> - One of the ratios should be higher or equal than 1 and - the other should be lower or equal than 1. - </i18n.Translate> - </Attention> + <div class="px-6 pt-6"> + <div class="justify-between items-center flex "> + <dt class="text-sm text-gray-600"> + <i18n.Translate>Cashout</i18n.Translate> + </dt> + <dd class="text-sm text-onBackground"> + <DescribeConversion + ratio={info.conversion_rate.cashout_ratio} + fee={info.conversion_rate.cashout_fee} + min={info.conversion_rate.cashout_min_amount} + rounding={info.conversion_rate.cashout_rounding_mode} + minSpec={info.regional_currency_specification} + feeSpec={info.fiat_currency_specification} + /> + </dd> + </div> </div> - ) : undefined} - - <div class="px-6 pt-6"> - <div class="grid max-w-2xl grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6"> - <div class="sm:col-span-5"> - <label - for="amount" - class="block text-sm font-medium leading-6 text-gray-900" - >{i18n.str`Initial amount`}</label> - <InputAmount - name="amount" - left - currency={info.fiat_currency} - value={form.amount?.value ?? ""} - onChange={form.amount?.onUpdate} - /> - <ShowInputErrorLabel - message={form.amount?.error} - isDirty={form.amount?.value !== undefined} - /> - <p class="mt-2 text-sm text-gray-500"> + + {both_low || both_high ? ( + <div class="p-4"> + <Attention title={i18n.str`Bad ratios`} type="warning"> <i18n.Translate> - Use it to test how the conversion will affect the - amount. + One of the ratios should be higher or equal than 1 and + the other should be lower or equal than 1. </i18n.Translate> - </p> + </Attention> </div> - </div> - </div> + ) : undefined} - {!cashoutCalc || !cashinCalc ? undefined : ( <div class="px-6 pt-6"> - <div class="sm:col-span-5"> - <dl class="mt-4 space-y-4"> - <div class="justify-between items-center flex "> - <dt class="text-sm text-gray-600"> - <i18n.Translate> - Sending to this bank - </i18n.Translate> - </dt> - <dd class="text-sm text-gray-900"> - <RenderAmount - value={cashinCalc.debit} - negative - withColor - spec={info.fiat_currency_specification} - /> - </dd> - </div> + <div class="grid max-w-2xl grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6"> + <div class="sm:col-span-5"> + <label + for="amount" + class="block text-sm font-medium leading-6 text-onBackground" + >{i18n.str`Initial amount`}</label> + <InputAmount + name="amount" + left + currency={info.fiat_currency} + value={form.amount?.value ?? ""} + onChange={form.amount?.onUpdate} + /> + <ShowInputErrorLabel + message={form.amount?.error} + isDirty={form.amount?.value !== undefined} + /> + <p class="mt-2 text-sm text-gray-500"> + <i18n.Translate> + Use it to test how the conversion will affect the + amount. + </i18n.Translate> + </p> + </div> + </div> + </div> - {Amounts.isZero(cashinCalc.beforeFee) ? undefined : ( - <div class="flex items-center justify-between afu "> - <dt class="flex items-center text-sm text-gray-600"> - <span> - <i18n.Translate>Converted</i18n.Translate> - </span> + {!cashoutCalc || !cashinCalc ? undefined : ( + <div class="px-6 pt-6"> + <div class="sm:col-span-5"> + <dl class="mt-4 space-y-4"> + <div class="justify-between items-center flex "> + <dt class="text-sm text-gray-600"> + <i18n.Translate> + Sending to this bank + </i18n.Translate> </dt> - <dd class="text-sm text-gray-900"> + <dd class="text-sm text-onBackground"> <RenderAmount - value={cashinCalc.beforeFee} + value={cashinCalc.debit} + negative + withColor + spec={info.fiat_currency_specification} + /> + </dd> + </div> + + {Amounts.isZero(cashinCalc.beforeFee) ? undefined : ( + <div class="flex items-center justify-between afu "> + <dt class="flex items-center text-sm text-gray-600"> + <span> + <i18n.Translate>Converted</i18n.Translate> + </span> + </dt> + <dd class="text-sm text-onBackground"> + <RenderAmount + value={cashinCalc.beforeFee} + spec={info.regional_currency_specification} + /> + </dd> + </div> + )} + <div class="flex justify-between items-center border-t-2 afu pt-4"> + <dt class="text-lg text-onBackground font-medium"> + <i18n.Translate>Cashin after fee</i18n.Translate> + </dt> + <dd class="text-lg text-onBackground font-medium"> + <RenderAmount + value={cashinCalc.credit} + withColor spec={info.regional_currency_specification} /> </dd> </div> - )} - <div class="flex justify-between items-center border-t-2 afu pt-4"> - <dt class="text-lg text-gray-900 font-medium"> - <i18n.Translate>Cashin after fee</i18n.Translate> - </dt> - <dd class="text-lg text-gray-900 font-medium"> - <RenderAmount - value={cashinCalc.credit} - withColor - spec={info.regional_currency_specification} - /> - </dd> - </div> - </dl> - </div> + </dl> + </div> - <div class="sm:col-span-5"> - <dl class="mt-4 space-y-4"> - <div class="justify-between items-center flex "> - <dt class="text-sm text-gray-600"> - <i18n.Translate> - Sending from this bank - </i18n.Translate> - </dt> - <dd class="text-sm text-gray-900"> - <RenderAmount - value={cashoutCalc.debit} - negative - withColor - spec={info.regional_currency_specification} - /> - </dd> - </div> + <div class="sm:col-span-5"> + <dl class="mt-4 space-y-4"> + <div class="justify-between items-center flex "> + <dt class="text-sm text-gray-600"> + <i18n.Translate> + Sending from this bank + </i18n.Translate> + </dt> + <dd class="text-sm text-onBackground"> + <RenderAmount + value={cashoutCalc.debit} + negative + withColor + spec={info.regional_currency_specification} + /> + </dd> + </div> - {Amounts.isZero(cashoutCalc.beforeFee) ? undefined : ( - <div class="flex items-center justify-between afu"> - <dt class="flex items-center text-sm text-gray-600"> - <span> - <i18n.Translate>Converted</i18n.Translate> - </span> + {Amounts.isZero(cashoutCalc.beforeFee) ? undefined : ( + <div class="flex items-center justify-between afu"> + <dt class="flex items-center text-sm text-gray-600"> + <span> + <i18n.Translate>Converted</i18n.Translate> + </span> + </dt> + <dd class="text-sm text-onBackground"> + <RenderAmount + value={cashoutCalc.beforeFee} + spec={info.fiat_currency_specification} + /> + </dd> + </div> + )} + <div class="flex justify-between items-center border-t-2 afu pt-4"> + <dt class="text-lg text-onBackground font-medium"> + <i18n.Translate>Cashout after fee</i18n.Translate> </dt> - <dd class="text-sm text-gray-900"> + <dd class="text-lg text-onBackground font-medium"> <RenderAmount - value={cashoutCalc.beforeFee} + value={cashoutCalc.credit} + withColor spec={info.fiat_currency_specification} /> </dd> </div> - )} - <div class="flex justify-between items-center border-t-2 afu pt-4"> - <dt class="text-lg text-gray-900 font-medium"> - <i18n.Translate>Cashout after fee</i18n.Translate> - </dt> - <dd class="text-lg text-gray-900 font-medium"> - <RenderAmount - value={cashoutCalc.credit} - withColor - spec={info.fiat_currency_specification} - /> - </dd> + </dl> + </div> + + {cashoutCalc && + status.status === "ok" && + Amounts.cmp(status.result.amount, cashoutCalc.credit) < + 0 ? ( + <div class="p-4"> + <Attention + title={i18n.str`Bad configuration`} + type="warning" + > + <i18n.Translate> + This configuration allows users to cash out more + of what has been cashed in. + </i18n.Translate> + </Attention> </div> - </dl> + ) : undefined} </div> - - {cashoutCalc && - status.status === "ok" && - Amounts.cmp(status.result.amount, cashoutCalc.credit) < - 0 ? ( - <div class="p-4"> - <Attention - title={i18n.str`Bad configuration`} - type="warning" - > - <i18n.Translate> - This configuration allows users to cash out more of - what has been cashed in. - </i18n.Translate> - </Attention> - </div> - ) : undefined} - </div> - )} - </Fragment> - )} - - <div class="flex items-center justify-between mt-4 gap-x-6 border-t border-gray-900/10 px-4 py-4"> - <a - name="cancel" - href={routeCancel.url({})} - class="text-sm font-semibold leading-6 text-gray-900" - > - <i18n.Translate>Cancel</i18n.Translate> - </a> - {section == "cashin" || section == "cashout" ? ( - <AsyncButton - 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" - disabled={!creds || status.status === "fail"} - onClick={() => - update.run( - { type: "bearer", token: creds.token }, - status.status === "fail" - ? undefined! - : status.result.conv, - ) - } - > - <i18n.Translate>Update</i18n.Translate> - </AsyncButton> - ) : ( - <div /> + )} + </Fragment> )} - </div> - </form> + + <div class="mt-4 border-t border-onBackground/10 bg-background/60 px-5 py-4 sm:px-8"> + <OperationError class="mb-4" /> + <div class="flex items-center justify-between gap-x-6"> + <a + name="cancel" + href={routeCancel.url({})} + class="text-sm font-semibold leading-6 text-onBackground" + > + <i18n.Translate>Cancel</i18n.Translate> + </a> + {section == "cashin" || section == "cashout" ? ( + <AsyncButton + submit + name="update conversion" + class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-primary px-3 py-2 text-sm font-semibold text-onPrimary shadow-sm hover:bg-primaryHover hover:text-white focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary" + disabled={!creds || status.status === "fail"} + onClick={() => + update.run( + { type: "bearer", token: creds.token }, + status.status === "fail" + ? undefined! + : status.result.conv, + ) + } + > + <i18n.Translate>Update</i18n.Translate> + </AsyncButton> + ) : ( + <div /> + )} + </div> + </div> + </form> + </div> </div> - </div> + </section> ); }; } @@ -848,7 +836,7 @@ export function ConversionForm({ <div class="sm:col-span-5"> <label for={`${id}_min_amount`} - class="block text-sm font-medium leading-6 text-gray-900" + class="block text-sm font-medium leading-6 text-onBackground" >{i18n.str`Minimum amount`}</label> <InputAmount name={`${id}_min_amount`} @@ -874,7 +862,7 @@ export function ConversionForm({ <div class="px-6 pt-6"> <label - class="block text-sm font-medium leading-6 text-gray-900" + class="block text-sm font-medium leading-6 text-onBackground" for={`${id}_ratio`} > {i18n.str`Ratio`} @@ -882,7 +870,7 @@ export function ConversionForm({ <div class="mt-2"> <input type="number" - class="block rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 data-[error=true]:ring-red-500 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" + class="block rounded-md border-0 py-1.5 text-onBackground shadow-sm ring-1 ring-inset ring-gray-300 data-[error=true]:ring-red-500 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-primary sm:text-sm sm:leading-6" name="current" id={`${id}_ratio`} data-error={!!ratio?.error && ratio?.value !== undefined} @@ -916,7 +904,7 @@ export function ConversionForm({ <div class="grid max-w-2xl grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6"> <div class="sm:col-span-5"> <label - class="block text-sm font-medium leading-6 text-gray-900" + class="block text-sm font-medium leading-6 text-onBackground" for={`${id}_tiny_amount`} > {i18n.str`Tiny amount`} @@ -941,7 +929,7 @@ export function ConversionForm({ <div class="grid max-w-2xl grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6"> <div class="sm:col-span-5"> <label - class="block text-sm font-medium leading-6 text-gray-900" + class="block text-sm font-medium leading-6 text-onBackground" for={`${id}_channel`} > {i18n.str`Rounding mode`} @@ -951,7 +939,7 @@ export function ConversionForm({ <label aria-label={i18n.str`Zero`} data-selected={rounding?.value === "zero"} - class="relative flex data-[disabled=false]:cursor-pointer rounded-lg border bg-white data-[disabled=true]:bg-gray-200 p-4 shadow-sm focus:outline-none border-gray-300 data-[selected=true]:ring-2 data-[selected=true]:ring-indigo-600" + class="relative flex data-[disabled=false]:cursor-pointer rounded-lg border bg-white data-[disabled=true]:bg-gray-200 p-4 shadow-sm focus:outline-none border-gray-300 data-[selected=true]:ring-2 data-[selected=true]:ring-primary" > <input type="radio" @@ -963,7 +951,7 @@ export function ConversionForm({ /> <span class="flex flex-1"> <span class="flex flex-col"> - <span class="block text-sm font-medium text-gray-900 "> + <span class="block text-sm font-medium text-onBackground "> <i18n.Translate>Zero</i18n.Translate> </span> <i18n.Translate> @@ -974,7 +962,7 @@ export function ConversionForm({ </span> <svg data-selected={rounding?.value === "zero"} - class="h-5 w-5 text-indigo-600 data-[selected=false]:hidden" + class="h-5 w-5 text-primary data-[selected=false]:hidden" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" @@ -990,7 +978,7 @@ export function ConversionForm({ <label aria-label={i18n.str`Up`} data-selected={rounding?.value === "up"} - class="relative flex data-[disabled=false]:cursor-pointer rounded-lg border data-[disabled=true]:bg-gray-200 p-4 shadow-sm focus:outline-none border-gray-300 data-[selected=true]:ring-2 data-[selected=true]:ring-indigo-600" + class="relative flex data-[disabled=false]:cursor-pointer rounded-lg border data-[disabled=true]:bg-gray-200 p-4 shadow-sm focus:outline-none border-gray-300 data-[selected=true]:ring-2 data-[selected=true]:ring-primary" > <input type="radio" @@ -1002,7 +990,7 @@ export function ConversionForm({ /> <span class="flex flex-1"> <span class="flex flex-col"> - <span class="block text-sm font-medium text-gray-900 "> + <span class="block text-sm font-medium text-onBackground "> <i18n.Translate>Up</i18n.Translate> </span> <i18n.Translate> @@ -1013,7 +1001,7 @@ export function ConversionForm({ </span> <svg data-selected={rounding?.value === "up"} - class="h-5 w-5 text-indigo-600 data-[selected=false]:hidden" + class="h-5 w-5 text-primary data-[selected=false]:hidden" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" @@ -1028,7 +1016,7 @@ export function ConversionForm({ <label aria-label={i18n.str`Nearest`} data-selected={rounding?.value === "nearest"} - class="relative flex data-[disabled=false]:cursor-pointer rounded-lg border data-[disabled=true]:bg-gray-200 p-4 shadow-sm focus:outline-none border-gray-300 data-[selected=true]:ring-2 data-[selected=true]:ring-indigo-600" + class="relative flex data-[disabled=false]:cursor-pointer rounded-lg border data-[disabled=true]:bg-gray-200 p-4 shadow-sm focus:outline-none border-gray-300 data-[selected=true]:ring-2 data-[selected=true]:ring-primary" > <input type="radio" @@ -1040,7 +1028,7 @@ export function ConversionForm({ /> <span class="flex flex-1"> <span class="flex flex-col"> - <span class="block text-sm font-medium text-gray-900 "> + <span class="block text-sm font-medium text-onBackground "> <i18n.Translate>Nearest</i18n.Translate> </span> <i18n.Translate> @@ -1050,7 +1038,7 @@ export function ConversionForm({ </span> <svg data-selected={rounding?.value === "nearest"} - class="h-5 w-5 text-indigo-600 data-[selected=false]:hidden" + class="h-5 w-5 text-primary data-[selected=false]:hidden" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" @@ -1100,23 +1088,23 @@ export function ConversionForm({ ></path> </svg> </summary> - <p class="text-gray-900 my-4"> + <p class="text-onBackground my-4"> <i18n.Translate> Given the rounding value of 0.1 the possible values closest to 1.24 are: 1.1, 1.2, 1.3, 1.4. </i18n.Translate> </p> - <p class="text-gray-900 my-4"> + <p class="text-onBackground my-4"> <i18n.Translate> With the "zero" mode the value will be rounded to 1.2 </i18n.Translate> </p> - <p class="text-gray-900 my-4"> + <p class="text-onBackground my-4"> <i18n.Translate> With the "nearest" mode the value will be rounded to 1.2 </i18n.Translate> </p> - <p class="text-gray-900 mt-4"> + <p class="text-onBackground mt-4"> <i18n.Translate> With the "up" mode the value will be rounded to 1.3 </i18n.Translate> @@ -1143,23 +1131,23 @@ export function ConversionForm({ ></path> </svg> </summary> - <p class="text-gray-900 my-4"> + <p class="text-onBackground my-4"> <i18n.Translate> Given the rounding value of 0.1 the possible values closest to 1.24 are: 1.1, 1.2, 1.3, 1.4. </i18n.Translate> </p> - <p class="text-gray-900 my-4"> + <p class="text-onBackground my-4"> <i18n.Translate> With the "zero" mode the value will be rounded to 1.2 </i18n.Translate> </p> - <p class="text-gray-900 my-4"> + <p class="text-onBackground my-4"> <i18n.Translate> With the "nearest" mode the value will be rounded to 1.3 </i18n.Translate> </p> - <p class="text-gray-900 my-4"> + <p class="text-onBackground my-4"> <i18n.Translate> With the "up" mode the value will be rounded to 1.3 </i18n.Translate> @@ -1186,23 +1174,23 @@ export function ConversionForm({ ></path> </svg> </summary> - <p class="text-gray-900 my-4"> + <p class="text-onBackground my-4"> <i18n.Translate> Given the rounding value of 0.3 the possible values closest to 1.24 are: 0.9, 1.2, 1.5, 1.8. </i18n.Translate> </p> - <p class="text-gray-900 my-4"> + <p class="text-onBackground my-4"> <i18n.Translate> With the "zero" mode the value will be rounded to 1.2 </i18n.Translate> </p> - <p class="text-gray-900 my-4"> + <p class="text-onBackground my-4"> <i18n.Translate> With the "nearest" mode the value will be rounded to 1.2 </i18n.Translate> </p> - <p class="text-gray-900 my-4"> + <p class="text-onBackground my-4"> <i18n.Translate> With the "up" mode the value will be rounded to 1.5 </i18n.Translate> @@ -1229,23 +1217,23 @@ export function ConversionForm({ ></path> </svg> </summary> - <p class="text-gray-900 my-4"> + <p class="text-onBackground my-4"> <i18n.Translate> Given the rounding value of 0.3 the possible values closest to 1.24 are: 0.9, 1.2, 1.5, 1.8. </i18n.Translate> </p> - <p class="text-gray-900 my-4"> + <p class="text-onBackground my-4"> <i18n.Translate> With the "zero" mode the value will be rounded to 1.2 </i18n.Translate> </p> - <p class="text-gray-900 my-4"> + <p class="text-onBackground my-4"> <i18n.Translate> With the "nearest" mode the value will be rounded to 1.3 </i18n.Translate> </p> - <p class="text-gray-900 my-4"> + <p class="text-onBackground my-4"> <i18n.Translate> With the "up" mode the value will be rounded to 1.3 </i18n.Translate> @@ -1261,7 +1249,7 @@ export function ConversionForm({ <div class="sm:col-span-5"> <label for={`${id}_fee`} - class="block text-sm font-medium leading-6 text-gray-900" + class="block text-sm font-medium leading-6 text-onBackground" >{i18n.str`Fee`}</label> <InputAmount name={`${id}_fee`} diff --git a/packages/libeufin-bank-webui/src/pages/regional/CreateCashout.tsx b/packages/libeufin-bank-webui/src/pages/regional/CreateCashout.tsx @@ -65,6 +65,7 @@ import { TanChannel, undefinedIfEmpty } from "../../utils.js"; import { LoginForm } from "../LoginForm.js"; import { InputAmount, doAutoFocus } from "../PaytoWireTransferForm.js"; import { RetryableError } from "../../components/RetryableError.js"; +import { OperationError } from "../../components/OperationError.js"; interface Props { account: string; @@ -109,7 +110,7 @@ export function CreateCashout({ <a href={routeClose.url({})} name="close" - class="inline-flex w-full justify-center rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" + class="inline-flex w-full justify-center rounded-md bg-primary px-3 py-2 text-sm font-semibold text-onPrimary shadow-sm hover:bg-primaryHover hover:text-white focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary" > <i18n.Translate>Close</i18n.Translate> </a> @@ -288,12 +289,13 @@ function CreateCashoutInternal({ * can be in regional currency or fiat currency * depending on the isDebit flag */ + const parsedInputAmount = Amounts.parse( + `${form.isDebit ? regional_currency : fiat_currency}:${ + !form.amount ? "0" : form.amount + }`, + ); const inputAmount = - Amounts.parse( - `${form.isDebit ? regional_currency : fiat_currency}:${ - !form.amount ? "0" : form.amount - }`, - ) ?? (form.isDebit ? regionalZero : fiatZero); + parsedInputAmount ?? (form.isDebit ? regionalZero : fiatZero); const calculationKey = `${form.isDebit === true ? "debit" : "credit"}:${ form.amount?.trim() ?? "" }:${rate.cashout_fee}:${rate.cashout_ratio}`; @@ -394,15 +396,12 @@ function CreateCashoutInternal({ subject: !form.subject ? i18n.str`Required` : undefined, amount: !form.amount ? i18n.str`Required` - : !inputAmount + : !parsedInputAmount ? i18n.str`Invalid` : !calculationResult - ? i18n.str`Amount needs to be higher` - : Amounts.isZero( - balanceLimit - .deduce(calculationResult.debit) - .getResultZeroIfNegative(), - ) + ? undefined + : balanceLimit.result.negative || + Amounts.cmp(balanceLimit.result, calculationResult.debit) < 0 ? i18n.str`Balance is not enough` : Amounts.cmp(calculationResult.debit, rate.cashout_min_amount) < 0 ? i18n.str`It is not possible to cash out less than ${ @@ -508,27 +507,35 @@ function CreateCashoutInternal({ return ( <div> - <div class="grid grid-cols-1 gap-x-8 gap-y-8 pt-6 md:grid-cols-3 bg-gray-100 my-4 px-4 pb-4 rounded-lg"> - <section class="mt-4 rounded-sm px-4 py-6 p-8 "> - <h2 id="summary-heading" class="font-medium text-lg"> - <i18n.Translate>Cashout</i18n.Translate> + <div class="mt-6 overflow-hidden rounded-xl border border-onBackground/10 bg-white shadow-sm"> + <section class="border-b border-onBackground/10 bg-primary/5 px-5 py-5 sm:px-8"> + <h2 + id="summary-heading" + class="text-lg font-semibold text-onBackground" + > + <i18n.Translate>Cashout details</i18n.Translate> </h2> + <p class="mt-1 text-sm text-gray-600"> + <i18n.Translate> + Review the destination account and conversion terms. + </i18n.Translate> + </p> - <dl class="mt-4 space-y-4"> - <div class="justify-between items-center flex"> + <dl class="mt-5 grid gap-x-8 gap-y-3 sm:grid-cols-2"> + <div class="flex items-center justify-between gap-4"> <dt class="text-sm text-gray-600"> <i18n.Translate>Conversion rate</i18n.Translate> </dt> - <dd class="text-sm text-gray-900">{sellRate}</dd> + <dd class="text-sm text-onBackground">{sellRate}</dd> </div> - <div class="flex items-center justify-between border-t-2 afu pt-4"> + <div class="flex items-center justify-between gap-4"> <dt class="flex items-center text-sm text-gray-600"> <span> <i18n.Translate>Balance</i18n.Translate> </span> </dt> - <dd class="text-sm text-gray-900"> + <dd class="text-sm text-onBackground"> <RenderAmount value={account.balance} negative={account.balanceIsDebit} @@ -537,40 +544,46 @@ function CreateCashoutInternal({ /> </dd> </div> - <div class="flex items-center justify-between border-t-2 afu pt-4"> + <div class="flex items-center justify-between gap-4"> <dt class="flex items-center text-sm text-gray-600"> <span> <i18n.Translate>Fee</i18n.Translate> </span> </dt> - <dd class="text-sm text-gray-900"> - <RenderAmount - value={sellFee} - negative - withSign - spec={fiat_currency_specification!} - /> + <dd class="text-sm text-onBackground"> + {Amounts.isZero(sellFee) ? ( + <i18n.Translate>No fee</i18n.Translate> + ) : ( + <RenderAmount + value={sellFee} + negative + withSign + spec={fiat_currency_specification!} + /> + )} </dd> </div> {cashoutAccountName && cashoutLegalName ? ( <Fragment> - <div class="flex items-center justify-between border-t-2 afu pt-4"> + <div class="flex items-center justify-between gap-4"> <dt class="flex items-center text-sm text-gray-600"> <span> <i18n.Translate>To account</i18n.Translate> </span> </dt> - <dd class="text-sm text-gray-900">{cashoutAccountName}</dd> + <dd class="text-sm text-onBackground"> + {cashoutAccountName} + </dd> </div> - <div class="flex items-center justify-between border-t-2 afu pt-4"> + <div class="flex items-center justify-between gap-4"> <dt class="flex items-center text-sm text-gray-600"> <span> <i18n.Translate>Legal name</i18n.Translate> </span> </dt> - <dd class="text-sm text-gray-900">{cashoutLegalName}</dd> + <dd class="text-sm text-onBackground">{cashoutLegalName}</dd> </div> - <p class="mt-2 text-sm text-gray-500"> + <p class="text-sm text-gray-500 sm:col-span-2"> <i18n.Translate> If this name doesn't match the account holder's name, your transaction may fail. @@ -578,7 +591,7 @@ function CreateCashoutInternal({ </p> </Fragment> ) : ( - <div class="flex items-center justify-between border-t-2 afu pt-4"> + <div class="sm:col-span-2"> <Attention type="warning" title={i18n.str`Unable to cash out`}> <i18n.Translate> Before being able to cash out to a bank account, you need to @@ -590,35 +603,36 @@ function CreateCashoutInternal({ </dl> </section> <form - class="bg-white shadow-sm ring-1 ring-gray-900/5 sm:rounded-xl md:col-span-2" + class="bg-white" autoCapitalize="none" autoCorrect="off" onSubmit={(e) => { e.preventDefault(); }} > - <div class="px-4 py-6 sm:p-8"> + <div class="px-5 py-6 sm:px-8"> <div class="grid max-w-2xl grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6"> {/* subject */} <div class="sm:col-span-5"> <label - class="block text-sm font-medium leading-6 text-gray-900" + class="block text-sm font-medium leading-6 text-onBackground" for="subject" > - {i18n.str`Transfer subject`} + {i18n.str`Transfer reference`} <b class="text-[red]"> *</b> </label> <div class="mt-2"> <input ref={focus ? doAutoFocus : undefined} type="text" - class="block w-full rounded-md disabled:bg-gray-200 border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 data-[error=true]:ring-red-500 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" + class="block w-full rounded-md disabled:bg-gray-200 border-0 py-1.5 text-onBackground shadow-sm ring-1 ring-inset ring-gray-300 data-[error=true]:ring-red-500 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-primary sm:text-sm sm:leading-6" name="subject" id="subject" disabled={cashoutDisabled} data-error={!!errors?.subject && form.subject !== undefined} value={form.subject ?? ""} + placeholder={i18n.str`What is this cashout for?`} onChange={(e) => { form.subject = e.currentTarget.value; updateForm(structuredClone(form)); @@ -632,97 +646,52 @@ function CreateCashoutInternal({ </div> </div> - <div class="sm:col-span-5"> - <label - class="block text-sm font-medium leading-6 text-gray-900" - for="subject" - > - {i18n.str`Currency`} - </label> + <fieldset class="sm:col-span-5"> + <legend class="block text-sm font-medium leading-6 text-onBackground"> + <i18n.Translate> + Enter the amount to send or receive + </i18n.Translate> + </legend> - <div class="mt-2"> + <div class="mt-2 inline-flex rounded-lg bg-onBackground/5 p-1"> <button type="button" - name="set 50" - class=" inline-flex p-4 text-sm items-center rounded-l-md bg-white text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:z-10" + name="send regional currency" + aria-pressed={form.isDebit === true} + data-selected={form.isDebit === true} + disabled={cashoutDisabled} + class="rounded-md px-3 py-2 text-sm font-medium text-gray-600 hover:text-brand data-[selected=true]:bg-white data-[selected=true]:text-brand data-[selected=true]:shadow-sm disabled:cursor-not-allowed disabled:opacity-50" onClick={(e) => { e.preventDefault(); form.isDebit = true; updateForm(structuredClone(form)); }} > - {form.isDebit ? ( - <svg - class="self-center flex-none h-5 w-5 text-indigo-600" - viewBox="0 0 20 20" - fill="currentColor" - aria-hidden="true" - > - <path - fill-rule="evenodd" - d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z" - clip-rule="evenodd" - /> - </svg> - ) : ( - <svg - fill="none" - viewBox="0 0 24 24" - stroke-width="1.5" - stroke="currentColor" - class="w-5 h-5" - > - <path d="M15 12H9m12 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" /> - </svg> - )} - <i18n.Translate>Send {regional_currency}</i18n.Translate> </button> <button type="button" - name="set 25" - class=" -ml-px -mr-px inline-flex p-4 text-sm items-center rounded-r-md bg-white text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:z-10" + name="receive fiat currency" + aria-pressed={form.isDebit === false} + data-selected={form.isDebit === false} + disabled={cashoutDisabled} + class="rounded-md px-3 py-2 text-sm font-medium text-gray-600 hover:text-brand data-[selected=true]:bg-white data-[selected=true]:text-brand data-[selected=true]:shadow-sm disabled:cursor-not-allowed disabled:opacity-50" onClick={(e) => { e.preventDefault(); form.isDebit = false; updateForm(structuredClone(form)); }} > - {!form.isDebit ? ( - <svg - class="self-center flex-none h-5 w-5 text-indigo-600" - viewBox="0 0 20 20" - fill="currentColor" - aria-hidden="true" - > - <path - fill-rule="evenodd" - d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z" - clip-rule="evenodd" - /> - </svg> - ) : ( - <svg - fill="none" - viewBox="0 0 24 24" - stroke-width="1.5" - stroke="currentColor" - class="w-5 h-5" - > - <path d="M15 12H9m12 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" /> - </svg> - )} - <i18n.Translate>Receive {fiat_currency}</i18n.Translate> </button> </div> - </div> + </fieldset> {/* amount */} <div class="sm:col-span-5"> <div class="flex justify-between"> <label - class="block text-sm font-medium leading-6 text-gray-900" + class="block text-sm font-medium leading-6 text-onBackground" for="amount" > {i18n.str`Amount`} @@ -753,12 +722,12 @@ function CreateCashoutInternal({ {Amounts.isZero(calc.credit) ? undefined : ( <div class="sm:col-span-5"> - <dl class="mt-4 space-y-4"> - <div class="justify-between items-center flex "> + <dl class="mt-2 divide-y divide-onBackground/10 rounded-lg bg-background px-4 py-2 text-sm ring-1 ring-onBackground/10"> + <div class="flex items-center justify-between py-2"> <dt class="text-sm text-gray-600"> <i18n.Translate>Total cost</i18n.Translate> </dt> - <dd class="text-sm text-gray-900"> + <dd class="text-sm text-onBackground"> <RenderAmount value={calc.debit} negative @@ -768,13 +737,13 @@ function CreateCashoutInternal({ </dd> </div> - <div class="flex items-center justify-between border-t-2 afu pt-4"> + <div class="flex items-center justify-between py-2"> <dt class="flex items-center text-sm text-gray-600"> <span> <i18n.Translate>Balance left</i18n.Translate> </span> </dt> - <dd class="text-sm text-gray-900"> + <dd class="text-sm text-onBackground"> <RenderAmount value={balanceAfter} negative={balanceAfter.negative} @@ -785,13 +754,13 @@ function CreateCashoutInternal({ </div> {Amounts.isZero(sellFee) || Amounts.isZero(calc.beforeFee) ? undefined : ( - <div class="flex items-center justify-between border-t-2 afu pt-4"> + <div class="flex items-center justify-between py-2"> <dt class="flex items-center text-sm text-gray-600"> <span> <i18n.Translate>Before fee</i18n.Translate> </span> </dt> - <dd class="text-sm text-gray-900"> + <dd class="text-sm text-onBackground"> <RenderAmount value={calc.beforeFee} spec={fiat_currency_specification!} @@ -799,11 +768,11 @@ function CreateCashoutInternal({ </dd> </div> )} - <div class="flex justify-between items-center border-t-2 afu pt-4"> - <dt class="text-lg text-gray-900 font-medium"> + <div class="flex items-center justify-between py-2"> + <dt class="font-medium text-onBackground"> <i18n.Translate>Total cashout transfer</i18n.Translate> </dt> - <dd class="text-lg text-gray-900 font-medium"> + <dd class="font-semibold text-onBackground"> <RenderAmount value={calc.credit} withColor @@ -817,31 +786,34 @@ function CreateCashoutInternal({ </div> </div> - <div class="flex items-center justify-between gap-x-6 border-t border-gray-900/10 px-4 py-4 sm:px-8"> - <a - href={routeClose.url({})} - name="cancel" - class="text-sm font-semibold leading-6 text-gray-900" - > - <i18n.Translate>Cancel</i18n.Translate> - </a> - <AsyncButton - submit - name="cashout" - class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" - disabled={ - !!errors || - !subject || - !calculationResult || - conversionCalculator.running - } - onClick={() => { - if (!calculationResult) return; - void cashout.run(session, calculationResult, subject!); - }} - > - <i18n.Translate>Cashout</i18n.Translate> - </AsyncButton> + <div class="border-t border-onBackground/10 bg-background/60 px-5 py-4 sm:px-8"> + <OperationError class="mb-4" /> + <div class="flex items-center justify-between gap-x-6"> + <a + href={routeClose.url({})} + name="cancel" + class="text-sm font-semibold leading-6 text-onBackground" + > + <i18n.Translate>Cancel</i18n.Translate> + </a> + <AsyncButton + submit + name="cashout" + class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-primary px-3 py-2 text-sm font-semibold text-onPrimary shadow-sm hover:bg-primaryHover hover:text-white focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary" + disabled={ + !!errors || + !subject || + !calculationResult || + conversionCalculator.running + } + onClick={() => { + if (!calculationResult) return; + void cashout.run(session, calculationResult, subject!); + }} + > + <i18n.Translate>Cash out</i18n.Translate> + </AsyncButton> + </div> </div> </form> </div> diff --git a/packages/libeufin-bank-webui/src/pages/regional/ShowCashoutDetails.tsx b/packages/libeufin-bank-webui/src/pages/regional/ShowCashoutDetails.tsx @@ -129,87 +129,78 @@ export function ShowCashoutDetails({ id, routeClose }: Props): VNode { info.body; return ( - <div> - <div class="grid grid-cols-1 gap-x-8 gap-y-8 pt-6 md:grid-cols-3 bg-gray-100 my-4 px-4 pb-4 rounded-lg"> - <section class="rounded-sm px-4"> - <h2 id="summary-heading" class="font-medium text-lg"> - <i18n.Translate>Cashout detail</i18n.Translate> - </h2> - <dl class="mt-8 space-y-4"> - <div class="justify-between items-center flex"> + <section class="mt-2"> + <a + href={routeClose.url({})} + name="close" + class="inline-flex items-center text-sm font-semibold text-brand hover:underline" + > + <span class="mr-1" aria-hidden="true"> + ← + </span> + <i18n.Translate>Back to cashout history</i18n.Translate> + </a> + <div class="mx-auto mt-6 max-w-2xl"> + <h1 class="text-2xl font-semibold text-brand"> + <i18n.Translate>Cashout details</i18n.Translate> + </h1> + <p class="mt-2 text-sm text-gray-600"> + <i18n.Translate> + Amounts recorded for this completed cashout transfer. + </i18n.Translate> + </p> + <dl class="mt-6 divide-y divide-onBackground/10 overflow-hidden rounded-xl border border-onBackground/10 bg-white px-5 shadow-sm sm:px-8"> + <div class="flex items-start justify-between gap-6 py-4"> + <dt class="text-sm text-gray-600"> + <i18n.Translate>Transfer reference</i18n.Translate> + </dt> + <dd class="text-right text-sm font-medium text-onBackground"> + {result.body.subject} + </dd> + </div> + {result.body.creation_time.t_s !== "never" ? ( + <div class="flex items-center justify-between gap-6 py-4"> <dt class="text-sm text-gray-600"> - <i18n.Translate>Subject</i18n.Translate> + <i18n.Translate>Date</i18n.Translate> </dt> - <dd class="text-sm ">{result.body.subject}</dd> - </div> - </dl> - </section> - <div class="bg-white shadow-sm ring-1 ring-gray-900/5 sm:rounded-xl md:col-span-2"> - <div class="px-4 py-6 sm:p-8"> - <div class="grid max-w-2xl grid-cols-1 gap-x-6 gap-y-8 "> - <div class="sm:col-span-5"> - <dl class="space-y-4"> - {result.body.creation_time.t_s !== "never" ? ( - <div class="justify-between items-center flex "> - <dt class=" text-gray-600"> - <i18n.Translate>Date</i18n.Translate> - </dt> - <dd class="text-sm "> - <Time - format="dd/MM/yyyy HH:mm:ss" - timestamp={AbsoluteTime.fromProtocolTimestamp( - result.body.creation_time, - )} - // relative={Duration.fromSpec({ days: 1 })} - /> - </dd> - </div> - ) : undefined} - - <div class="flex justify-between items-center border-t-2 afu pt-4"> - <dt class="text-gray-600"> - <i18n.Translate>Debited</i18n.Translate> - </dt> - <dd class=" font-medium"> - <RenderAmount - value={Amounts.parseOrThrow(result.body.amount_debit)} - negative - withColor - spec={regional_currency_specification} - /> - </dd> - </div> - - <div class="flex items-center justify-between border-t-2 afu pt-4"> - <dt class="flex items-center text-gray-600"> - <span> - <i18n.Translate>Transferred</i18n.Translate> - </span> - </dt> - <dd class="text-sm "> - <RenderAmount - value={Amounts.parseOrThrow(result.body.amount_credit)} - withColor - spec={fiat_currency_specification} - /> - </dd> - </div> - </dl> - </div> + <dd class="text-right text-sm text-onBackground"> + <Time + format="dd/MM/yyyy HH:mm:ss" + timestamp={AbsoluteTime.fromProtocolTimestamp( + result.body.creation_time, + )} + /> + </dd> </div> + ) : undefined} + <div class="flex items-center justify-between gap-6 py-4"> + <dt class="text-sm text-gray-600"> + <i18n.Translate>Amount debited</i18n.Translate> + </dt> + <dd class="font-medium text-onBackground"> + <RenderAmount + value={Amounts.parseOrThrow(result.body.amount_debit)} + negative + withSign + withColor + spec={regional_currency_specification} + /> + </dd> </div> - </div> - </div> - - <div> - <a - href={routeClose.url({})} - name="close" - class="text-sm font-semibold leading-6 text-gray-900" - > - <i18n.Translate>Close</i18n.Translate> - </a> + <div class="flex items-center justify-between gap-6 py-4"> + <dt class="text-sm text-gray-600"> + <i18n.Translate>Amount transferred</i18n.Translate> + </dt> + <dd class="font-semibold text-onBackground"> + <RenderAmount + value={Amounts.parseOrThrow(result.body.amount_credit)} + withColor + spec={fiat_currency_specification} + /> + </dd> + </div> + </dl> </div> - </div> + </section> ); } diff --git a/packages/libeufin-bank-webui/src/pages/rnd.ts b/packages/libeufin-bank-webui/src/pages/rnd.ts @@ -1,6 +1,6 @@ /* This file is part of GNU Taler - (C) 2022-2024 Taler Systems S.A. + (C) 2022-2024, 2026 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 @@ -9,2899 +9,10 @@ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. +*/ - You should have received a copy of the GNU General Public License along with - GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> - */ import { encodeCrock, getRandomBytes } from "@gnu-taler/taler-util"; -const noun = [ - "people", - "history", - "way", - "art", - "world", - "information", - "map", - "two", - "family", - "government", - "health", - "system", - "computer", - "meat", - "year", - "thanks", - "music", - "person", - "reading", - "method", - "data", - "food", - "understanding", - "theory", - "law", - "bird", - "literature", - "problem", - "software", - "control", - "knowledge", - "power", - "ability", - "economics", - "love", - "internet", - "television", - "science", - "library", - "nature", - "fact", - "product", - "idea", - "temperature", - "investment", - "area", - "society", - "activity", - "story", - "industry", - "media", - "thing", - "oven", - "community", - "definition", - "safety", - "quality", - "development", - "language", - "management", - "player", - "variety", - "video", - "week", - "security", - "country", - "exam", - "movie", - "organization", - "equipment", - "physics", - "analysis", - "policy", - "series", - "thought", - "basis", - "boyfriend", - "direction", - "strategy", - "technology", - "army", - "camera", - "freedom", - "paper", - "environment", - "child", - "instance", - "month", - "truth", - "marketing", - "university", - "writing", - "article", - "department", - "difference", - "goal", - "news", - "audience", - "fishing", - "growth", - "income", - "marriage", - "user", - "combination", - "failure", - "meaning", - "medicine", - "philosophy", - "teacher", - "communication", - "night", - "chemistry", - "disease", - "disk", - "energy", - "nation", - "road", - "role", - "soup", - "advertising", - "location", - "success", - "addition", - "apartment", - "education", - "math", - "moment", - "painting", - "politics", - "attention", - "decision", - "event", - "property", - "shopping", - "student", - "wood", - "competition", - "distribution", - "entertainment", - "office", - "population", - "president", - "unit", - "category", - "cigarette", - "context", - "introduction", - "opportunity", - "performance", - "driver", - "flight", - "length", - "magazine", - "newspaper", - "relationship", - "teaching", - "cell", - "dealer", - "finding", - "lake", - "member", - "message", - "phone", - "scene", - "appearance", - "association", - "concept", - "customer", - "death", - "discussion", - "housing", - "inflation", - "insurance", - "mood", - "woman", - "advice", - "blood", - "effort", - "expression", - "importance", - "opinion", - "payment", - "reality", - "responsibility", - "situation", - "skill", - "statement", - "wealth", - "application", - "city", - "county", - "depth", - "estate", - "foundation", - "grandmother", - "heart", - "perspective", - "photo", - "recipe", - "studio", - "topic", - "collection", - "depression", - "imagination", - "passion", - "percentage", - "resource", - "setting", - "ad", - "agency", - "college", - "connection", - "criticism", - "debt", - "description", - "memory", - "patience", - "secretary", - "solution", - "administration", - "aspect", - "attitude", - "director", - "personality", - "psychology", - "recommendation", - "response", - "selection", - "storage", - "version", - "alcohol", - "argument", - "complaint", - "contract", - "emphasis", - "highway", - "loss", - "membership", - "possession", - "preparation", - "steak", - "union", - "agreement", - "cancer", - "currency", - "employment", - "engineering", - "entry", - "interaction", - "mixture", - "preference", - "region", - "republic", - "tradition", - "virus", - "actor", - "classroom", - "delivery", - "device", - "difficulty", - "drama", - "election", - "engine", - "football", - "guidance", - "hotel", - "owner", - "priority", - "protection", - "suggestion", - "tension", - "variation", - "anxiety", - "atmosphere", - "awareness", - "bath", - "bread", - "candidate", - "climate", - "comparison", - "confusion", - "construction", - "elevator", - "emotion", - "employee", - "employer", - "guest", - "height", - "leadership", - "mall", - "manager", - "operation", - "recording", - "sample", - "transportation", - "charity", - "cousin", - "disaster", - "editor", - "efficiency", - "excitement", - "extent", - "feedback", - "guitar", - "homework", - "leader", - "mom", - "outcome", - "permission", - "presentation", - "promotion", - "reflection", - "refrigerator", - "resolution", - "revenue", - "session", - "singer", - "tennis", - "basket", - "bonus", - "cabinet", - "childhood", - "church", - "clothes", - "coffee", - "dinner", - "drawing", - "hair", - "hearing", - "initiative", - "judgment", - "lab", - "measurement", - "mode", - "mud", - "orange", - "poetry", - "police", - "possibility", - "procedure", - "queen", - "ratio", - "relation", - "restaurant", - "satisfaction", - "sector", - "signature", - "significance", - "song", - "tooth", - "town", - "vehicle", - "volume", - "wife", - "accident", - "airport", - "appointment", - "arrival", - "assumption", - "baseball", - "chapter", - "committee", - "conversation", - "database", - "enthusiasm", - "error", - "explanation", - "farmer", - "gate", - "girl", - "hall", - "historian", - "hospital", - "injury", - "instruction", - "maintenance", - "manufacturer", - "meal", - "perception", - "pie", - "poem", - "presence", - "proposal", - "reception", - "replacement", - "revolution", - "river", - "son", - "speech", - "tea", - "village", - "warning", - "winner", - "worker", - "writer", - "assistance", - "breath", - "buyer", - "chest", - "chocolate", - "conclusion", - "contribution", - "cookie", - "courage", - "dad", - "desk", - "drawer", - "establishment", - "examination", - "garbage", - "grocery", - "honey", - "impression", - "improvement", - "independence", - "insect", - "inspection", - "inspector", - "king", - "ladder", - "menu", - "penalty", - "piano", - "potato", - "profession", - "professor", - "quantity", - "reaction", - "requirement", - "salad", - "sister", - "supermarket", - "tongue", - "weakness", - "wedding", - "affair", - "ambition", - "analyst", - "apple", - "assignment", - "assistant", - "bathroom", - "bedroom", - "beer", - "birthday", - "celebration", - "championship", - "cheek", - "client", - "consequence", - "departure", - "diamond", - "dirt", - "ear", - "fortune", - "friendship", - "funeral", - "gene", - "girlfriend", - "hat", - "indication", - "intention", - "lady", - "midnight", - "negotiation", - "obligation", - "passenger", - "pizza", - "platform", - "poet", - "pollution", - "recognition", - "reputation", - "shirt", - "sir", - "speaker", - "stranger", - "surgery", - "sympathy", - "tale", - "throat", - "trainer", - "uncle", - "youth", - "time", - "work", - "film", - "water", - "money", - "example", - "while", - "business", - "study", - "game", - "life", - "form", - "air", - "day", - "place", - "number", - "part", - "field", - "fish", - "back", - "process", - "heat", - "hand", - "experience", - "job", - "book", - "end", - "point", - "type", - "home", - "economy", - "value", - "body", - "market", - "guide", - "interest", - "state", - "radio", - "course", - "company", - "price", - "size", - "card", - "list", - "mind", - "trade", - "line", - "care", - "group", - "risk", - "word", - "fat", - "force", - "key", - "light", - "training", - "name", - "school", - "top", - "amount", - "level", - "order", - "practice", - "research", - "sense", - "service", - "piece", - "web", - "boss", - "sport", - "fun", - "house", - "page", - "term", - "test", - "answer", - "sound", - "focus", - "matter", - "kind", - "soil", - "board", - "oil", - "picture", - "access", - "garden", - "range", - "rate", - "reason", - "future", - "site", - "demand", - "exercise", - "image", - "case", - "cause", - "coast", - "action", - "age", - "bad", - "boat", - "record", - "result", - "section", - "building", - "mouse", - "cash", - "class", - "nothing", - "period", - "plan", - "store", - "tax", - "side", - "subject", - "space", - "rule", - "stock", - "weather", - "chance", - "figure", - "man", - "model", - "source", - "beginning", - "earth", - "program", - "chicken", - "design", - "feature", - "head", - "material", - "purpose", - "question", - "rock", - "salt", - "act", - "birth", - "car", - "dog", - "object", - "scale", - "sun", - "note", - "profit", - "rent", - "speed", - "style", - "war", - "bank", - "craft", - "half", - "inside", - "outside", - "standard", - "bus", - "exchange", - "eye", - "fire", - "position", - "pressure", - "stress", - "advantage", - "benefit", - "box", - "frame", - "issue", - "step", - "cycle", - "face", - "item", - "metal", - "paint", - "review", - "room", - "screen", - "structure", - "view", - "account", - "ball", - "discipline", - "medium", - "share", - "balance", - "bit", - "black", - "bottom", - "choice", - "gift", - "impact", - "machine", - "shape", - "tool", - "wind", - "address", - "average", - "career", - "culture", - "morning", - "pot", - "sign", - "table", - "task", - "condition", - "contact", - "credit", - "egg", - "hope", - "ice", - "network", - "north", - "square", - "attempt", - "date", - "effect", - "link", - "post", - "star", - "voice", - "capital", - "challenge", - "friend", - "self", - "shot", - "brush", - "couple", - "debate", - "exit", - "front", - "function", - "lack", - "living", - "plant", - "plastic", - "spot", - "summer", - "taste", - "theme", - "track", - "wing", - "brain", - "button", - "click", - "desire", - "foot", - "gas", - "influence", - "notice", - "rain", - "wall", - "base", - "damage", - "distance", - "feeling", - "pair", - "savings", - "staff", - "sugar", - "target", - "text", - "animal", - "author", - "budget", - "discount", - "file", - "ground", - "lesson", - "minute", - "officer", - "phase", - "reference", - "register", - "sky", - "stage", - "stick", - "title", - "trouble", - "bowl", - "bridge", - "campaign", - "character", - "club", - "edge", - "evidence", - "fan", - "letter", - "lock", - "maximum", - "novel", - "option", - "pack", - "park", - "plenty", - "quarter", - "skin", - "sort", - "weight", - "baby", - "background", - "carry", - "dish", - "factor", - "fruit", - "glass", - "joint", - "master", - "muscle", - "red", - "strength", - "traffic", - "trip", - "vegetable", - "appeal", - "chart", - "gear", - "ideal", - "kitchen", - "land", - "log", - "mother", - "net", - "party", - "principle", - "relative", - "sale", - "season", - "signal", - "spirit", - "street", - "tree", - "wave", - "belt", - "bench", - "commission", - "copy", - "drop", - "minimum", - "path", - "progress", - "project", - "sea", - "south", - "status", - "stuff", - "ticket", - "tour", - "angle", - "blue", - "breakfast", - "confidence", - "daughter", - "degree", - "doctor", - "dot", - "dream", - "duty", - "essay", - "father", - "fee", - "finance", - "hour", - "juice", - "limit", - "luck", - "milk", - "mouth", - "peace", - "pipe", - "seat", - "stable", - "storm", - "substance", - "team", - "trick", - "afternoon", - "bat", - "beach", - "blank", - "catch", - "chain", - "consideration", - "cream", - "crew", - "detail", - "gold", - "interview", - "kid", - "mark", - "match", - "mission", - "pain", - "pleasure", - "score", - "screw", - "sex", - "shop", - "shower", - "suit", - "tone", - "window", - "agent", - "band", - "block", - "bone", - "calendar", - "cap", - "coat", - "contest", - "corner", - "court", - "cup", - "district", - "door", - "east", - "finger", - "garage", - "guarantee", - "hole", - "hook", - "implement", - "layer", - "lecture", - "lie", - "manner", - "meeting", - "nose", - "parking", - "partner", - "profile", - "respect", - "rice", - "routine", - "schedule", - "swimming", - "telephone", - "tip", - "winter", - "airline", - "bag", - "battle", - "bed", - "bill", - "bother", - "cake", - "code", - "curve", - "designer", - "dimension", - "dress", - "ease", - "emergency", - "evening", - "extension", - "farm", - "fight", - "gap", - "grade", - "holiday", - "horror", - "horse", - "host", - "husband", - "loan", - "mistake", - "mountain", - "nail", - "noise", - "occasion", - "package", - "patient", - "pause", - "phrase", - "proof", - "race", - "relief", - "sand", - "sentence", - "shoulder", - "smoke", - "stomach", - "string", - "tourist", - "towel", - "vacation", - "west", - "wheel", - "wine", - "arm", - "aside", - "associate", - "bet", - "blow", - "border", - "branch", - "breast", - "brother", - "buddy", - "bunch", - "chip", - "coach", - "cross", - "document", - "draft", - "dust", - "expert", - "floor", - "god", - "golf", - "habit", - "iron", - "judge", - "knife", - "landscape", - "league", - "mail", - "mess", - "native", - "opening", - "parent", - "pattern", - "pin", - "pool", - "pound", - "request", - "salary", - "shame", - "shelter", - "shoe", - "silver", - "tackle", - "tank", - "trust", - "assist", - "bake", - "bar", - "bell", - "bike", - "blame", - "boy", - "brick", - "chair", - "closet", - "clue", - "collar", - "comment", - "conference", - "devil", - "diet", - "fear", - "fuel", - "glove", - "jacket", - "lunch", - "monitor", - "mortgage", - "nurse", - "pace", - "panic", - "peak", - "plane", - "reward", - "row", - "sandwich", - "shock", - "spite", - "spray", - "surprise", - "till", - "transition", - "weekend", - "welcome", - "yard", - "alarm", - "bend", - "bicycle", - "bite", - "blind", - "bottle", - "cable", - "candle", - "clerk", - "cloud", - "concert", - "counter", - "flower", - "grandfather", - "harm", - "knee", - "lawyer", - "leather", - "load", - "mirror", - "neck", - "pension", - "plate", - "purple", - "ruin", - "ship", - "skirt", - "slice", - "snow", - "specialist", - "stroke", - "switch", - "trash", - "tune", - "zone", - "anger", - "award", - "bid", - "bitter", - "boot", - "bug", - "camp", - "candy", - "carpet", - "cat", - "champion", - "channel", - "clock", - "comfort", - "cow", - "crack", - "engineer", - "entrance", - "fault", - "grass", - "guy", - "hell", - "highlight", - "incident", - "island", - "joke", - "jury", - "leg", - "lip", - "mate", - "motor", - "nerve", - "passage", - "pen", - "pride", - "priest", - "prize", - "promise", - "resident", - "resort", - "ring", - "roof", - "rope", - "sail", - "scheme", - "script", - "sock", - "station", - "toe", - "tower", - "truck", - "witness", - "a", - "you", - "it", - "can", - "will", - "if", - "one", - "many", - "most", - "other", - "use", - "make", - "good", - "look", - "help", - "go", - "great", - "being", - "few", - "might", - "still", - "public", - "read", - "keep", - "start", - "give", - "human", - "local", - "general", - "she", - "specific", - "long", - "play", - "feel", - "high", - "tonight", - "put", - "common", - "set", - "change", - "simple", - "past", - "big", - "possible", - "particular", - "today", - "major", - "personal", - "current", - "national", - "cut", - "natural", - "physical", - "show", - "try", - "check", - "second", - "call", - "move", - "pay", - "let", - "increase", - "single", - "individual", - "turn", - "ask", - "buy", - "guard", - "hold", - "main", - "offer", - "potential", - "professional", - "international", - "travel", - "cook", - "alternative", - "following", - "special", - "working", - "whole", - "dance", - "excuse", - "cold", - "commercial", - "low", - "purchase", - "deal", - "primary", - "worth", - "fall", - "necessary", - "positive", - "produce", - "search", - "present", - "spend", - "talk", - "creative", - "tell", - "cost", - "drive", - "green", - "support", - "glad", - "remove", - "return", - "run", - "complex", - "due", - "effective", - "middle", - "regular", - "reserve", - "independent", - "leave", - "original", - "reach", - "rest", - "serve", - "watch", - "beautiful", - "charge", - "active", - "break", - "negative", - "safe", - "stay", - "visit", - "visual", - "affect", - "cover", - "report", - "rise", - "walk", - "white", - "beyond", - "junior", - "pick", - "unique", - "anything", - "classic", - "final", - "lift", - "mix", - "private", - "stop", - "teach", - "western", - "concern", - "familiar", - "fly", - "official", - "broad", - "comfortable", - "gain", - "maybe", - "rich", - "save", - "stand", - "young", - "fail", - "heavy", - "hello", - "lead", - "listen", - "valuable", - "worry", - "handle", - "leading", - "meet", - "release", - "sell", - "finish", - "normal", - "press", - "ride", - "secret", - "spread", - "spring", - "tough", - "wait", - "brown", - "deep", - "display", - "flow", - "hit", - "objective", - "shoot", - "touch", - "cancel", - "chemical", - "cry", - "dump", - "extreme", - "push", - "conflict", - "eat", - "fill", - "formal", - "jump", - "kick", - "opposite", - "pass", - "pitch", - "remote", - "total", - "treat", - "vast", - "abuse", - "beat", - "burn", - "deposit", - "print", - "raise", - "sleep", - "somewhere", - "advance", - "anywhere", - "consist", - "dark", - "double", - "draw", - "equal", - "fix", - "hire", - "internal", - "join", - "kill", - "sensitive", - "tap", - "win", - "attack", - "claim", - "constant", - "drag", - "drink", - "guess", - "minor", - "pull", - "raw", - "soft", - "solid", - "wear", - "weird", - "wonder", - "annual", - "count", - "dead", - "doubt", - "feed", - "forever", - "impress", - "nobody", - "repeat", - "round", - "sing", - "slide", - "strip", - "whereas", - "wish", - "combine", - "command", - "dig", - "divide", - "equivalent", - "hang", - "hunt", - "initial", - "march", - "mention", - "smell", - "spiritual", - "survey", - "tie", - "adult", - "brief", - "crazy", - "escape", - "gather", - "hate", - "prior", - "repair", - "rough", - "sad", - "scratch", - "sick", - "strike", - "employ", - "external", - "hurt", - "illegal", - "laugh", - "lay", - "mobile", - "nasty", - "ordinary", - "respond", - "royal", - "senior", - "split", - "strain", - "struggle", - "swim", - "train", - "upper", - "wash", - "yellow", - "convert", - "crash", - "dependent", - "fold", - "funny", - "grab", - "hide", - "miss", - "permit", - "quote", - "recover", - "resolve", - "roll", - "sink", - "slip", - "spare", - "suspect", - "sweet", - "swing", - "twist", - "upstairs", - "usual", - "abroad", - "brave", - "calm", - "concentrate", - "estimate", - "grand", - "male", - "mine", - "prompt", - "quiet", - "refuse", - "regret", - "reveal", - "rush", - "shake", - "shift", - "shine", - "steal", - "suck", - "surround", - "anybody", - "bear", - "brilliant", - "dare", - "dear", - "delay", - "drunk", - "female", - "hurry", - "inevitable", - "invite", - "kiss", - "neat", - "pop", - "punch", - "quit", - "reply", - "representative", - "resist", - "rip", - "rub", - "silly", - "smile", - "spell", - "stretch", - "stupid", - "tear", - "temporary", - "tomorrow", - "wake", - "wrap", - "yesterday", -]; - -const adj = [ - "abandoned", - "able", - "absolute", - "adorable", - "adventurous", - "academic", - "acceptable", - "acclaimed", - "accomplished", - "accurate", - "aching", - "acidic", - "acrobatic", - "active", - "actual", - "adept", - "admirable", - "admired", - "adolescent", - "adorable", - "adored", - "advanced", - "afraid", - "affectionate", - "aged", - "aggravating", - "aggressive", - "agile", - "agitated", - "agonizing", - "agreeable", - "ajar", - "alarmed", - "alarming", - "alert", - "alienated", - "alive", - "all", - "altruistic", - "amazing", - "ambitious", - "ample", - "amused", - "amusing", - "anchored", - "ancient", - "angelic", - "angry", - "anguished", - "animated", - "annual", - "another", - "antique", - "anxious", - "any", - "apprehensive", - "appropriate", - "apt", - "arctic", - "arid", - "aromatic", - "artistic", - "ashamed", - "assured", - "astonishing", - "athletic", - "attached", - "attentive", - "attractive", - "austere", - "authentic", - "authorized", - "automatic", - "avaricious", - "average", - "aware", - "awesome", - "awful", - "awkward", - "babyish", - "bad", - "back", - "baggy", - "bare", - "barren", - "basic", - "beautiful", - "belated", - "beloved", - "beneficial", - "better", - "best", - "bewitched", - "big", - "big-hearted", - "biodegradable", - "bite-sized", - "bitter", - "black", - "black-and-white", - "bland", - "blank", - "blaring", - "bleak", - "blind", - "blissful", - "blond", - "blue", - "blushing", - "bogus", - "boiling", - "bold", - "bony", - "boring", - "bossy", - "both", - "bouncy", - "bountiful", - "bowed", - "brave", - "breakable", - "brief", - "bright", - "brilliant", - "brisk", - "broken", - "bronze", - "brown", - "bruised", - "bubbly", - "bulky", - "bumpy", - "buoyant", - "burdensome", - "burly", - "bustling", - "busy", - "buttery", - "buzzing", - "calculating", - "calm", - "candid", - "canine", - "capital", - "carefree", - "careful", - "careless", - "caring", - "cautious", - "cavernous", - "celebrated", - "charming", - "cheap", - "cheerful", - "cheery", - "chief", - "chilly", - "chubby", - "circular", - "classic", - "clean", - "clear", - "clear-cut", - "clever", - "close", - "closed", - "cloudy", - "clueless", - "clumsy", - "cluttered", - "coarse", - "cold", - "colorful", - "colorless", - "colossal", - "comfortable", - "common", - "compassionate", - "competent", - "complete", - "complex", - "complicated", - "composed", - "concerned", - "concrete", - "confused", - "conscious", - "considerate", - "constant", - "content", - "conventional", - "cooked", - "cool", - "cooperative", - "coordinated", - "corny", - "corrupt", - "costly", - "courageous", - "courteous", - "crafty", - "crazy", - "creamy", - "creative", - "creepy", - "criminal", - "crisp", - "critical", - "crooked", - "crowded", - "cruel", - "crushing", - "cuddly", - "cultivated", - "cultured", - "cumbersome", - "curly", - "curvy", - "cute", - "cylindrical", - "damaged", - "damp", - "dangerous", - "dapper", - "daring", - "darling", - "dark", - "dazzling", - "dead", - "deadly", - "deafening", - "dear", - "dearest", - "decent", - "decimal", - "decisive", - "deep", - "defenseless", - "defensive", - "defiant", - "deficient", - "definite", - "definitive", - "delayed", - "delectable", - "delicious", - "delightful", - "delirious", - "demanding", - "dense", - "dental", - "dependable", - "dependent", - "descriptive", - "deserted", - "detailed", - "determined", - "devoted", - "different", - "difficult", - "digital", - "diligent", - "dim", - "dimpled", - "dimwitted", - "direct", - "disastrous", - "discrete", - "disfigured", - "disgusting", - "disloyal", - "dismal", - "distant", - "downright", - "dreary", - "dirty", - "disguised", - "dishonest", - "dismal", - "distant", - "distinct", - "distorted", - "dizzy", - "dopey", - "doting", - "double", - "downright", - "drab", - "drafty", - "dramatic", - "dreary", - "droopy", - "dry", - "dual", - "dull", - "dutiful", - "each", - "eager", - "earnest", - "early", - "easy", - "easy-going", - "ecstatic", - "edible", - "educated", - "elaborate", - "elastic", - "elated", - "elderly", - "electric", - "elegant", - "elementary", - "elliptical", - "embarrassed", - "embellished", - "eminent", - "emotional", - "empty", - "enchanted", - "enchanting", - "energetic", - "enlightened", - "enormous", - "enraged", - "entire", - "envious", - "equal", - "equatorial", - "essential", - "esteemed", - "ethical", - "euphoric", - "even", - "evergreen", - "everlasting", - "every", - "evil", - "exalted", - "excellent", - "exemplary", - "exhausted", - "excitable", - "excited", - "exciting", - "exotic", - "expensive", - "experienced", - "expert", - "extraneous", - "extroverted", - "extra-large", - "extra-small", - "fabulous", - "failing", - "faint", - "fair", - "faithful", - "fake", - "false", - "familiar", - "famous", - "fancy", - "fantastic", - "far", - "faraway", - "far-flung", - "far-off", - "fast", - "fat", - "fatal", - "fatherly", - "favorable", - "favorite", - "fearful", - "fearless", - "feisty", - "feline", - "female", - "feminine", - "few", - "fickle", - "filthy", - "fine", - "finished", - "firm", - "first", - "firsthand", - "fitting", - "fixed", - "flaky", - "flamboyant", - "flashy", - "flat", - "flawed", - "flawless", - "flickering", - "flimsy", - "flippant", - "flowery", - "fluffy", - "fluid", - "flustered", - "focused", - "fond", - "foolhardy", - "foolish", - "forceful", - "forked", - "formal", - "forsaken", - "forthright", - "fortunate", - "fragrant", - "frail", - "frank", - "frayed", - "free", - "French", - "fresh", - "frequent", - "friendly", - "frightened", - "frightening", - "frigid", - "frilly", - "frizzy", - "frivolous", - "front", - "frosty", - "frozen", - "frugal", - "fruitful", - "full", - "fumbling", - "functional", - "funny", - "fussy", - "fuzzy", - "gargantuan", - "gaseous", - "general", - "generous", - "gentle", - "genuine", - "giant", - "giddy", - "gigantic", - "gifted", - "giving", - "glamorous", - "glaring", - "glass", - "gleaming", - "gleeful", - "glistening", - "glittering", - "gloomy", - "glorious", - "glossy", - "glum", - "golden", - "good", - "good-natured", - "gorgeous", - "graceful", - "gracious", - "grand", - "grandiose", - "granular", - "grateful", - "grave", - "gray", - "great", - "greedy", - "green", - "gregarious", - "grim", - "grimy", - "gripping", - "grizzled", - "gross", - "grotesque", - "grouchy", - "grounded", - "growing", - "growling", - "grown", - "grubby", - "gruesome", - "grumpy", - "guilty", - "gullible", - "gummy", - "hairy", - "half", - "handmade", - "handsome", - "handy", - "happy", - "happy-go-lucky", - "hard", - "hard-to-find", - "harmful", - "harmless", - "harmonious", - "harsh", - "hasty", - "hateful", - "haunting", - "healthy", - "heartfelt", - "hearty", - "heavenly", - "heavy", - "hefty", - "helpful", - "helpless", - "hidden", - "hideous", - "high", - "high-level", - "hilarious", - "hoarse", - "hollow", - "homely", - "honest", - "honorable", - "honored", - "hopeful", - "horrible", - "hospitable", - "hot", - "huge", - "humble", - "humiliating", - "humming", - "humongous", - "hungry", - "hurtful", - "husky", - "icky", - "icy", - "ideal", - "idealistic", - "identical", - "idle", - "idiotic", - "idolized", - "ignorant", - "ill", - "illegal", - "ill-fated", - "ill-informed", - "illiterate", - "illustrious", - "imaginary", - "imaginative", - "immaculate", - "immaterial", - "immediate", - "immense", - "impassioned", - "impeccable", - "impartial", - "imperfect", - "imperturbable", - "impish", - "impolite", - "important", - "impossible", - "impractical", - "impressionable", - "impressive", - "improbable", - "impure", - "inborn", - "incomparable", - "incompatible", - "incomplete", - "inconsequential", - "incredible", - "indelible", - "inexperienced", - "indolent", - "infamous", - "infantile", - "infatuated", - "inferior", - "infinite", - "informal", - "innocent", - "insecure", - "insidious", - "insignificant", - "insistent", - "instructive", - "insubstantial", - "intelligent", - "intent", - "intentional", - "interesting", - "internal", - "international", - "intrepid", - "ironclad", - "irresponsible", - "irritating", - "itchy", - "jaded", - "jagged", - "jam-packed", - "jaunty", - "jealous", - "jittery", - "joint", - "jolly", - "jovial", - "joyful", - "joyous", - "jubilant", - "judicious", - "juicy", - "jumbo", - "junior", - "jumpy", - "juvenile", - "kaleidoscopic", - "keen", - "key", - "kind", - "kindhearted", - "kindly", - "klutzy", - "knobby", - "knotty", - "knowledgeable", - "knowing", - "known", - "kooky", - "kosher", - "lame", - "lanky", - "large", - "last", - "lasting", - "late", - "lavish", - "lawful", - "lazy", - "leading", - "lean", - "leafy", - "left", - "legal", - "legitimate", - "light", - "lighthearted", - "likable", - "likely", - "limited", - "limp", - "limping", - "linear", - "lined", - "liquid", - "little", - "live", - "lively", - "livid", - "loathsome", - "lone", - "lonely", - "long", - "long-term", - "loose", - "lopsided", - "lost", - "loud", - "lovable", - "lovely", - "loving", - "low", - "loyal", - "lucky", - "lumbering", - "luminous", - "lumpy", - "lustrous", - "luxurious", - "mad", - "made-up", - "magnificent", - "majestic", - "major", - "male", - "mammoth", - "married", - "marvelous", - "masculine", - "massive", - "mature", - "meager", - "mealy", - "mean", - "measly", - "meaty", - "medical", - "mediocre", - "medium", - "meek", - "mellow", - "melodic", - "memorable", - "menacing", - "merry", - "messy", - "metallic", - "mild", - "milky", - "mindless", - "miniature", - "minor", - "minty", - "miserable", - "miserly", - "misguided", - "misty", - "mixed", - "modern", - "modest", - "moist", - "monstrous", - "monthly", - "monumental", - "moral", - "mortified", - "motherly", - "motionless", - "mountainous", - "muddy", - "muffled", - "multicolored", - "mundane", - "murky", - "mushy", - "musty", - "muted", - "mysterious", - "naive", - "narrow", - "nasty", - "natural", - "naughty", - "nautical", - "near", - "neat", - "necessary", - "needy", - "negative", - "neglected", - "negligible", - "neighboring", - "nervous", - "new", - "next", - "nice", - "nifty", - "nimble", - "nippy", - "nocturnal", - "noisy", - "nonstop", - "normal", - "notable", - "noted", - "noteworthy", - "novel", - "noxious", - "numb", - "nutritious", - "nutty", - "obedient", - "obese", - "oblong", - "oily", - "oblong", - "obvious", - "occasional", - "odd", - "oddball", - "offbeat", - "offensive", - "official", - "old", - "old-fashioned", - "only", - "open", - "optimal", - "optimistic", - "opulent", - "orange", - "orderly", - "organic", - "ornate", - "ornery", - "ordinary", - "original", - "other", - "our", - "outlying", - "outgoing", - "outlandish", - "outrageous", - "outstanding", - "oval", - "overcooked", - "overdue", - "overjoyed", - "overlooked", - "palatable", - "pale", - "paltry", - "parallel", - "parched", - "partial", - "passionate", - "past", - "pastel", - "peaceful", - "peppery", - "perfect", - "perfumed", - "periodic", - "perky", - "personal", - "pertinent", - "pesky", - "pessimistic", - "petty", - "phony", - "physical", - "piercing", - "pink", - "pitiful", - "plain", - "plaintive", - "plastic", - "playful", - "pleasant", - "pleased", - "pleasing", - "plump", - "plush", - "polished", - "polite", - "political", - "pointed", - "pointless", - "poised", - "poor", - "popular", - "portly", - "posh", - "positive", - "possible", - "potable", - "powerful", - "powerless", - "practical", - "precious", - "present", - "prestigious", - "pretty", - "precious", - "previous", - "pricey", - "prickly", - "primary", - "prime", - "pristine", - "private", - "prize", - "probable", - "productive", - "profitable", - "profuse", - "proper", - "proud", - "prudent", - "punctual", - "pungent", - "puny", - "pure", - "purple", - "pushy", - "putrid", - "puzzled", - "puzzling", - "quaint", - "qualified", - "quarrelsome", - "quarterly", - "queasy", - "querulous", - "questionable", - "quick", - "quick-witted", - "quiet", - "quintessential", - "quirky", - "quixotic", - "quizzical", - "radiant", - "ragged", - "rapid", - "rare", - "rash", - "raw", - "recent", - "reckless", - "rectangular", - "ready", - "real", - "realistic", - "reasonable", - "red", - "reflecting", - "regal", - "regular", - "reliable", - "relieved", - "remarkable", - "remorseful", - "remote", - "repentant", - "required", - "respectful", - "responsible", - "repulsive", - "revolving", - "rewarding", - "rich", - "rigid", - "right", - "ringed", - "ripe", - "roasted", - "robust", - "rosy", - "rotating", - "rotten", - "rough", - "round", - "rowdy", - "royal", - "rubbery", - "rundown", - "ruddy", - "rude", - "runny", - "rural", - "rusty", - "sad", - "safe", - "salty", - "same", - "sandy", - "sane", - "sarcastic", - "sardonic", - "satisfied", - "scaly", - "scarce", - "scared", - "scary", - "scented", - "scholarly", - "scientific", - "scornful", - "scratchy", - "scrawny", - "second", - "secondary", - "second-hand", - "secret", - "self-assured", - "self-reliant", - "selfish", - "sentimental", - "separate", - "serene", - "serious", - "serpentine", - "several", - "severe", - "shabby", - "shadowy", - "shady", - "shallow", - "shameful", - "shameless", - "sharp", - "shimmering", - "shiny", - "shocked", - "shocking", - "shoddy", - "short", - "short-term", - "showy", - "shrill", - "shy", - "sick", - "silent", - "silky", - "silly", - "silver", - "similar", - "simple", - "simplistic", - "sinful", - "single", - "sizzling", - "skeletal", - "skinny", - "sleepy", - "slight", - "slim", - "slimy", - "slippery", - "slow", - "slushy", - "small", - "smart", - "smoggy", - "smooth", - "smug", - "snappy", - "snarling", - "sneaky", - "sniveling", - "snoopy", - "sociable", - "soft", - "soggy", - "solid", - "somber", - "some", - "spherical", - "sophisticated", - "sore", - "sorrowful", - "soulful", - "soupy", - "sour", - "Spanish", - "sparkling", - "sparse", - "specific", - "spectacular", - "speedy", - "spicy", - "spiffy", - "spirited", - "spiteful", - "splendid", - "spotless", - "spotted", - "spry", - "square", - "squeaky", - "squiggly", - "stable", - "staid", - "stained", - "stale", - "standard", - "starchy", - "stark", - "starry", - "steep", - "sticky", - "stiff", - "stimulating", - "stingy", - "stormy", - "straight", - "strange", - "steel", - "strict", - "strident", - "striking", - "striped", - "strong", - "studious", - "stunning", - "stupendous", - "stupid", - "sturdy", - "stylish", - "subdued", - "submissive", - "substantial", - "subtle", - "suburban", - "sudden", - "sugary", - "sunny", - "super", - "superb", - "superficial", - "superior", - "supportive", - "sure-footed", - "surprised", - "suspicious", - "svelte", - "sweaty", - "sweet", - "sweltering", - "swift", - "sympathetic", - "tall", - "talkative", - "tame", - "tan", - "tangible", - "tart", - "tasty", - "tattered", - "taut", - "tedious", - "teeming", - "tempting", - "tender", - "tense", - "tepid", - "terrible", - "terrific", - "testy", - "thankful", - "that", - "these", - "thick", - "thin", - "third", - "thirsty", - "this", - "thorough", - "thorny", - "those", - "thoughtful", - "threadbare", - "thrifty", - "thunderous", - "tidy", - "tight", - "timely", - "tinted", - "tiny", - "tired", - "torn", - "total", - "tough", - "traumatic", - "treasured", - "tremendous", - "tragic", - "trained", - "tremendous", - "triangular", - "tricky", - "trifling", - "trim", - "trivial", - "troubled", - "true", - "trusting", - "trustworthy", - "trusty", - "truthful", - "tubby", - "turbulent", - "twin", - "ugly", - "ultimate", - "unacceptable", - "unaware", - "uncomfortable", - "uncommon", - "unconscious", - "understated", - "unequaled", - "uneven", - "unfinished", - "unfit", - "unfolded", - "unfortunate", - "unhappy", - "unhealthy", - "uniform", - "unimportant", - "unique", - "united", - "unkempt", - "unknown", - "unlawful", - "unlined", - "unlucky", - "unnatural", - "unpleasant", - "unrealistic", - "unripe", - "unruly", - "unselfish", - "unsightly", - "unsteady", - "unsung", - "untidy", - "untimely", - "untried", - "untrue", - "unused", - "unusual", - "unwelcome", - "unwieldy", - "unwilling", - "unwitting", - "unwritten", - "upbeat", - "upright", - "upset", - "urban", - "usable", - "used", - "useful", - "useless", - "utilized", - "utter", - "vacant", - "vague", - "vain", - "valid", - "valuable", - "vapid", - "variable", - "vast", - "velvety", - "venerated", - "vengeful", - "verifiable", - "vibrant", - "vicious", - "victorious", - "vigilant", - "vigorous", - "villainous", - "violet", - "violent", - "virtual", - "virtuous", - "visible", - "vital", - "vivacious", - "vivid", - "voluminous", - "wan", - "warlike", - "warm", - "warmhearted", - "warped", - "wary", - "wasteful", - "watchful", - "waterlogged", - "watery", - "wavy", - "wealthy", - "weak", - "weary", - "webbed", - "weed", - "weekly", - "weepy", - "weighty", - "weird", - "welcome", - "well-documented", - "well-groomed", - "well-informed", - "well-lit", - "well-made", - "well-off", - "well-to-do", - "well-worn", - "wet", - "which", - "whimsical", - "whirlwind", - "whispered", - "white", - "whole", - "whopping", - "wicked", - "wide", - "wide-eyed", - "wiggly", - "wild", - "willing", - "wilted", - "winding", - "windy", - "winged", - "wiry", - "wise", - "witty", - "wobbly", - "woeful", - "wonderful", - "wooden", - "woozy", - "wordy", - "worldly", - "worn", - "worried", - "worrisome", - "worse", - "worst", - "worthless", - "worthwhile", - "worthy", - "wrathful", - "wretched", - "writhing", - "wrong", - "wry", - "yawning", - "yearly", - "yellow", - "yellowish", - "young", - "youthful", - "yummy", - "zany", - "zealous", - "zesty", - "zigzag", -]; - -export function getRandomUsername(): { first: string; second: string } { - const n = Math.floor(Math.random() * noun.length); - const a = Math.floor(Math.random() * adj.length); - return { - first: adj[a], - second: noun[n], - }; -} - export function getRandomPassword(): string { return encodeCrock(getRandomBytes(16)); } diff --git a/packages/libeufin-bank-webui/src/pages/withdrawal-amount.ts b/packages/libeufin-bank-webui/src/pages/withdrawal-amount.ts @@ -0,0 +1,44 @@ +/* + This file is part of GNU Taler + (C) 2026 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. +*/ + +import { AmountJson, Amounts } from "@gnu-taler/taler-util"; + +export type WithdrawalAmountValidation = + | "zero" + | "below-minimum" + | "above-maximum"; + +export function validateWithdrawalAmount( + amount: AmountJson, + minimum: AmountJson | undefined, + maximum: AmountJson | undefined, +): WithdrawalAmountValidation | undefined { + if (Amounts.isZero(amount)) return "zero"; + if (minimum && Amounts.cmp(amount, minimum) < 0) return "below-minimum"; + if (maximum && Amounts.cmp(amount, maximum) > 0) return "above-maximum"; + return undefined; +} + +export function maximumWithdrawalAmount( + accountLimit: AmountJson & { negative: boolean; saturated: boolean }, + fee: AmountJson, + configuredMaximum?: AmountJson, +): AmountJson { + const zero = Amounts.zeroOfCurrency(accountLimit.currency); + if (accountLimit.negative || accountLimit.saturated) return zero; + const afterFee = Amounts.sub(accountLimit, fee); + if (afterFee.saturated) return zero; + if ( + configuredMaximum && + Amounts.cmp(configuredMaximum, afterFee.amount) < 0 + ) { + return configuredMaximum; + } + return afterFee.amount; +} diff --git a/packages/libeufin-bank-webui/src/settings.json b/packages/libeufin-bank-webui/src/settings.json @@ -1,12 +1,13 @@ { "backendBaseURL": "https://bank.taler/", - "allowRandomAccountCreation": true, "fastWithdrawalForm": true, "defaultSuggestedAmount": 11, "bankName": "Taler DEVELOPMENT Bank", + "showPublicAccounts": false, "topNavSites": { - "Exchange": "https://Exchnage.taler/", - "Bank": "https://bank.taler/", - "Merchant": "https://merchant.taler/" + "Introduction": "https://test.taler.net/", + "Bank": "https://bank.test.taler.net/", + "Essay Shop": "https://shop.test.taler.net/", + "Donations": "https://donations.test.taler.net/" } } diff --git a/packages/libeufin-bank-webui/src/settings.ts b/packages/libeufin-bank-webui/src/settings.ts @@ -29,10 +29,6 @@ export interface UiSettings { // Where libeufin backend is localted // default: window.origin without "webui/" backendBaseURL?: string; - // Shows a button "create random account" in the registration form - // Useful for testing - // default: false - allowRandomAccountCreation?: boolean; // URL where the user is going to be redirected after // clicking in Taler Logo // default: home page @@ -49,6 +45,13 @@ export interface UiSettings { // Show a "This is a demo" info in the home screen. // default: false showDemoDescription?: boolean; + // Show links to the public account histories. + // default: false + showPublicAccounts?: boolean; + // Browser-local override set from #/dev. This is not read from settings.json. + showDemoBannerOverride?: boolean; + // Browser-local override set from #/dev. This is not read from settings.json. + showPublicAccountsOverride?: boolean; } /** @@ -57,8 +60,8 @@ export interface UiSettings { const defaultSettings: UiSettings = { backendBaseURL: buildDefaultBackendBaseURL(), iconLinkURL: undefined, - allowRandomAccountCreation: false, showDemoDescription: false, + showPublicAccounts: false, topNavSites: {}, defaultSuggestedAmount: 10, }; @@ -66,8 +69,8 @@ const defaultSettings: UiSettings = { const codecForUISettings = (): Codec<UiSettings> => buildCodecForObject<UiSettings>() .property("backendBaseURL", codecOptional(codecForString())) - .property("allowRandomAccountCreation", codecOptional(codecForBoolean())) .property("showDemoDescription", codecOptional(codecForBoolean())) + .property("showPublicAccounts", codecOptional(codecForBoolean())) .property("defaultSuggestedAmount", codecOptional(codecForNumber())) .property("iconLinkURL", codecOptional(codecForString())) .property("topNavSites", codecOptional(codecForMap(codecForString()))) diff --git a/packages/libeufin-bank-webui/src/stories-context.tsx b/packages/libeufin-bank-webui/src/stories-context.tsx @@ -0,0 +1,179 @@ +/* + This file is part of GNU Taler + (C) 2026 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +import { + AbsoluteTime, + AccessToken, + AmountString, + TalerBankConversionHttpClient, + TalerCoreBankHttpClient, + TalerCorebankApi, +} from "@gnu-taler/taler-util"; +import { + BankApiProviderTesting, + buildStorageKey, + NotificationProvider, + TalerWalletIntegrationTestingProvider, + useLocalStorage, +} from "@gnu-taler/web-util/browser"; +import { ComponentChildren, Fragment, VNode, h } from "preact"; +import { useEffect, useMemo, useRef } from "preact/hooks"; +import { BankChallengeHandlerProvider } from "./context/challenge.js"; +import { SettingsProvider } from "./context/settings.js"; +import { + codecForSessionState, + defaultState as defaultSessionState, + SessionState, +} from "./hooks/session.js"; +import { UiSettings } from "./settings.js"; + +export interface BankStoryContextProps { + children?: ComponentChildren; + loggedIn?: boolean; + administrator?: boolean; + settings?: UiSettings; + config?: Partial<TalerCorebankApi.TalerCorebankConfigResponse>; +} + +const sessionStorageKey = buildStorageKey( + "bank-session", + codecForSessionState(), +); + +const defaultConfig: TalerCorebankApi.TalerCorebankConfigResponse = { + name: "libeufin-bank", + allow_deletions: true, + bank_name: "Taler Bank", + wire_type: "X_TALER_BANK", + supported_tan_channels: [], + allow_registrations: true, + allow_conversion: true, + allow_edit_cashout_payto_uri: false, + allow_edit_name: false, + currency: "ASR", + currency_specification: { + name: "ASR", + alt_unit_names: {}, + num_fractional_input_digits: 2, + num_fractional_normal_digits: 2, + num_fractional_trailing_zero_digits: 2, + }, + default_debit_threshold: "ASR:10" as AmountString, + version: "1:0:0", +}; + +const baseUrl = new URL("http://bank.example/"); +const http = { + fetch: async () => { + throw new Error("story made an unexpected HTTP request"); + }, +} as any; +const bank = new TalerCoreBankHttpClient(baseUrl.href, http); +const conversion = new TalerBankConversionHttpClient( + bank.getConversionInfoAPI().href, + http, +); +const bankContext = { + url: baseUrl, + config: defaultConfig, + lib: { + bank, + conversion, + conversionForUser: (username: string) => + new TalerBankConversionHttpClient( + bank.getConversionInfoAPIForUser(username).href, + http, + ), + conversionForClass: (classId: number) => + new TalerBankConversionHttpClient( + bank.getConversionInfoAPIForClass(classId).href, + http, + ), + }, + hints: [], + onActivity: () => () => undefined, + cancelRequest: () => undefined, +}; + +function sameSession(left: SessionState, right: SessionState): boolean { + if (left.status !== right.status) return false; + if (left.status === "loggedOut" || right.status === "loggedOut") return true; + if (left.username !== right.username) return false; + if (left.isUserAdministrator !== right.isUserAdministrator) return false; + if (left.expiration.t_ms !== right.expiration.t_ms) return false; + if (left.status === "expired" || right.status === "expired") return true; + return left.token === right.token; +} + +export function BankStoryContext({ + children, + loggedIn = false, + administrator = false, + settings = {}, + config, +}: BankStoryContextProps): VNode | null { + const desiredSession = useMemo<SessionState>(() => { + if (!loggedIn && !administrator) return defaultSessionState; + return { + status: "loggedIn", + username: administrator ? "admin" : "alice", + token: "story-token" as AccessToken, + expiration: AbsoluteTime.never(), + isUserAdministrator: administrator, + }; + }, [administrator, loggedIn]); + const { value: currentSession, update: updateSession } = useLocalStorage( + sessionStorageKey, + defaultSessionState, + ); + const updateSessionRef = useRef(updateSession); + updateSessionRef.current = updateSession; + const sessionReady = sameSession(currentSession, desiredSession); + const runningInBrowser = typeof window !== "undefined"; + const currentBankContext = useMemo( + () => ({ + ...bankContext, + config: { ...defaultConfig, ...config }, + }), + [config], + ); + + useEffect(() => { + if (!sessionReady) updateSessionRef.current(desiredSession); + }, [desiredSession, sessionReady]); + + if (!runningInBrowser && !sessionReady) { + updateSession(desiredSession); + } else if (!sessionReady) { + return null; + } + + return ( + <SettingsProvider value={settings}> + <NotificationProvider> + <TalerWalletIntegrationTestingProvider + value={{ publishTalerAction: () => undefined }} + > + <BankApiProviderTesting value={currentBankContext}> + <BankChallengeHandlerProvider> + <Fragment>{children}</Fragment> + </BankChallengeHandlerProvider> + </BankApiProviderTesting> + </TalerWalletIntegrationTestingProvider> + </NotificationProvider> + </SettingsProvider> + ); +} diff --git a/packages/libeufin-bank-webui/src/stories.test.ts b/packages/libeufin-bank-webui/src/stories.test.ts @@ -20,33 +20,13 @@ */ import { describe, it } from "node:test"; import assert from "node:assert"; -import { - AbsoluteTime, - AccessToken, - AmountString, - setupI18n, - TalerBankConversionHttpClient, - TalerCoreBankHttpClient, - TalerCorebankApi, -} from "@gnu-taler/taler-util"; -import { - BankApiProviderTesting, - buildStorageKey, - NotificationProvider, - TalerWalletIntegrationTestingProvider, - parseGroupImport, - useLocalStorage, -} from "@gnu-taler/web-util/browser"; +import { setupI18n } from "@gnu-taler/taler-util"; +import { parseGroupImport } from "@gnu-taler/web-util/browser"; import * as tests from "@gnu-taler/web-util/testing"; import * as components from "./components/index.examples.js"; import * as pages from "./pages/index.stories.js"; -import { ComponentChildren, h as create, VNode } from "preact"; -import { - codecForSessionState, - defaultState as defaultSessionState, -} from "./hooks/session.js"; -// import { BankCoreApiProviderTesting } from "./context/config.js"; +import { BankStoryContext } from "./stories-context.js"; setupI18n("en", { en: {} }); @@ -56,7 +36,7 @@ describe("All the examples:", () => { throw new Error("story-render-marker"); }, {}); assert.throws( - () => tests.renderUI(marker, DefaultTestingContext), + () => tests.renderUI(marker, BankStoryContext), /story-render-marker/, ); }); @@ -67,7 +47,7 @@ describe("All the examples:", () => { describe(`Component ${component.name}:`, () => { component.examples.forEach((example) => { it(`should render example: ${example.name}`, () => { - tests.renderUI(example.render, DefaultTestingContext); + tests.renderUI(example.render, BankStoryContext); }); }); }); @@ -75,91 +55,3 @@ describe("All the examples:", () => { }); }); }); - -function DefaultTestingContext({ - children, - loggedIn = false, - administrator = false, -}: { - children: ComponentChildren; - loggedIn?: boolean; - administrator?: boolean; -}): VNode { - const { update: updateSession } = useLocalStorage( - buildStorageKey("bank-session", codecForSessionState()), - defaultSessionState, - ); - updateSession( - loggedIn - ? { - status: "loggedIn", - username: "alice", - token: "story-token" as AccessToken, - expiration: AbsoluteTime.never(), - isUserAdministrator: administrator, - } - : defaultSessionState, - ); - const cfg: TalerCorebankApi.TalerCorebankConfigResponse = { - name: "libeufin-bank", - allow_deletions: true, - bank_name: "taler bank", - wire_type: "wire t", - supported_tan_channels: [], - allow_registrations: true, - allow_conversion: true, - allow_edit_cashout_payto_uri: false, - allow_edit_name: false, - currency: "ASR", - currency_specification: { - name: "ARS", - alt_unit_names: {}, - num_fractional_input_digits: 2, - num_fractional_normal_digits: 2, - num_fractional_trailing_zero_digits: 2, - }, - default_debit_threshold: "ARS:10" as AmountString, - version: "1:0:0", - }; - const baseUrl = new URL("http://bank.example/"); - const http = { - fetch: async () => { - throw new Error("story made an unexpected HTTP request"); - }, - } as any; - const bank = new TalerCoreBankHttpClient(baseUrl.href, http); - const conversion = new TalerBankConversionHttpClient( - bank.getConversionInfoAPI().href, - http, - ); - const ctx2 = create(BankApiProviderTesting, { - children, - value: { - url: baseUrl, - config: cfg, - lib: { - bank, - conversion, - conversionForUser: (username: string) => - new TalerBankConversionHttpClient( - bank.getConversionInfoAPIForUser(username).href, - http, - ), - conversionForClass: (classId: number) => - new TalerBankConversionHttpClient( - bank.getConversionInfoAPIForClass(classId).href, - http, - ), - }, - hints: [], - onActivity: () => () => undefined, - cancelRequest: () => undefined, - }, - }); - return create(NotificationProvider, { - children: create(TalerWalletIntegrationTestingProvider, { - value: { publishTalerAction: () => undefined }, - children: ctx2, - }), - }); -} diff --git a/packages/libeufin-bank-webui/src/stories.tsx b/packages/libeufin-bank-webui/src/stories.tsx @@ -24,12 +24,15 @@ import * as pages from "./pages/index.stories.js"; import * as components from "./components/index.examples.js"; import { renderStories } from "@gnu-taler/web-util/browser"; +import { BankStoryContext } from "./stories-context.js"; +import "./scss/main.css"; function main(): void { renderStories( { pages, components }, { strings, + getWrapperForGroup: () => BankStoryContext, }, ); } diff --git a/packages/libeufin-bank-webui/tailwind.config.js b/packages/libeufin-bank-webui/tailwind.config.js @@ -1,6 +1,6 @@ /* This file is part of GNU Taler - (C) 2022-2024 Taler Systems S.A. + (C) 2022-2024, 2026 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 @@ -13,256 +13,277 @@ 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 { default as tw_form } from '@tailwindcss/forms'; - - +import { default as tw_form } from "@tailwindcss/forms"; export default { content: { relative: true, files: [ "./src/**/*.{html,tsx}", - "./node_modules/@gnu-taler/web-util/src/**/*.{html,tsx}" + "./node_modules/@gnu-taler/web-util/src/**/*.{html,tsx}", ], }, theme: { extend: { colors: { - // https://docs.taler.net/design-documents/066-wallet-color-scheme.html + // Regional Currency Bank palette. Structural brand colors and action + // colors are separate so that navigation does not compete with CTAs. + + // BRAND + brand: "#132e35", + brandMuted: "#e8efed", + onBrand: "#faf8f5", - // PRIMARY + // PRIMARY /** * Main action color (e.g. filled buttons, tabs, icons) */ - 'primary': '#0042b3', + primary: "#d97706", + primaryHover: "#b45309", + primaryDark: "#b45309", + primaryMuted: "#fff3df", /** * Text/icons placed on top of primary */ - 'onPrimary': '#ffffff', + onPrimary: "#1f2937", /** * Background for FABs, cards, filled fields */ - 'primaryContainer': '#d3deff', + primaryContainer: "#fde7c2", /** * Foreground for primaryContainer */ - 'onPrimaryContainer': '#00134a', + onPrimaryContainer: "#78350f", /** * primary in dark mode */ - 'darkPrimary': '#b4c5ff', + darkPrimary: "#f6b94d", /** * Text/icons on darkPrimary */ - 'darkOnPrimary': '#002a78', + darkOnPrimary: "#1f2937", /** * Container in dark mode */ - 'darkPrimaryContainer': '#0042b3', + darkPrimaryContainer: "#92400e", /** * Foreground on container in dark */ - 'darkOnPrimaryContainer': '#e5ebff', + darkOnPrimaryContainer: "#fff3df", - // SECONDARY + // SECONDARY /** - * Secondary buttons, chips, and passive UI states - */ - 'secondary': '#586a88', + * Secondary buttons, chips, and passive UI states + */ + secondary: "#2d6a4f", /** * Foreground on secondary */ - 'onSecondary': '#ffffff', + onSecondary: "#ffffff", /** * Background for secondary surfaces */ - 'secondaryContainer': '#d9e3f9', + secondaryContainer: "#e3eee8", /** * Foreground on secondaryContainer */ - 'onSecondaryContainer': '#111c2b', + onSecondaryContainer: "#132e35", /** * Secondary color in dark mode */ - 'darkSecondary': '#a4c9ff', + darkSecondary: "#8bc4a5", /** * Text/icons on darkPrimary */ - 'darkOnSecondary': '#00315d', + darkOnSecondary: "#132e35", /** * Container in dark mode */ - 'darkSecondaryContainer': '#72a3e5', + darkSecondaryContainer: "#214d3a", /** * Foreground on container in dark */ - 'darkOnSecondaryContainer': '#003869', + darkOnSecondaryContainer: "#e3eee8", - // TERTIARY + // TERTIARY /** - * Used for tags, emphasis markers - */ - 'tertiary': '#338af0', + * Used for tags, emphasis markers + */ + tertiary: "#b45309", /** * Text/icons on tertiary */ - 'onTertiary': '#ffffff', + onTertiary: "#ffffff", /** * Input field backgrounds, selected indicators */ - 'tertiaryContainer': '#d1e4ff', + tertiaryContainer: "#f7e2d3", /** * Text/icons on tertiaryContainer */ - 'onTertiaryContainer': '#001c39', + onTertiaryContainer: "#552006", /** * Accent color in dark mode */ - 'darkTertiary': '#8dd1e5', + darkTertiary: "#8dd1e5", /** * Foreground in dark */ - 'darkOnTertiary': '#003641', + darkOnTertiary: "#003641", /** * Container fill in dark */ - 'darkTertiaryContainer': '#166577', + darkTertiaryContainer: "#166577", /** * Text/icons on dark container */ - 'darkOnTertiaryContainer': '#9ce0f5', + darkOnTertiaryContainer: "#9ce0f5", // ERROR /** - * Main error color for messages or outlines - */ - 'error': '#b3261e', + * Main error color for messages or outlines + */ + error: "#b3261e", /** - * Text/icons on error surfaces + * Text/icons on error surfaces */ - 'onError': '#ffffff', + onError: "#ffffff", /** - * + * */ - 'errorContainer': '#f9dedc', + errorContainer: "#f9dedc", /** - * + * */ - 'onErrorContainer': '#410e0b', + onErrorContainer: "#410e0b", /** - * + * */ - 'darkError': '#ffb4aa', + darkError: "#ffb4aa", /** - * + * */ - 'darkOnError': '#690003', + darkOnError: "#690003", /** - * + * */ - 'darkErrorContainer': '#b3261e', + darkErrorContainer: "#b3261e", /** - * + * */ - 'darkOnErrorContainer': '#ffcbc4', + darkOnErrorContainer: "#ffcbc4", // SUCCESS /** - * - */ - 'success': '#337a40', + * + */ + success: "#337a40", /** - * + * */ - 'onSuccess': '#ffffff', + onSuccess: "#ffffff", /** - * + * */ - 'successContainer': '#2e8534', + successContainer: "#eaf6ec", /** - * + * */ - 'onSuccessContainer': '#f7fff1', + onSuccessContainer: "#1d3522", /** - * + * */ - 'darkSuccess': '#337a40', + darkSuccess: "#8ed29b", /** - * + * */ - 'darkOnSuccess': '#ffffff', + darkOnSuccess: "#ffffff", /** - * + * */ - 'darkSuccessContainer': '#1d3522', + darkSuccessContainer: "#1d3522", /** - * + * */ - 'darkOnSuccessContainer': '#eaf6ec', + darkOnSuccessContainer: "#eaf6ec", // WARNING /** - * Alert banners, passive warnings - */ - 'warning': '#f99c06', + * Alert banners, passive warnings + */ + warning: "#f99c06", /** - * + * */ - 'onWarning': '#000000', + onWarning: "#000000", /** - * + * */ - 'warningContainer': '#fdedd3', + warningContainer: "#fdedd3", /** - * + * */ - 'onWarningContainer': '#6b4706', + onWarningContainer: "#6b4706", /** - * + * */ - 'darkWarning': '#f99c06', + darkWarning: "#f99c06", /** - * + * */ - 'darkOnWarning': '#000000', + darkOnWarning: "#000000", /** - * + * */ - 'darkWarningContainer': '#664200', + darkWarningContainer: "#664200", /** - * + * */ - 'darkOnWarningContainer': '#fdedd3', + darkOnWarningContainer: "#fdedd3", // BACKGROUND /** - * App-wide background color - */ - 'background': '#fdfdff', + * App-wide background color + */ + background: "#faf8f5", /** - * + * */ - 'onBackground': '#1a1c1f', + onBackground: "#1f2937", /** - * Background in dark mode + * Background in dark mode */ - 'darkBackground': '#11131a', + darkBackground: "#11131a", /** - * + * */ - 'darkOnBackground': '#e2e2eb', + darkOnBackground: "#e2e2eb", // OUTLINE /** - * Used for input borders, field outlines - */ - 'outline': '#767880', + * Used for input borders, field outlines + */ + outline: "#68736f", /** - * Decorative borders, dividers + * Decorative borders, dividers */ - 'outlineVariant': '#c4c6d0', - }, + outlineVariant: "#d8d2ca", + // Keep shared web-util components within the bank palette. + indigo: { + 50: "#fff8ed", + 100: "#fff3df", + 200: "#fde7c2", + 300: "#f6c778", + 400: "#e99a2f", + 500: "#d97706", + 600: "#b45309", + 700: "#92400e", + 800: "#78350f", + 900: "#552006", + 950: "#361205", + }, + }, }, }, plugins: [tw_form], diff --git a/packages/taler-merchant-webui/package.json b/packages/taler-merchant-webui/package.json @@ -24,10 +24,10 @@ }, "dependencies": { "@gnu-taler/taler-util": "workspace:*", + "@gnu-taler/web-util": "workspace:*", "@preact/signals": "^2.0.0", "chart.js": "^4.4.1", "preact": "10.29.8", - "qrcode-generator": "^1.4.4", "swr": "2.2.2", "wouter-preact": "^3.0.0" }, diff --git a/packages/taler-merchant-webui/src/screens/screens.test.tsx b/packages/taler-merchant-webui/src/screens/screens.test.tsx @@ -7839,23 +7839,22 @@ test("the Taler QR component uses the branded frame proportions and smaller logo const frame = container.querySelector( ".taler-qr-frame", - ) as HTMLElement | null; + ) as SVGSVGElement | null; assert.ok(frame); - assert.strictEqual(frame.style.borderRadius, "8%"); + assert.strictEqual(frame.getAttribute("viewBox"), "0 0 100 100"); + assert.strictEqual(frame.getAttribute("width"), "200"); + assert.strictEqual(frame.querySelector("rect")?.getAttribute("rx"), "8"); assert.strictEqual( frame.querySelectorAll(".taler-qr-motion-segment").length, 41, ); - const surface = Array.from(frame.children).find( - (child) => child.tagName === "DIV", - ) as HTMLElement | undefined; - assert.strictEqual(surface?.style.width, "87.5%"); - assert.strictEqual(surface?.style.height, "87.5%"); + const surface = frame.querySelector('rect[x="6.25"]'); + assert.strictEqual(surface?.getAttribute("width"), "87.5"); + assert.strictEqual(surface?.getAttribute("height"), "87.5"); const logo = frame.querySelector(".taler-qr-logo"); assert.ok(logo); - const logoReserve = logo.parentElement as HTMLElement; - assert.strictEqual(logoReserve.style.height, "12.5%"); - assert.strictEqual(logoReserve.style.boxShadow, ""); + assert.strictEqual(logo.getAttribute("height"), "7.25"); + assert.strictEqual(logo.getAttribute("width"), "17.3"); render(null, container); document.body.removeChild(container); @@ -7869,14 +7868,13 @@ test("the Swiss QR component uses the proportional SIX recognition mark", () => container, ); - const frame = container.firstElementChild?.firstElementChild as HTMLElement; - assert.strictEqual(frame.style.width, "100%"); - assert.strictEqual(frame.style.aspectRatio, "1 / 1"); - const cross = frame.querySelector(".swiss-qr-cross") as HTMLElement | null; + const frame = container.firstElementChild?.firstElementChild as SVGSVGElement; + assert.strictEqual(frame.getAttribute("width"), "360"); + assert.strictEqual(frame.getAttribute("viewBox"), "0 0 100 100"); + const cross = frame.querySelector(".swiss-qr-cross") as SVGSVGElement | null; assert.ok(cross); - assert.strictEqual(cross.style.width, "15.217%"); - assert.strictEqual(cross.style.height, "15.217%"); - assert.strictEqual(cross.style.zIndex, "2"); + assert.strictEqual(cross.getAttribute("width"), "15.217"); + assert.strictEqual(cross.getAttribute("height"), "15.217"); render(null, container); document.body.removeChild(container); @@ -7885,7 +7883,7 @@ test("the Swiss QR component uses the proportional SIX recognition mark", () => /** * How many QR codes the verification screen draws for an instruction. * - * `TalerQrCode` renders each one as an SVG data URI, so counting them is a + * `TalerQrCode` marks each rendered code, so counting them is a * direct reading of what a merchant would see, not of what the screen intended. */ function kycQrCount(instruction: unknown): { codes: number; html: string } { @@ -7900,7 +7898,7 @@ function kycQrCount(instruction: unknown): { codes: number; html: string } { />, container, ); - const codes = container.querySelectorAll('img[src^="data:image/svg"]').length; + const codes = container.querySelectorAll("[data-taler-qr-code]").length; const html = container.innerHTML; render(null, container); document.body.removeChild(container); diff --git a/packages/taler-merchant-webui/src/style.css b/packages/taler-merchant-webui/src/style.css @@ -2,47 +2,6 @@ @tailwind components; @tailwind utilities; -@keyframes taler-qr-line-travel { - from { - stroke-dashoffset: var(--taler-qr-segment-start); - } - to { - stroke-dashoffset: calc(var(--taler-qr-segment-start) - 400px); - } -} - -/* Taler payment component proportions from design document 090. */ -.taler-qr-frame { - background: #f1f1f4; - isolation: isolate; - overflow: hidden; -} - -.taler-qr-motion { - position: absolute; - inset: 0; - width: 100%; - height: 100%; - z-index: 0; - pointer-events: none; -} - -.taler-qr-motion-segment { - stroke-dashoffset: var(--taler-qr-segment-start); - animation: taler-qr-line-travel 8s linear infinite; -} - -.taler-qr-logo { - width: auto; - height: 58%; -} - -@media (prefers-reduced-motion: reduce) { - .taler-qr-motion-segment { - animation: none; - } -} - /* Custom scrollbars for sidebars and menus */ .custom-scrollbar::-webkit-scrollbar, aside nav::-webkit-scrollbar { diff --git a/packages/taler-merchant-webui/src/ui/TalerQrCode.test.ts b/packages/taler-merchant-webui/src/ui/TalerQrCode.test.ts @@ -1,7 +0,0 @@ -import { test } from "node:test"; -import assert from "node:assert"; -import { generateQrDataUrl } from "./TalerQrCode.js"; - -test("QR generation reports oversized input as an error", () => { - assert.deepEqual(generateQrDataUrl("x".repeat(100_000)), { type: "error" }); -}); diff --git a/packages/taler-merchant-webui/src/ui/TalerQrCode.tsx b/packages/taler-merchant-webui/src/ui/TalerQrCode.tsx @@ -14,214 +14,29 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ +import { + TalerQrCode as SharedTalerQrCode, + generateQrDataUrl, +} from "@gnu-taler/web-util/browser"; +import type { TalerQrCodeProps as SharedTalerQrCodeProps } from "@gnu-taler/web-util/browser"; import type { VNode } from "preact"; -import qrcode from "qrcode-generator"; -import { TalerLogo } from "./TalerLogo.js"; import { useTranslation } from "../context/translation.js"; -import { useClipboard } from "../utils/useClipboard.js"; -export interface TalerQrCodeProps { - url: string; - copyUrl?: string; - size?: number; - alt?: string; - variant?: "taler" | "swiss" | "plain"; -} - -// Short adjacent strokes make one soft gradient line. The dash pattern repeats -// halfway around the frame, producing the two opposing lines in the brand -// design while still letting both bend cleanly around rounded corners. -const TALER_QR_LINE_COLORS = Array.from({ length: 41 }, (_, index) => { - const progress = index / 40; - const brandStrength = 1 - Math.abs(progress * 2 - 1); - const neutral = [241, 241, 244]; - const brand = [0, 66, 179]; - const channel = (i: number) => Math.round(neutral[i]! + (brand[i]! - neutral[i]!) * brandStrength); - return `rgb(${channel(0)}, ${channel(1)}, ${channel(2)})`; -}); +export { generateQrDataUrl }; -export function generateQrDataUrl(text: string): - | { type: "success"; url: string } - | { type: "error" } { - try { - const qr = qrcode(0, "H"); - qr.addData(text, "Byte"); - qr.make(); - const svgTag = qr.createSvgTag({ - scalable: true, - margin: 1, - }); - return { type: "success", url: `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svgTag)}` }; - } catch { - return { type: "error" }; - } -} +export type TalerQrCodeProps = Omit<SharedTalerQrCodeProps, "labels">; -export function TalerQrCode({ - url, - copyUrl, - size = 280, - alt: propAlt, - variant = "taler", -}: TalerQrCodeProps): VNode { +export function TalerQrCode(props: TalerQrCodeProps): VNode { const { t } = useTranslation(); - const alt = propAlt ?? t`Payment QR Code`; - const qrResult = generateQrDataUrl(url); - const textToCopy = copyUrl || url; - const { copied, copy } = useClipboard(); - - const handleCopy = () => { - void copy(textToCopy); - }; - return ( - <div - class="flex flex-col items-center justify-center space-y-4" - style={{ width: `${size}px`, maxWidth: "100%" }} - > - {/* Container styling based on variant */} - <div - style={{ - width: "100%", - aspectRatio: "1 / 1", - padding: variant === "taler" ? undefined : "10px", - borderRadius: variant === "taler" ? "8%" : "24px", - position: "relative", - display: "flex", - alignItems: "center", - justifyContent: "center", - }} - class={variant === "taler" ? "taler-qr-frame" : "bg-gray-100 border border-gray-300 rounded-2xl shadow-sm"} - > - {variant === "taler" && qrResult.type === "success" && ( - <svg - class="taler-qr-motion" - viewBox="0 0 100 100" - preserveAspectRatio="none" - aria-hidden="true" - > - {TALER_QR_LINE_COLORS.map((color, index) => { - const segmentLength = 57 / TALER_QR_LINE_COLORS.length; - return ( - <rect - key={index} - class="taler-qr-motion-segment" - x="4.9" - y="4.9" - width="90.2" - height="90.2" - rx="4.2" - pathLength="400" - fill="none" - stroke={color} - stroke-width="2.6" - stroke-dasharray={`${segmentLength} ${200 - segmentLength}`} - style={`--taler-qr-segment-start: ${-index * segmentLength}px`} - /> - ); - })} - </svg> - )} - - {/* Inner White QR Box */} - <div - style={{ - width: variant === "taler" ? "87.5%" : "100%", - height: variant === "taler" ? "87.5%" : "100%", - backgroundColor: "#FFFFFF", - borderRadius: variant === "taler" ? "4%" : "16px", - padding: variant === "taler" ? undefined : "8px", - display: "flex", - alignItems: "center", - justifyContent: "center", - position: "relative", - zIndex: 1, - }} - > - {qrResult.type === "success" ? ( - <img - src={qrResult.url} - alt={alt} - style={{ width: "100%", height: "100%", objectFit: "contain" }} - /> - ) : ( - <div class="space-y-2 p-3 text-center"> - <div class="text-xs font-semibold text-red-700">{t`The QR code could not be generated.`}</div> - <div class="max-h-24 overflow-auto break-all rounded bg-gray-50 p-2 text-2xs font-mono text-gray-700">{url}</div> - </div> - )} - </div> - - {/* Centered Overlay Icon depending on Variant */} - {variant === "taler" && qrResult.type === "success" && ( - <div - style={{ - position: "absolute", - top: "50%", - left: "50%", - transform: "translate(-50%, -50%)", - backgroundColor: "#FFFFFF", - width: "27%", - height: "12.5%", - borderRadius: "9999px", - display: "flex", - alignItems: "center", - justifyContent: "center", - zIndex: 2, - }} - > - <TalerLogo class="taler-qr-logo" color="#0042b3" /> - </div> - )} - - {variant === "swiss" && qrResult.type === "success" && ( - // SIX supplies this exact 7 mm artwork for overlaying a 46 mm Swiss - // QR Code. Keeping the ratio here makes the recognition mark scale - // with responsive on-screen codes as well as with fixed-size ones. - <svg - class="swiss-qr-cross" - viewBox="0 0 19.8 19.8" - aria-hidden="true" - style={{ - position: "absolute", - top: "50%", - left: "50%", - transform: "translate(-50%, -50%)", - width: "15.217%", - height: "15.217%", - zIndex: 2, - }} - > - <polygon points="18.3,0.7 1.6,0.7 0.7,0.7 0.7,1.6 0.7,18.3 0.7,19.1 1.6,19.1 18.3,19.1 19.1,19.1 19.1,18.3 19.1,1.6 19.1,0.7" /> - <rect x="8.3" y="4" width="3.3" height="11" fill="#FFFFFF" /> - <rect x="4.4" y="7.9" width="11" height="3.3" fill="#FFFFFF" /> - <polygon - points="0.7,1.6 0.7,18.3 0.7,19.1 1.6,19.1 18.3,19.1 19.1,19.1 19.1,18.3 19.1,1.6 19.1,0.7 18.3,0.7 1.6,0.7 0.7,0.7" - fill="none" - stroke="#FFFFFF" - stroke-width="1.4357" - stroke-miterlimit="10" - /> - </svg> - )} - </div> - - {/* Copy / Launch URI Actions */} - {(copyUrl || qrResult.type === "error") && ( - <div class="flex items-center space-x-2"> - <button - type="button" - onClick={handleCopy} - class={`px-3 py-1.5 text-xs font-semibold rounded-md border transition-all shadow-2xs ${ - copied - ? "bg-emerald-100 text-emerald-900 border-emerald-300 font-bold" - : "bg-white hover:bg-gray-50 text-gray-700 border-gray-300" - }`} - > - {copied ? t`✓ Copied!` : t`Copy URI`} - </button> - </div> - )} - </div> + <SharedTalerQrCode + {...props} + alt={props.alt ?? t`Payment QR Code`} + labels={{ + copy: t`Copy URI`, + copied: t`✓ Copied!`, + generationFailed: t`The QR code could not be generated.`, + }} + /> ); } diff --git a/packages/taler-merchant-webui/tailwind.config.js b/packages/taler-merchant-webui/tailwind.config.js @@ -1,6 +1,9 @@ /** @type {import('tailwindcss').Config} */ export default { - content: ["./src/**/*.{ts,tsx,html}"], + content: [ + "./src/**/*.{ts,tsx,html}", + "./node_modules/@gnu-taler/web-util/src/**/*.{ts,tsx,html}", + ], theme: { extend: { /** diff --git a/packages/taler-merchant-webui/tsconfig.json b/packages/taler-merchant-webui/tsconfig.json @@ -35,6 +35,6 @@ // to true and the affected types imported explicitly instead. "preserveSymlinks": false }, - "references": [{ "path": "../taler-util/" }], + "references": [{ "path": "../taler-util/" }, { "path": "../web-util/" }], "include": ["src/**/*"] } diff --git a/packages/taler-util/src/payto.test.ts b/packages/taler-util/src/payto.test.ts @@ -400,6 +400,16 @@ test("Paytos helper functions extract fields and construct URIs correctly", () = assert.strictEqual(Paytos.getAccountHolder(bankPayto), "Bob"); assert.strictEqual(Paytos.getBankHost(bankPayto), "bank.example.com"); assert.strictEqual(Paytos.getAccountNumber(bankPayto), "bob"); + assert.strictEqual(bankPayto.displayName, "bob @ bank.example.com"); + + const bankPaytoWithPath = Paytos.parsePaytoUri( + "payto://x-taler-bank/bank.example.com/bank/api/alice", + ); + assert.ok(bankPaytoWithPath); + assert.strictEqual( + bankPaytoWithPath.displayName, + "alice @ bank.example.com/bank/api", + ); const constructedIban = Paytos.constructPayto({ targetType: "iban", diff --git a/packages/taler-util/src/payto.ts b/packages/taler-util/src/payto.ts @@ -530,7 +530,7 @@ export namespace Paytos { params, normalizedPath, fullPath, - displayName: `${account}@${url}`, + displayName: `${account} @ ${host}`, }; } diff --git a/packages/web-util/package.json b/packages/web-util/package.json @@ -60,6 +60,7 @@ }, "dependencies": { "@types/chrome": "0.0.197", + "qrcode-generator": "^1.4.4", "tailwindcss": "3.4.17" }, "pogen": { diff --git a/packages/web-util/src/components/CopyButton.tsx b/packages/web-util/src/components/CopyButton.tsx @@ -46,11 +46,13 @@ export function CopyButton({ style, children, getContent, + onCopyComplete, }: { children?: ComponentChildren; class: string; style?: CSSProperties; getContent: () => string; + onCopyComplete?: () => void; }): VNode { const [copied, setCopied] = useState(false); const { i18n } = useTranslationContext(); @@ -63,6 +65,8 @@ export function CopyButton({ } catch (error) { console.error("Could not write to the clipboard", error); prompt(i18n.str`Copy this text manually:`, content); + } finally { + onCopyComplete?.(); } } useEffect(() => { diff --git a/packages/web-util/src/components/Footer.tsx b/packages/web-util/src/components/Footer.tsx @@ -1,14 +1,20 @@ import { useTranslationContext } from "../context/translation.js"; -import { h } from "preact"; +import { ComponentChildren, h } from "preact"; export function Footer({ testingUrlKey, VERSION, GIT_HASH, + backgroundClass, + actions, + variant = "default", }: { VERSION?: string; GIT_HASH?: string; testingUrlKey?: string; + backgroundClass?: string; + actions?: ComponentChildren; + variant?: "default" | "demo"; }) { const { i18n } = useTranslationContext(); @@ -33,42 +39,84 @@ export function Footer({ ) : ( "" ); - return ( - <footer class="bottom-4 my-4 mx-8 bg-slate-200"> - <div> - <p class="text-xs leading-5 text-gray-400"> - <i18n.Translate> - Learn more about{" "} + const effectiveBackgroundClass = + backgroundClass ?? (variant === "demo" ? "" : "bg-slate-200"); + const testingNotice = testingUrlKey && testingUrl && ( + <p class="text-xs leading-5 text-gray-300"> + <i18n.Translate> + Testing with <span>{testingUrl}</span> + </i18n.Translate>{" "} + <a + href="" + onClick={(e) => { + e.preventDefault(); + localStorage.removeItem(testingUrlKey); + window.location.reload(); + }} + > + <i18n.Translate>stop testing</i18n.Translate> + </a> + </p> + ); + + if (variant === "demo") { + return ( + <footer + class={`mt-auto border-t border-outlineVariant ${effectiveBackgroundClass}`} + > + <div class="mx-auto flex min-h-[4.25rem] w-[calc(100%-2rem)] max-w-[68rem] flex-wrap items-center justify-between gap-x-5 gap-y-2 py-4 text-[0.82rem] text-secondary"> + <p class="m-0"> <a target="_blank" rel="noreferrer noopener" - class="font-semibold text-gray-500 hover:text-gray-400" + class="text-inherit underline hover:text-primary" href="https://taler.net" > - GNU Taler + <i18n.Translate> + Learn more about <span>GNU Taler</span> + </i18n.Translate> </a> - </i18n.Translate> - </p> + . + </p> + <div class="flex flex-wrap items-center gap-x-4 gap-y-2"> + <p class="m-0"> + Copyright &copy; 2014&mdash;2026 Taler Systems SA. {versionText} + </p> + {actions ? <div class="shrink-0">{actions}</div> : undefined} + </div> + {testingNotice ? ( + <div class="basis-full">{testingNotice}</div> + ) : undefined} + </div> + </footer> + ); + } + + return ( + <footer class={`bottom-4 my-4 mx-8 ${effectiveBackgroundClass}`}> + <div class="flex items-start justify-between gap-4"> + <div> + <p class="text-xs leading-5 text-gray-400"> + <i18n.Translate> + Learn more about{" "} + <a + target="_blank" + rel="noreferrer noopener" + class="font-semibold text-gray-500 hover:text-gray-400" + href="https://taler.net" + > + GNU Taler + </a> + </i18n.Translate> + </p> + <p class="text-xs leading-5 text-gray-400"> + Copyright &copy; 2014&mdash;2026 Taler Systems SA.{" "} + {versionText}{" "} + </p> + </div> + {actions ? <div class="shrink-0">{actions}</div> : undefined} </div> - <div style="flex-grow:1" /> - <p class="text-xs leading-5 text-gray-400"> - Copyright &copy; 2014&mdash;2025 Taler Systems SA. {versionText}{" "} - </p> - {testingUrlKey && testingUrl && ( - <p class="text-xs leading-5 text-gray-300"> - Testing with {testingUrl}{" "} - <a - href="" - onClick={(e) => { - e.preventDefault(); - localStorage.removeItem(testingUrlKey); - window.location.reload(); - }} - > - stop testing - </a> - </p> - )} + {testingNotice} </footer> ); } diff --git a/packages/web-util/src/components/Header.tsx b/packages/web-util/src/components/Header.tsx @@ -12,6 +12,8 @@ interface Props { children?: ComponentChildren; onLogout: (() => void) | undefined; sites: Array<Array<string>>; + showMenu?: boolean; + backgroundClass?: string; } export function Header({ @@ -22,13 +24,17 @@ export function Header({ sites, onLogout, children, + showMenu = true, + backgroundClass = "bg-primary", }: Props): VNode { const { i18n } = useTranslationContext(); const [open, setOpen] = useState(false); // const ns = useNotifications(); return ( <Fragment> - <header class="bg-primary w-full mx-auto px-2 border-b border-opacity-25 border-indigo-400"> + <header + class={`${backgroundClass} w-full mx-auto px-2 border-b border-opacity-25 border-indigo-400`} + > <div class="flex flex-row h-16 items-center "> <div class="flex px-2 justify-start"> <div class="flex-shrink-0 rounded-lg"> @@ -133,40 +139,40 @@ export function Header({ )} <LangSelector type="icon" /> - <button - type="button" - name="toggle sidebar" - class="relative inline-flex items-center justify-center rounded-md bg-primary p-1 text-indigo-200 hover:bg-indigo-500 hover:bg-opacity-75 hover:text-white focus:outline-none focus:ring-2 focus:ring-white focus:ring-offset-2 focus:ring-offset-indigo-600" - aria-controls="mobile-menu" - aria-expanded="false" - onClick={(e) => { - setOpen(!open); - }} - > - <span class="absolute -inset-0.5"></span> - <span class="sr-only"> - <i18n.Translate>Open settings</i18n.Translate> - </span> - <svg - class="block h-10 w-10" - fill="none" - viewBox="0 0 24 24" - stroke-width="2" - stroke="currentColor" - aria-hidden="true" + {showMenu ? ( + <button + type="button" + name="toggle sidebar" + class="relative inline-flex items-center justify-center rounded-md bg-primary p-1 text-indigo-200 hover:bg-indigo-500 hover:bg-opacity-75 hover:text-white focus:outline-none focus:ring-2 focus:ring-white focus:ring-offset-2 focus:ring-offset-indigo-600" + aria-controls="mobile-menu" + aria-expanded="false" + onClick={() => setOpen(!open)} > - <path - stroke-linecap="round" - stroke-linejoin="round" - d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" - /> - </svg> - </button> + <span class="absolute -inset-0.5"></span> + <span class="sr-only"> + <i18n.Translate>Open settings</i18n.Translate> + </span> + <svg + class="block h-10 w-10" + fill="none" + viewBox="0 0 24 24" + stroke-width="2" + stroke="currentColor" + aria-hidden="true" + > + <path + stroke-linecap="round" + stroke-linejoin="round" + d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" + /> + </svg> + </button> + ) : undefined} </div> </div> </header> - {open && ( + {showMenu && open && ( <div class="relative z-10" name="sidebar overlay" diff --git a/packages/web-util/src/components/LangSelector.tsx b/packages/web-util/src/components/LangSelector.tsx @@ -46,10 +46,14 @@ function getLangName(s: keyof LangsNames | string): string { return String(s); } +function getPlainLangName(s: keyof LangsNames | string): string { + return getLangName(s).replace(/ \[[^\]]+\]$/, ""); +} + export function LangSelector({ type = "select", }: { - type?: "select" | "icon"; + type?: "select" | "icon" | "plain"; }): VNode { const { lang, changeLanguage, completeness, supportedLang } = useTranslationContext(); @@ -70,7 +74,7 @@ export function LangSelector({ }; }, []); return ( - <div class="m-2 block"> + <div class={type === "plain" ? "relative flex items-center" : "m-2 block"}> {(function () { switch (type) { case "select": { @@ -132,12 +136,39 @@ export function LangSelector({ </button> ); } + case "plain": { + return ( + <button + type="button" + class="inline-flex min-h-11 items-center border-0 bg-transparent p-0 font-semibold text-onBackground hover:text-primary focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 max-md:min-h-9 max-md:w-10 max-md:overflow-hidden max-md:whitespace-nowrap" + aria-haspopup="listbox" + aria-expanded={!hidden} + onClick={(e) => { + setHidden(!hidden); + e.stopPropagation(); + }} + > + <span class="max-md:hidden">{getPlainLangName(lang)}</span> + <span class="hidden max-md:inline" aria-hidden="true"> + 文 + </span> + <span + aria-hidden="true" + class="ml-2 mt-[-0.2rem] h-[0.4rem] w-[0.4rem] rotate-45 border-b-[0.1rem] border-r-[0.1rem] border-current max-md:hidden" + /> + </button> + ); + } } })()} {!hidden && ( <ul - class="absolute m-0 max-h-60 overflow-auto rounded-md bg-white py-1 text-base text-left shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm" + class={`absolute right-0 z-50 m-0 max-h-60 overflow-auto bg-white text-left shadow-lg focus:outline-none ${ + type === "plain" + ? "top-[calc(100%-0.1rem)] w-[9.5rem] max-w-[calc(100vw-2rem)] rounded-[0.2rem] border border-outlineVariant py-[0.3rem] text-sm" + : "min-w-56 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 sm:text-sm" + }`} tabIndex={-1} style={type === "icon" ? { marginLeft: -110 } : {}} role="listbox" @@ -170,7 +201,11 @@ export function LangSelector({ .map((lang, idx) => ( <li key={idx} - class="text-gray-900 hover:bg-primary hover:bg-gray-300 cursor-pointer relative select-none py-2 pl-3 pr-9" + class={ + type === "plain" + ? "relative flex min-h-[2.4rem] cursor-pointer select-none items-center px-3 text-onBackground hover:text-primary" + : "text-gray-900 hover:bg-primary hover:bg-gray-300 cursor-pointer relative select-none py-2 pl-3 pr-9" + } role="option" onClick={() => { changeLanguage(lang); @@ -178,8 +213,14 @@ export function LangSelector({ }} > <span class="font-normal truncate flex justify-between "> - <span>{getLangName(lang)}</span> - <span>{(completeness as any)[lang]}%</span> + <span> + {type === "plain" + ? getPlainLangName(lang) + : getLangName(lang)} + </span> + {type === "plain" ? undefined : ( + <span>{(completeness as any)[lang]}%</span> + )} </span> <span class="text-indigo-600 absolute inset-y-0 right-0 flex items-center pr-4"> diff --git a/packages/web-util/src/components/NotificationBanner.test.tsx b/packages/web-util/src/components/NotificationBanner.test.tsx @@ -0,0 +1,189 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { i18n, setupI18n } from "@gnu-taler/taler-util"; +import { Window } from "happy-dom"; +import { h } from "preact"; +import { act } from "preact/test-utils"; +import { + NotificationProvider, + useNotificationContext, +} from "../context/notification.js"; +import { ToastBanner } from "./NotificationBanner.js"; + +setupI18n("en", {}); + +function installDom(): Window { + const window = new Window({ url: "https://bank.example/" }); + for (const [key, value] of Object.entries({ + window, + document: window.document, + navigator: window.navigator, + Node: window.Node, + Element: window.Element, + Event: window.Event, + MouseEvent: window.MouseEvent, + HTMLElement: window.HTMLElement, + HTMLButtonElement: window.HTMLButtonElement, + MutationObserver: window.MutationObserver, + })) { + Object.defineProperty(globalThis, key, { + configurable: true, + writable: true, + value, + }); + } + return window; +} + +function Harness() { + const notifications = useNotificationContext(); + return ( + <div> + <button + type="button" + onClick={() => + notifications.displayError( + i18n.str`Sign-in failed`, + new Error("diagnostic"), + i18n.str`Check your password.`, + i18n.str`Try again in a moment.`, + ) + } + > + Show error + </button> + <ToastBanner compact /> + </div> + ); +} + +function FilteredHarness() { + const notifications = useNotificationContext(); + return ( + <div> + <button + type="button" + onClick={() => + notifications.displayError( + i18n.str`First error`, + undefined, + i18n.str`First description`, + ) + } + > + Show first error + </button> + <button + type="button" + onClick={() => + notifications.displayError( + i18n.str`Second error`, + undefined, + i18n.str`Second description`, + ) + } + > + Show second error + </button> + <button + type="button" + onClick={() => notifications.displayInfo(i18n.str`Saved`)} + > + Show info + </button> + <button type="button" onClick={notifications.clearErrors}> + Clear errors + </button> + <div data-testid="error-outlet"> + <ToastBanner compact messageType="error" /> + </div> + <div data-testid="info-outlet"> + <ToastBanner compact messageType="info" /> + </div> + </div> + ); +} + +async function eventually(assertion: () => void): Promise<void> { + let lastError: unknown; + for (let attempt = 0; attempt < 50; attempt++) { + try { + assertion(); + return; + } catch (error) { + lastError = error; + await new Promise((resolve) => setTimeout(resolve, 0)); + } + } + throw lastError; +} + +test("compact notifications stay inline and disclose details on request", async () => { + const window = installDom(); + const { cleanup, render } = await import("@testing-library/preact"); + const view = render( + <NotificationProvider> + <Harness /> + </NotificationProvider>, + ); + + view.getByRole("button", { name: "Show error" }).click(); + const alert = await view.findByRole("alert"); + assert.equal(alert.classList.contains("fixed"), false); + assert.ok(view.getByText("Sign-in failed")); + assert.ok(view.getByText("Check your password.")); + assert.equal(view.queryByText("Try again in a moment."), null); + + await new Promise((resolve) => setTimeout(resolve, 0)); + await act(() => { + view.getByRole("button", { name: "Show details" }).click(); + }); + assert.ok(await view.findByText("Try again in a moment.")); + await act(() => { + view.getByRole("button", { name: "Dismiss notification" }).click(); + }); + await eventually(() => assert.equal(view.queryByRole("alert"), null)); + + cleanup(); + await window.happyDOM.abort(); +}); + +test("filtered notification outlets replace and clear errors independently", async () => { + const window = installDom(); + const { cleanup, render } = await import("@testing-library/preact"); + const view = render( + <NotificationProvider> + <FilteredHarness /> + </NotificationProvider>, + ); + + await act(() => { + view.getByRole("button", { name: "Show first error" }).click(); + }); + assert.ok(await view.findByText("First error")); + + await act(() => { + view.getByRole("button", { name: "Show second error" }).click(); + }); + assert.ok(await view.findByText("Second error")); + assert.equal(view.queryByText("First error"), null); + + await act(() => { + view.getByRole("button", { name: "Show info" }).click(); + }); + const errorOutlet = view.getByTestId("error-outlet"); + const infoOutlet = view.getByTestId("info-outlet"); + assert.match(errorOutlet.textContent, /Second error/); + assert.match(infoOutlet.textContent, /Saved/); + + await act(() => { + view.getByRole("button", { name: "Clear errors" }).click(); + }); + await eventually(() => + assert.equal(errorOutlet.querySelector('[role="alert"]'), null), + ); + assert.match(infoOutlet.textContent, /Saved/); + + cleanup(); + await window.happyDOM.abort(); +}); diff --git a/packages/web-util/src/components/NotificationBanner.tsx b/packages/web-util/src/components/NotificationBanner.tsx @@ -21,14 +21,113 @@ import { useNotificationContext } from "../context/notification.js"; import { useTranslationContext } from "../context/translation.js"; import { Attention } from "./Attention.js"; -export function ToastBanner(): VNode { +export function ToastBanner({ + compact = false, + messageType, +}: { + compact?: boolean; + messageType?: "error" | "info"; +}): VNode { const { i18n } = useTranslationContext(); const { notification: notifications } = useNotificationContext(); const [{ showDebugInfo }] = useCommonPreferences(); - const [moreInfo, setMoreInfo] = useState(false); - if (!notifications.length) return <Fragment />; + const notification = messageType + ? notifications.find((item) => item.message.type === messageType) + : notifications[0]; + const [expandedNotification, setExpandedNotification] = + useState<typeof notification>(); + const moreInfo = expandedNotification === notification; + if (!notification) return <Fragment />; + + if (compact) { + const isError = notification.message.type === "error"; + const errorMessage = + notification.message.type === "error" ? notification.message : undefined; + const descriptions = errorMessage?.description ?? []; + const visibleDescriptions = moreInfo + ? descriptions + : descriptions.slice(0, 1); + const debug = + errorMessage && showDebugInfo + ? JSON.stringify( + errorMessage.debug, + (key, value) => (key.startsWith("__") ? "..." : value), + 2, + ) + : undefined; + const copyText = [notification.message.title, ...descriptions, debug] + .filter((entry): entry is string => !!entry) + .join("\n\n"); + + return ( + <div + class={`rounded-md border px-3 py-2 text-sm ${ + isError + ? "border-red-200 bg-red-50 text-red-800" + : "border-green-200 bg-green-50 text-green-800" + }`} + role={isError ? "alert" : "status"} + > + <div class="flex items-start justify-between gap-3"> + <div class="min-w-0 flex-1"> + <div class="font-semibold">{notification.message.title}</div> + {visibleDescriptions.map((description, index) => ( + <p key={index} class="mt-1"> + {description} + </p> + ))} + {moreInfo && debug ? ( + <pre class="mt-2 max-h-64 overflow-auto whitespace-pre-wrap rounded bg-white/70 p-2 text-xs text-onBackground"> + {debug} + </pre> + ) : undefined} + {isError ? ( + <div class="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-xs font-semibold"> + {descriptions.length > 1 || debug ? ( + <button + type="button" + class="underline hover:no-underline" + aria-expanded={moreInfo} + onClick={() => + setExpandedNotification((current) => + current === notification ? undefined : notification, + ) + } + > + {moreInfo ? ( + <i18n.Translate>Hide details</i18n.Translate> + ) : ( + <i18n.Translate>Show details</i18n.Translate> + )} + </button> + ) : undefined} + {copyText ? ( + <button + type="button" + class="underline hover:no-underline" + onClick={() => + void navigator.clipboard?.writeText(copyText) + } + > + <i18n.Translate>Copy details</i18n.Translate> + </button> + ) : undefined} + </div> + ) : undefined} + </div> + <button + type="button" + class="shrink-0 rounded px-1 text-lg leading-none hover:bg-black/5 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-current" + aria-label={i18n.str`Dismiss notification`} + onClick={notification.acknowledge} + > + <span aria-hidden="true">×</span> + </button> + </div> + </div> + ); + } - const notification = notifications[0]; if (notification.message.type === "info") { return ( <Attention @@ -47,7 +146,7 @@ export function ToastBanner(): VNode { copy onClose={() => { notification.acknowledge(); - setMoreInfo(false); + setExpandedNotification(undefined); }} > {(moreInfo ? descriptions : descriptions.slice(0, 1)).map( @@ -58,7 +157,11 @@ export function ToastBanner(): VNode { ), )} {!moreInfo && descriptions.length > 1 ? ( - <button onClick={() => setMoreInfo(true)} class="text-grey"> + <button + type="button" + onClick={() => setExpandedNotification(notification)} + class="text-grey" + > <i18n.Translate>Show more info</i18n.Translate> </button> ) : undefined} diff --git a/packages/web-util/src/components/TalerQrCode.test.ts b/packages/web-util/src/components/TalerQrCode.test.ts @@ -0,0 +1,86 @@ +/* + This file is part of GNU Taler + (C) 2026 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 assert from "node:assert/strict"; +import test from "node:test"; +import { Window } from "happy-dom"; +import { h, render } from "preact"; +import { generateQrDataUrl } from "./TalerQrCode.js"; +import { TalerQrCode } from "./TalerQrCode.js"; + +function installDom(): Window { + const window = new Window({ url: "https://bank.example/" }); + for (const [key, value] of Object.entries({ + window, + document: window.document, + navigator: window.navigator, + HTMLElement: window.HTMLElement, + HTMLButtonElement: window.HTMLButtonElement, + })) { + Object.defineProperty(globalThis, key, { + configurable: true, + writable: true, + value, + }); + } + return window; +} + +test("QR generation returns an SVG data URL", () => { + const result = generateQrDataUrl("taler://pay/example.com/order/session"); + assert.equal(result.type, "success"); + if (result.type === "success") { + assert.match(result.url, /^data:image\/svg\+xml;charset=utf-8,/); + } +}); + +test("QR generation reports oversized input as an error", () => { + assert.deepEqual(generateQrDataUrl("x".repeat(100_000)), { type: "error" }); +}); + +test("the shared component renders the Taler frame and reports copied URIs", async () => { + const window = installDom(); + const container = document.createElement("div"); + document.body.appendChild(container); + Object.defineProperty(window.navigator, "clipboard", { + configurable: true, + value: { writeText: async () => undefined }, + }); + + render( + h(TalerQrCode, { + url: "taler://pay/example.com/order/session", + copyUrl: "https://example.com/pay", + labels: { copy: "Copy", copied: "Copied" }, + }), + container, + ); + + const frame = container.querySelector(".taler-qr-frame"); + assert.ok(frame); + assert.equal(frame.querySelectorAll(".taler-qr-motion-segment").length, 41); + assert.ok(frame.querySelector(".taler-qr-logo")); + + const button = container.querySelector("button"); + assert.ok(button); + button.click(); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.equal(button.textContent, "Copied"); + + render(null, container); + document.body.removeChild(container); + await window.happyDOM.abort(); +}); diff --git a/packages/web-util/src/components/TalerQrCode.tsx b/packages/web-util/src/components/TalerQrCode.tsx @@ -0,0 +1,368 @@ +/* + This file is part of GNU Taler + (C) 2026 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 { VNode, h } from "preact"; +import qrcode from "qrcode-generator"; + +export interface TalerQrCodeLabels { + copy: string; + copied: string; + generationFailed: string; +} + +export interface TalerQrCodeProps { + url: string; + copyUrl?: string; + size?: number; + alt?: string; + variant?: "taler" | "swiss" | "plain"; + labels?: Partial<TalerQrCodeLabels>; +} + +const DEFAULT_LABELS: TalerQrCodeLabels = { + copy: "Copy URI", + copied: "✓ Copied!", + generationFailed: "The QR code could not be generated.", +}; + +// Short adjacent strokes make one soft gradient line. The dash pattern repeats +// halfway around the frame, producing the two opposing lines in the brand +// design while still letting both bend cleanly around rounded corners. +const TALER_QR_LINE_COLORS = Array.from({ length: 41 }, (_, index) => { + const progress = index / 40; + const brandStrength = 1 - Math.abs(progress * 2 - 1); + const neutral = [241, 241, 244]; + const brand = [0, 66, 179]; + const channel = (i: number): number => + Math.round(neutral[i]! + (brand[i]! - neutral[i]!) * brandStrength); + return `rgb(${channel(0)}, ${channel(1)}, ${channel(2)})`; +}); + +export function generateQrDataUrl( + text: string, +): { type: "success"; url: string } | { type: "error" } { + try { + const qr = qrcode(0, "H"); + qr.addData(text, "Byte"); + qr.make(); + const svgTag = qr.createSvgTag({ + scalable: true, + margin: 1, + }); + return { + type: "success", + url: `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svgTag)}`, + }; + } catch { + return { type: "error" }; + } +} + +async function copyText(text: string): Promise<boolean> { + try { + if (typeof navigator !== "undefined" && navigator.clipboard) { + await navigator.clipboard.writeText(text); + return true; + } + if (typeof document === "undefined") return false; + const textarea = document.createElement("textarea"); + textarea.value = text; + document.body.appendChild(textarea); + try { + textarea.select(); + return document.execCommand("copy"); + } finally { + document.body.removeChild(textarea); + } + } catch { + return false; + } +} + +const copyResetTimers = new WeakMap< + HTMLButtonElement, + ReturnType<typeof setTimeout> +>(); + +function showCopied( + button: HTMLButtonElement, + labels: TalerQrCodeLabels, +): void { + const previousTimer = copyResetTimers.get(button); + if (previousTimer !== undefined) clearTimeout(previousTimer); + button.textContent = labels.copied; + button.classList.add( + "bg-emerald-100", + "border-emerald-300", + "text-emerald-900", + ); + const timer = setTimeout(() => { + button.textContent = labels.copy; + button.classList.remove( + "bg-emerald-100", + "border-emerald-300", + "text-emerald-900", + ); + copyResetTimers.delete(button); + }, 2000); + copyResetTimers.set(button, timer); +} + +function TalerLogo(): VNode { + return ( + <svg + class="taler-qr-logo" + x="41.35" + y="46.375" + width="17.3" + height="7.25" + viewBox="0 0 196.2 87.3" + aria-hidden="true" + > + <g + fill="#0042b3" + fill-rule="evenodd" + stroke-width="0.3" + transform="translate(-1,-1.1)" + > + <path d="m 86.7,1.1 c 15.6,0 29,9.4 36,23.2 h -5.9 A 35.1,35.1 0 0 0 86.7,6.5 C 67,6.5 51,23.6 51,44.7 c 0,10.4 3.8,19.7 10,26.6 a 31.4,31.4 0 0 1 -4.2,3 A 45.2,45.2 0 0 1 46,44.7 C 46,20.7 64.2,1.1 86.7,1.1 Z m 35.8,64.3 a 40.4,40.4 0 0 1 -39,22.8 c 3,-1.5 6,-3.5 8.6,-5.7 a 35.6,35.6 0 0 0 24.6,-17.1 z" /> + <path d="m 64.2,1.1 3.1,0.1 C 64.3,2.8 61.4,4.7 58.8,7 A 37.5,37.5 0 0 0 28.6,44.7 c 0,14.3 7.3,26.7 18,33.3 a 29.6,29.6 0 0 1 -8.5,0.2 c -9,-8 -14.6,-20 -14.6,-33.5 0,-24 18.2,-43.6 40.7,-43.6 z m 5.4,81.4 A 35.6,35.6 0 0 0 94.2,65.4 h 5.9 a 40.4,40.4 0 0 1 -39,22.8 c 3,-1.5 5.9,-3.5 8.5,-5.7 z M 94.4,24.3 A 37,37 0 0 0 81.8,11.5 29.6,29.6 0 0 1 90.3,11.3 c 4,3.6 7.4,8 9.9,13 z" /> + <path d="m 41.8,1.1 c 1,0 2,0 3.1,0.2 -3,1.5 -5.9,3.4 -8.5,5.6 A 37.5,37.5 0 0 0 6.1,44.7 c 0,21.1 16,38.3 35.7,38.3 12.6,0 23.6,-7 30,-17.6 h 5.8 a 40.4,40.4 0 0 1 -35.8,23 C 19.3,88.4 1,68.8 1,44.7 1,20.7 19.2,1.1 41.7,1.1 Z m 30.1,23.2 a 38.1,38.1 0 0 0 -4.5,-6.1 c 1.3,-1.2 2.7,-2.2 4.3,-3 2.3,2.7 4.4,5.8 6,9.1 z" /> + </g> + <path + fill="#0042b3" + d="m 75.1,33.3 h 9.2 v -5 H 60.9 v 5 H 70 v 26 h 5.1 z m 16.5,18.5 h 13.7 l 3,7.4 h 5.3 L 100.9,28 H 96.2 L 83.5,59.2 h 5.2 z m 11.8,-4.9 h -9.9 l 5,-12.4 z m 19.4,-18.6 h -4.6 v 31 h 20.6 v -5 h -16 z m 42.7,0 H 144 v 31 h 21.6 v -5 H 149 V 46 h 14.5 V 41.1 H 149 v -8 h 16.4 z m 24.7,10.1 c 0,1.6 -0.5,2.8 -1.6,3.8 -1.1,1 -2.6,1.4 -4.4,1.4 h -7.4 V 33.2 h 7.4 c 1.9,0 3.4,0.4 4.4,1.3 1,0.9 1.6,2.2 1.6,3.9 z m 6,20.8 -7.7,-11.7 c 1,-0.3 1.9,-0.7 2.7,-1.3 a 8.8,8.8 0 0 0 3.6,-4.6 c 0.4,-1 0.5,-2.2 0.5,-3.5 0,-1.5 -0.2,-2.9 -0.7,-4.1 a 8.4,8.4 0 0 0 -2.1,-3.1 c -1,-0.8 -2,-1.5 -3.4,-2 -1.3,-0.4 -2.8,-0.6 -4.5,-0.6 h -12.9 v 31 h 5 v -11 h 6.5 l 7,10.8 z" + /> + </svg> + ); +} + +function TalerFrame({ + qrUrl, + size, + alt, +}: { + qrUrl: string; + size: number; + alt: string; +}): VNode { + const animate = + typeof window === "undefined" || + typeof window.matchMedia !== "function" || + !window.matchMedia("(prefers-reduced-motion: reduce)").matches; + return ( + <svg + data-taler-qr-code + class="taler-qr-frame h-auto max-w-full" + width={size} + height={size} + viewBox="0 0 100 100" + role="img" + aria-label={alt} + > + <title>{alt}</title> + <rect width="100" height="100" rx="8" fill="#f1f1f4" /> + {TALER_QR_LINE_COLORS.map((color, index) => { + const segmentLength = 57 / TALER_QR_LINE_COLORS.length; + const start = -index * segmentLength; + return ( + <rect + key={index} + class="taler-qr-motion-segment" + x="4.9" + y="4.9" + width="90.2" + height="90.2" + rx="4.2" + pathLength="400" + fill="none" + stroke={color} + stroke-width="2.6" + stroke-dasharray={`${segmentLength} ${200 - segmentLength}`} + stroke-dashoffset={start} + > + {animate ? ( + <animate + attributeName="stroke-dashoffset" + from={String(start)} + to={String(start - 400)} + dur="8s" + repeatCount="indefinite" + /> + ) : undefined} + </rect> + ); + })} + <rect + x="6.25" + y="6.25" + width="87.5" + height="87.5" + rx="3.5" + fill="#ffffff" + /> + <image + href={qrUrl} + x="6.25" + y="6.25" + width="87.5" + height="87.5" + preserveAspectRatio="xMidYMid meet" + /> + <rect + x="36.5" + y="43.75" + width="27" + height="12.5" + rx="6.25" + fill="#ffffff" + /> + <TalerLogo /> + </svg> + ); +} + +function StandardFrame({ + qrUrl, + size, + alt, + swiss, +}: { + qrUrl: string; + size: number; + alt: string; + swiss: boolean; +}): VNode { + return ( + <svg + data-taler-qr-code + class="h-auto max-w-full" + width={size} + height={size} + viewBox="0 0 100 100" + role="img" + aria-label={alt} + > + <title>{alt}</title> + <rect width="100" height="100" rx="8" fill="#f3f4f6" stroke="#d1d5db" /> + <rect x="3" y="3" width="94" height="94" rx="5" fill="#ffffff" /> + <image + href={qrUrl} + x="5" + y="5" + width="90" + height="90" + preserveAspectRatio="xMidYMid meet" + /> + {swiss ? ( + <svg + class="swiss-qr-cross" + x="42.3915" + y="42.3915" + width="15.217" + height="15.217" + viewBox="0 0 19.8 19.8" + aria-hidden="true" + > + <polygon points="18.3,0.7 1.6,0.7 0.7,0.7 0.7,1.6 0.7,18.3 0.7,19.1 1.6,19.1 18.3,19.1 19.1,19.1 19.1,18.3 19.1,1.6 19.1,0.7" /> + <rect x="8.3" y="4" width="3.3" height="11" fill="#ffffff" /> + <rect x="4.4" y="7.9" width="11" height="3.3" fill="#ffffff" /> + <polygon + points="0.7,1.6 0.7,18.3 0.7,19.1 1.6,19.1 18.3,19.1 19.1,19.1 19.1,18.3 19.1,1.6 19.1,0.7 18.3,0.7 1.6,0.7 0.7,0.7" + fill="none" + stroke="#ffffff" + stroke-width="1.4357" + stroke-miterlimit="10" + /> + </svg> + ) : undefined} + </svg> + ); +} + +export function TalerQrCode({ + url, + copyUrl, + size = 280, + alt = "Payment QR code", + variant = "taler", + labels: labelOverrides, +}: TalerQrCodeProps): VNode { + const labels = { ...DEFAULT_LABELS, ...labelOverrides }; + const qrResult = generateQrDataUrl(url); + const textToCopy = copyUrl || url; + + const handleCopy = async (button: HTMLButtonElement): Promise<void> => { + if (!(await copyText(textToCopy))) return; + showCopied(button, labels); + }; + + return ( + <div class="inline-flex max-w-full flex-col items-center gap-4"> + {qrResult.type === "success" ? ( + variant === "taler" ? ( + <TalerFrame qrUrl={qrResult.url} size={size} alt={alt} /> + ) : ( + <StandardFrame + qrUrl={qrResult.url} + size={size} + alt={alt} + swiss={variant === "swiss"} + /> + ) + ) : ( + <svg + data-taler-qr-code + class="h-auto max-w-full" + width={size} + height={size} + viewBox="0 0 100 100" + role="img" + aria-label={labels.generationFailed} + > + <rect + width="100" + height="100" + rx="8" + fill="#f9fafb" + stroke="#d1d5db" + /> + <text + x="50" + y="51" + text-anchor="middle" + fill="#b91c1c" + font-size="3" + font-weight="600" + > + {labels.generationFailed} + </text> + </svg> + )} + + {copyUrl || qrResult.type === "error" ? ( + <button + type="button" + class="taler-qr-copy-button cursor-pointer rounded-md border border-gray-300 bg-white px-3 py-1.5 text-xs font-semibold text-gray-700 hover:bg-gray-50" + aria-live="polite" + onClick={(event) => void handleCopy(event.currentTarget)} + > + {labels.copy} + </button> + ) : undefined} + </div> + ); +} diff --git a/packages/web-util/src/components/index.ts b/packages/web-util/src/components/index.ts @@ -11,6 +11,7 @@ export * from "./Button.js"; export * from "./ShowInputErrorLabel.js"; export * from "./NotificationBanner.js"; export * from "./Time.js"; +export * from "./TalerQrCode.js"; export * from "./RenderAmount.js"; export * from "./Pagination.js"; export * from "./SafeMarkdown.js"; diff --git a/packages/web-util/src/context/navigation.ts b/packages/web-util/src/context/navigation.ts @@ -37,10 +37,12 @@ type Type = { // addNavigationListener: (listener: (path: string, params: Record<string, string>) => void) => (() => void); }; -// @ts-expect-error should not be used without provider -const Context = createContext<Type>(undefined); +const Context = createContext<Type | undefined>(undefined); -export const useNavigationContext = (): Type => useContext(Context); +export const useNavigationContext = (): Type => useContext(Context)!; + +export const useOptionalNavigationContext = (): Type | undefined => + useContext(Context); export function useCurrentLocation<T extends ObjectOf<RouteDefinition<any>>>( pagesMap: T, diff --git a/packages/web-util/src/context/notification.ts b/packages/web-util/src/context/notification.ts @@ -33,6 +33,7 @@ const initial: Type = { showSuccess: unhandled, displayError: unhandled, clear: unhandled, + clearErrors: unhandled, }; const Context = createContext<Type>(initial); diff --git a/packages/web-util/src/hooks/useNotifications.ts b/packages/web-util/src/hooks/useNotifications.ts @@ -54,7 +54,13 @@ export function useNotificationHandler() { setNotification((latest) => latest.filter((n) => n !== item)); }, }; - return [item, ...current]; + return [ + item, + ...current.filter( + (existing) => + message.type !== "error" || existing.message.type !== "error", + ), + ]; }); } @@ -110,6 +116,10 @@ export function useNotificationHandler() { displayError, displayInfo, clear: () => setNotification([]), + clearErrors: () => + setNotification((current) => + current.filter((item) => item.message.type !== "error"), + ), }; } diff --git a/packages/web-util/src/stories-utils.test.tsx b/packages/web-util/src/stories-utils.test.tsx @@ -17,7 +17,7 @@ import assert from "node:assert/strict"; import test from "node:test"; import { Window } from "happy-dom"; -import { h, render } from "preact"; +import { ComponentChildren, h, render } from "preact"; import { act } from "preact/test-utils"; import { renderStories } from "./stories-utils.js"; @@ -306,3 +306,73 @@ test("desktop navigation remains persistently available", async () => { await unmount(window); }); + +test("example context properties are passed to the group wrapper", async () => { + const window = installDom(false); + + function ContextWrapper({ + children, + label, + }: { + children?: ComponentChildren; + label?: string; + }) { + return <div data-context-label={label}>{children}</div>; + } + + await act(() => + renderStories( + { + examples: { + Contextual: { + default: { title: "Contextual example" }, + Primary: { + component: () => <div>Primary dataset</div>, + props: {}, + contextProps: { label: "primary" }, + }, + Secondary: { + component: () => <div>Secondary dataset</div>, + props: {}, + contextProps: { label: "secondary" }, + }, + }, + }, + }, + { + strings: { en: {} }, + getWrapperForGroup: () => ContextWrapper, + }, + ), + ); + + await act(() => + document + .querySelector<HTMLAnchorElement>( + 'a[href="#examples-Contextual%20example-Primary"]', + )! + .click(), + ); + assert.equal( + document + .querySelector("[data-context-label]") + ?.getAttribute("data-context-label"), + "primary", + ); + + const datasetSelect = document.querySelector<HTMLSelectElement>( + ".taler-stories-dataset-selector select", + )!; + datasetSelect.value = "Secondary"; + await act(() => { + datasetSelect.dispatchEvent(new Event("change", { bubbles: true })); + }); + assert.equal( + document + .querySelector("[data-context-label]") + ?.getAttribute("data-context-label"), + "secondary", + ); + + await unmount(window); +}); diff --git a/packages/web-util/src/stories-utils.tsx b/packages/web-util/src/stories-utils.tsx @@ -791,7 +791,7 @@ function folder(groupName: string, value: ComponentOrFolder): ComponentItem[] { } interface Props { - getWrapperForGroup: (name: string) => FunctionComponent; + getWrapperForGroup: (name: string) => FunctionComponent<any>; examplesInGroups: Group[]; langs: Record<string, object>; } @@ -1082,7 +1082,7 @@ function Application({ )} <ErrorReport selected={selected}> <PreventLinkNavigation> - <GroupWrapper> + <GroupWrapper {...(selected?.render.contextProps ?? {})}> <ExampleContent /> </GroupWrapper> </PreventLinkNavigation> @@ -1096,7 +1096,7 @@ function Application({ export interface Options { id?: string; strings?: any; - getWrapperForGroup?: (name: string) => FunctionComponent; + getWrapperForGroup?: (name: string) => FunctionComponent<any>; } export function renderStories( diff --git a/packages/web-util/src/utils/buildPaginatedResult.ts b/packages/web-util/src/utils/buildPaginatedResult.ts @@ -1,5 +1,5 @@ import { assertUnreachable, OperationOk } from "@gnu-taler/taler-util"; -import { useState } from "preact/hooks"; +import { useCallback, useState } from "preact/hooks"; export type PaginationControl = { loadNext?(): void; @@ -102,6 +102,7 @@ function __compute_for_ui<R>(data: Array<R>, offset: ListPointer, max: number) { * If id is not set then assume the top most entry point. */ export type ListPointer = { id?: string; order: "asc" | "dec" }; +const INITIAL_LIST_POINTER: ListPointer = { order: "dec" }; /** * @@ -111,21 +112,16 @@ export type ListPointer = { id?: string; order: "asc" | "dec" }; export function useListPointer<T>( getId: (d: T) => string, ): [ListPointer, (p: T | undefined, order: ListPointer["order"]) => void] { - const initial = { - order: "dec" as const, - }; - const [pointer, setPointer] = useState<ListPointer>(initial); - return [ - pointer, - (d, order) => { + const [pointer, setPointer] = useState<ListPointer>(INITIAL_LIST_POINTER); + const movePointer = useCallback( + (d: T | undefined, order: ListPointer["order"]) => { if (!d) { - setPointer(initial); + setPointer(INITIAL_LIST_POINTER); } else { - setPointer({ - order, - id: getId(d), - }); + setPointer({ order, id: getId(d) }); } }, - ]; + [getId], + ); + return [pointer, movePointer]; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml @@ -199,9 +199,6 @@ importers: preact: specifier: 10.11.3 version: 10.11.3 - qrcode-generator: - specifier: ^1.4.4 - version: 1.4.4 swr: specifier: 2.0.3 version: 2.0.3(react@18.3.1) @@ -539,6 +536,9 @@ importers: '@gnu-taler/taler-util': specifier: workspace:* version: link:../taler-util + '@gnu-taler/web-util': + specifier: workspace:* + version: link:../web-util '@preact/signals': specifier: ^2.0.0 version: 2.11.0(preact@10.29.8) @@ -548,9 +548,6 @@ importers: preact: specifier: 10.29.8 version: 10.29.8(preact-render-to-string@5.2.6) - qrcode-generator: - specifier: ^1.4.4 - version: 1.4.4 swr: specifier: 2.2.2 version: 2.2.2(react@18.3.1) @@ -788,6 +785,9 @@ importers: '@types/chrome': specifier: 0.0.197 version: 0.0.197 + qrcode-generator: + specifier: ^1.4.4 + version: 1.4.4 tailwindcss: specifier: 3.4.17 version: 3.4.17(ts-node@10.9.1(@types/node@20.19.41)(typescript@7.0.2))