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:
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]">
- <{i18n.str`Invalid value`}>
- </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]">
- <
- {i18n.str`Invalid value`}>
- </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 "
- {error.payto_uri}"
- </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">💵</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">↔</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 © 2014—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 © 2014—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 © 2014—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))