aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorSebastian <sebasjm@gmail.com>2023-02-17 16:23:37 -0300
committerSebastian <sebasjm@gmail.com>2023-02-17 16:23:49 -0300
commit9697e953f56dc37208c2852d686d1854256f71ef (patch)
treefbbe6e5934c1a8dd438da76d37b719372811b542
parent8b83f729d7394837a3be231bbeeea44f6a01e9a1 (diff)
cashout for business accounts
-rw-r--r--packages/demobank-ui/src/components/Cashouts/index.ts6
-rw-r--r--packages/demobank-ui/src/components/Cashouts/state.ts8
-rw-r--r--packages/demobank-ui/src/components/Cashouts/test.ts4
-rw-r--r--packages/demobank-ui/src/components/Cashouts/views.tsx23
-rw-r--r--packages/demobank-ui/src/declaration.d.ts16
-rw-r--r--packages/demobank-ui/src/hooks/access.ts8
-rw-r--r--packages/demobank-ui/src/hooks/backend.ts23
-rw-r--r--packages/demobank-ui/src/hooks/circuit.ts136
-rw-r--r--packages/demobank-ui/src/pages/AccountPage.tsx37
-rw-r--r--packages/demobank-ui/src/pages/AdminPage.tsx150
-rw-r--r--packages/demobank-ui/src/pages/BankFrame.tsx24
-rw-r--r--packages/demobank-ui/src/pages/BusinessAccount.tsx677
-rw-r--r--packages/demobank-ui/src/pages/HomePage.tsx2
-rw-r--r--packages/demobank-ui/src/pages/Routing.tsx2
-rw-r--r--packages/demobank-ui/src/scss/bank.scss32
-rw-r--r--packages/demobank-ui/src/scss/main.scss1
-rw-r--r--packages/demobank-ui/src/scss/toggle.scss51
-rw-r--r--packages/demobank-ui/src/utils.ts15
18 files changed, 1117 insertions, 98 deletions
diff --git a/packages/demobank-ui/src/components/Cashouts/index.ts b/packages/demobank-ui/src/components/Cashouts/index.ts
index 1410267be..3ca7d9026 100644
--- a/packages/demobank-ui/src/components/Cashouts/index.ts
+++ b/packages/demobank-ui/src/components/Cashouts/index.ts
@@ -23,7 +23,8 @@ import { useComponentState } from "./state.js";
import { LoadingUriView, ReadyView } from "./views.js";
export interface Props {
- empty?: boolean;
+ account: string;
+ onSelected: (id: string) => void;
}
export type State = State.Loading | State.LoadingUriError | State.Ready;
@@ -45,7 +46,8 @@ export namespace State {
export interface Ready extends BaseInfo {
status: "ready";
error: undefined;
- cashouts: SandboxBackend.Circuit.CashoutStatusResponse[];
+ cashouts: SandboxBackend.Circuit.CashoutStatusResponseWithId[];
+ onSelected: (id: string) => void;
}
}
diff --git a/packages/demobank-ui/src/components/Cashouts/state.ts b/packages/demobank-ui/src/components/Cashouts/state.ts
index 178a1e815..124f9bf9c 100644
--- a/packages/demobank-ui/src/components/Cashouts/state.ts
+++ b/packages/demobank-ui/src/components/Cashouts/state.ts
@@ -14,12 +14,11 @@
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
-import { AbsoluteTime, Amounts } from "@gnu-taler/taler-util";
import { useCashouts } from "../../hooks/circuit.js";
-import { Props, State, Transaction } from "./index.js";
+import { Props, State } from "./index.js";
-export function useComponentState({ empty }: Props): State {
- const result = useCashouts();
+export function useComponentState({ account, onSelected }: Props): State {
+ const result = useCashouts(account);
if (result.loading) {
return {
status: "loading",
@@ -37,5 +36,6 @@ export function useComponentState({ empty }: Props): State {
status: "ready",
error: undefined,
cashouts: result.data,
+ onSelected,
};
}
diff --git a/packages/demobank-ui/src/components/Cashouts/test.ts b/packages/demobank-ui/src/components/Cashouts/test.ts
index 78450ed2d..e91116378 100644
--- a/packages/demobank-ui/src/components/Cashouts/test.ts
+++ b/packages/demobank-ui/src/components/Cashouts/test.ts
@@ -31,7 +31,7 @@ describe("Transaction states", () => {
const env = new SwrMockEnvironment();
const props: Props = {
-
+ account: "123",
};
env.addRequestExpectation(TRANSACTION_API_EXAMPLE.LIST_FIRST_PAGE, {
@@ -115,6 +115,7 @@ describe("Transaction states", () => {
const env = new SwrMockEnvironment();
const props: Props = {
+ account: "123",
};
env.addRequestExpectation(TRANSACTION_API_EXAMPLE.LIST_NOT_FOUND, {});
@@ -147,6 +148,7 @@ describe("Transaction states", () => {
const env = new SwrMockEnvironment(false);
const props: Props = {
+ account: "123",
};
env.addRequestExpectation(TRANSACTION_API_EXAMPLE.LIST_ERROR, {});
diff --git a/packages/demobank-ui/src/components/Cashouts/views.tsx b/packages/demobank-ui/src/components/Cashouts/views.tsx
index 16ae8a58f..af1d9ed2c 100644
--- a/packages/demobank-ui/src/components/Cashouts/views.tsx
+++ b/packages/demobank-ui/src/components/Cashouts/views.tsx
@@ -30,8 +30,15 @@ export function LoadingUriView({ error }: State.LoadingUriError): VNode {
);
}
-export function ReadyView({ cashouts }: State.Ready): VNode {
+export function ReadyView({ cashouts, onSelected }: State.Ready): VNode {
const { i18n } = useTranslationContext();
+ if (!cashouts.length) {
+ return (
+ <div>
+ <i18n.Translate>No cashout at the moment</i18n.Translate>
+ </div>
+ );
+ }
return (
<div class="results">
<table class="pure-table pure-table-striped">
@@ -39,6 +46,8 @@ export function ReadyView({ cashouts }: State.Ready): VNode {
<tr>
<th>{i18n.str`Created`}</th>
<th>{i18n.str`Confirmed`}</th>
+ <th>{i18n.str`Total debit`}</th>
+ <th>{i18n.str`Total credit`}</th>
<th>{i18n.str`Status`}</th>
<th>{i18n.str`Subject`}</th>
</tr>
@@ -56,7 +65,17 @@ export function ReadyView({ cashouts }: State.Ready): VNode {
<td>{Amounts.stringifyValue(item.amount_debit)}</td>
<td>{Amounts.stringifyValue(item.amount_credit)}</td>
<td>{item.status}</td>
- <td>{item.subject}</td>
+ <td>
+ <a
+ href="#"
+ onClick={(e) => {
+ e.preventDefault();
+ onSelected(item.id);
+ }}
+ >
+ {item.subject}
+ </a>
+ </td>
</tr>
);
})}
diff --git a/packages/demobank-ui/src/declaration.d.ts b/packages/demobank-ui/src/declaration.d.ts
index c46fcc9ed..e3160d9ae 100644
--- a/packages/demobank-ui/src/declaration.d.ts
+++ b/packages/demobank-ui/src/declaration.d.ts
@@ -322,11 +322,6 @@ namespace SandboxBackend {
// where to send cashouts.
cashout_address: string;
}
- enum TanChannel {
- SMS = "sms",
- EMAIL = "email",
- FILE = "file",
- }
interface CashoutRequest {
// Optional subject to associate to the
// cashout operation. This data will appear
@@ -369,6 +364,7 @@ namespace SandboxBackend {
// Contains ratios and fees related to buying
// and selling the circuit currency.
ratios_and_fees: RatiosAndFees;
+ currency: string;
}
interface RatiosAndFees {
// Exchange rate to buy the circuit currency from fiat.
@@ -400,14 +396,6 @@ namespace SandboxBackend {
// Missing or null, when the operation wasn't confirmed yet.
confirmation_time?: number | null; // milliseconds since the Unix epoch
}
- enum CashoutStatus {
- // The payment was initiated after a valid
- // TAN was received by the bank.
- CONFIRMED = "confirmed",
-
- // The cashout was created and now waits
- // for the TAN by the author.
- PENDING = "pending",
- }
+ type CashoutStatusResponseWithId = CashoutStatusResponse & { id: string };
}
}
diff --git a/packages/demobank-ui/src/hooks/access.ts b/packages/demobank-ui/src/hooks/access.ts
index 0379de27d..6046146ba 100644
--- a/packages/demobank-ui/src/hooks/access.ts
+++ b/packages/demobank-ui/src/hooks/access.ts
@@ -18,7 +18,7 @@ import {
HttpResponse,
HttpResponseOk,
HttpResponsePaginated,
- RequestError
+ RequestError,
} from "@gnu-taler/web-util/lib/index.browser";
import { useEffect, useState } from "preact/hooks";
import { useBackendContext } from "../context/backend.js";
@@ -26,12 +26,12 @@ import { MAX_RESULT_SIZE, PAGE_SIZE } from "../utils.js";
import {
useAuthenticatedBackend,
useMatchMutate,
- usePublicBackend
+ usePublicBackend,
} from "./backend.js";
// FIX default import https://github.com/microsoft/TypeScript/issues/49189
-import _useSWR, { SWRHook } from 'swr';
-const useSWR = _useSWR as unknown as SWRHook
+import _useSWR, { SWRHook } from "swr";
+const useSWR = _useSWR as unknown as SWRHook;
export function useAccessAPI(): AccessAPI {
const mutateAll = useMatchMutate();
diff --git a/packages/demobank-ui/src/hooks/backend.ts b/packages/demobank-ui/src/hooks/backend.ts
index e87bdd5fe..e0649f5fe 100644
--- a/packages/demobank-ui/src/hooks/backend.ts
+++ b/packages/demobank-ui/src/hooks/backend.ts
@@ -118,6 +118,7 @@ interface useBackendType {
sandboxAccountsFetcher: <T>(
args: [string, number, number, string],
) => Promise<HttpResponseOk<T>>;
+ sandboxCashoutFetcher: <T>(endpoint: string[]) => Promise<HttpResponseOk<T>>;
}
export function usePublicBackend(): useBackendType {
const { state } = useBackendContext();
@@ -176,12 +177,21 @@ export function usePublicBackend(): useBackendType {
},
[baseUrl],
);
+ const sandboxCashoutFetcher = useCallback(
+ function fetcherImpl<T>([endpoint, account]: string[]): Promise<
+ HttpResponseOk<T>
+ > {
+ return requestHandler<T>(baseUrl, endpoint);
+ },
+ [baseUrl],
+ );
return {
request,
fetcher,
paginatedFetcher,
multiFetcher,
sandboxAccountsFetcher,
+ sandboxCashoutFetcher,
};
}
@@ -225,7 +235,6 @@ export function useAuthenticatedBackend(): useBackendType {
function multiFetcherImpl<T>([endpoints]: string[][]): Promise<
HttpResponseOk<T>[]
> {
- console.log("list size", endpoints.length, endpoints);
return Promise.all(
endpoints.map((endpoint) =>
requestHandler<T>(baseUrl, endpoint, { basicAuth: creds }),
@@ -249,12 +258,24 @@ export function useAuthenticatedBackend(): useBackendType {
[baseUrl],
);
+ const sandboxCashoutFetcher = useCallback(
+ function fetcherImpl<T>([endpoint, account]: string[]): Promise<
+ HttpResponseOk<T>
+ > {
+ return requestHandler<T>(baseUrl, endpoint, {
+ basicAuth: creds,
+ params: { account },
+ });
+ },
+ [baseUrl, creds],
+ );
return {
request,
fetcher,
paginatedFetcher,
multiFetcher,
sandboxAccountsFetcher,
+ sandboxCashoutFetcher,
};
}
diff --git a/packages/demobank-ui/src/hooks/circuit.ts b/packages/demobank-ui/src/hooks/circuit.ts
index 21e5ce852..c7170309b 100644
--- a/packages/demobank-ui/src/hooks/circuit.ts
+++ b/packages/demobank-ui/src/hooks/circuit.ts
@@ -27,8 +27,8 @@ import { MAX_RESULT_SIZE, PAGE_SIZE } from "../utils.js";
import { useAuthenticatedBackend, useMatchMutate } from "./backend.js";
// FIX default import https://github.com/microsoft/TypeScript/issues/49189
-import _useSWR, { SWRHook } from 'swr';
-const useSWR = _useSWR as unknown as SWRHook
+import _useSWR, { SWRHook } from "swr";
+const useSWR = _useSWR as unknown as SWRHook;
export function useAdminAccountAPI(): AdminAccountAPI {
const { request } = useAuthenticatedBackend();
@@ -118,7 +118,54 @@ export function useCircuitAccountAPI(): CircuitAccountAPI {
return res;
};
- return { updateAccount, changePassword };
+ const createCashout = async (
+ data: SandboxBackend.Circuit.CashoutRequest,
+ ): Promise<HttpResponseOk<SandboxBackend.Circuit.CashoutPending>> => {
+ const res = await request<SandboxBackend.Circuit.CashoutPending>(
+ `circuit-api/cashouts`,
+ {
+ method: "POST",
+ data,
+ contentType: "json",
+ },
+ );
+ return res;
+ };
+
+ const confirmCashout = async (
+ cashoutId: string,
+ data: SandboxBackend.Circuit.CashoutConfirm,
+ ): Promise<HttpResponseOk<void>> => {
+ const res = await request<void>(
+ `circuit-api/cashouts/${cashoutId}/confirm`,
+ {
+ method: "POST",
+ data,
+ contentType: "json",
+ },
+ );
+ await mutateAll(/.*circuit-api\/cashout.*/);
+ return res;
+ };
+
+ const abortCashout = async (
+ cashoutId: string,
+ ): Promise<HttpResponseOk<void>> => {
+ const res = await request<void>(`circuit-api/cashouts/${cashoutId}/abort`, {
+ method: "POST",
+ contentType: "json",
+ });
+ await mutateAll(/.*circuit-api\/cashout.*/);
+ return res;
+ };
+
+ return {
+ updateAccount,
+ changePassword,
+ createCashout,
+ confirmCashout,
+ abortCashout,
+ };
}
export interface AdminAccountAPI {
@@ -144,11 +191,14 @@ export interface CircuitAccountAPI {
changePassword: (
data: SandboxBackend.Circuit.AccountPasswordChange,
) => Promise<HttpResponseOk<void>>;
-}
-
-export interface InstanceTemplateFilter {
- //FIXME: add filter to the template list
- position?: string;
+ createCashout: (
+ data: SandboxBackend.Circuit.CashoutRequest,
+ ) => Promise<HttpResponseOk<SandboxBackend.Circuit.CashoutPending>>;
+ confirmCashout: (
+ id: string,
+ data: SandboxBackend.Circuit.CashoutConfirm,
+ ) => Promise<HttpResponseOk<void>>;
+ abortCashout: (id: string) => Promise<HttpResponseOk<void>>;
}
async function getBusinessStatus(
@@ -217,6 +267,35 @@ export function useBusinessAccountDetails(
return { loading: true };
}
+export function useRatiosAndFeeConfig(): HttpResponse<
+ SandboxBackend.Circuit.Config,
+ SandboxBackend.SandboxError
+> {
+ const { fetcher } = useAuthenticatedBackend();
+
+ const { data, error } = useSWR<
+ HttpResponseOk<SandboxBackend.Circuit.Config>,
+ RequestError<SandboxBackend.SandboxError>
+ >([`circuit-api/config`], fetcher, {
+ refreshInterval: 0,
+ refreshWhenHidden: false,
+ revalidateOnFocus: false,
+ revalidateOnReconnect: false,
+ refreshWhenOffline: false,
+ errorRetryCount: 0,
+ errorRetryInterval: 1,
+ shouldRetryOnError: false,
+ keepPreviousData: true,
+ });
+
+ if (data) {
+ data.data.currency = "FIAT";
+ }
+ if (data) return data;
+ if (error) return error.info;
+ return { loading: true };
+}
+
interface PaginationFilter {
account?: string;
page?: number;
@@ -299,17 +378,18 @@ export function useBusinessAccounts(
return { loading: true };
}
-export function useCashouts(): HttpResponse<
- (SandboxBackend.Circuit.CashoutStatusResponse & WithId)[],
+export function useCashouts(
+ account: string,
+): HttpResponse<
+ SandboxBackend.Circuit.CashoutStatusResponseWithId[],
SandboxBackend.SandboxError
> {
- const { fetcher, multiFetcher } = useAuthenticatedBackend();
-
+ const { sandboxCashoutFetcher, multiFetcher } = useAuthenticatedBackend();
const { data: list, error: listError } = useSWR<
HttpResponseOk<SandboxBackend.Circuit.Cashouts>,
RequestError<SandboxBackend.SandboxError>
- >([`circuit-api/cashouts`], fetcher, {
+ >([`circuit-api/cashouts`, account], sandboxCashoutFetcher, {
refreshInterval: 0,
refreshWhenHidden: false,
revalidateOnFocus: false,
@@ -317,7 +397,7 @@ export function useCashouts(): HttpResponse<
refreshWhenOffline: false,
});
- const paths = (list?.data.cashouts || []).map(
+ const paths = ((list?.data && list?.data.cashouts) || []).map(
(cashoutId) => `circuit-api/cashouts/${cashoutId}`,
);
const { data: cashouts, error: productError } = useSWR<
@@ -346,3 +426,31 @@ export function useCashouts(): HttpResponse<
}
return { loading: true };
}
+
+export function useCashoutDetails(
+ id: string,
+): HttpResponse<
+ SandboxBackend.Circuit.CashoutStatusResponse,
+ SandboxBackend.SandboxError
+> {
+ const { fetcher } = useAuthenticatedBackend();
+
+ const { data, error } = useSWR<
+ HttpResponseOk<SandboxBackend.Circuit.CashoutStatusResponse>,
+ RequestError<SandboxBackend.SandboxError>
+ >([`circuit-api/cashouts/${id}`], fetcher, {
+ refreshInterval: 0,
+ refreshWhenHidden: false,
+ revalidateOnFocus: false,
+ revalidateOnReconnect: false,
+ refreshWhenOffline: false,
+ errorRetryCount: 0,
+ errorRetryInterval: 1,
+ shouldRetryOnError: false,
+ keepPreviousData: true,
+ });
+
+ if (data) return data;
+ if (error) return error.info;
+ return { loading: true };
+}
diff --git a/packages/demobank-ui/src/pages/AccountPage.tsx b/packages/demobank-ui/src/pages/AccountPage.tsx
index 370605871..ae0c2b1f8 100644
--- a/packages/demobank-ui/src/pages/AccountPage.tsx
+++ b/packages/demobank-ui/src/pages/AccountPage.tsx
@@ -112,40 +112,3 @@ export function AccountPage({ account, onLoadNotOk }: Props): VNode {
</Fragment>
);
}
-
-// function Moves({ account }: { account: string }): VNode {
-// const [tab, setTab] = useState<"transactions" | "cashouts">("transactions");
-// const { i18n } = useTranslationContext();
-// return (
-// <article>
-// <div class="payments">
-// <div class="tab">
-// <button
-// class={tab === "transactions" ? "tablinks active" : "tablinks"}
-// onClick={(): void => {
-// setTab("transactions");
-// }}
-// >
-// {i18n.str`Transactions`}
-// </button>
-// <button
-// class={tab === "cashouts" ? "tablinks active" : "tablinks"}
-// onClick={(): void => {
-// setTab("cashouts");
-// }}
-// >
-// {i18n.str`Cashouts`}
-// </button>
-// </div>
-// {tab === "transactions" && (
-// )}
-// {tab === "cashouts" && (
-// <div class="active">
-// <h3>{i18n.str`Latest cashouts`}</h3>
-// <Cashouts account={account} />
-// </div>
-// )}
-// </div>
-// </article>
-// );
-// }
diff --git a/packages/demobank-ui/src/pages/AdminPage.tsx b/packages/demobank-ui/src/pages/AdminPage.tsx
index f8efddd80..d15ac02c4 100644
--- a/packages/demobank-ui/src/pages/AdminPage.tsx
+++ b/packages/demobank-ui/src/pages/AdminPage.tsx
@@ -14,7 +14,11 @@
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
-import { parsePaytoUri, TranslatedString } from "@gnu-taler/taler-util";
+import {
+ Amounts,
+ parsePaytoUri,
+ TranslatedString,
+} from "@gnu-taler/taler-util";
import {
HttpResponsePaginated,
RequestError,
@@ -22,7 +26,9 @@ import {
} from "@gnu-taler/web-util/lib/index.browser";
import { Fragment, h, VNode } from "preact";
import { useState } from "preact/hooks";
+import { Cashouts } from "../components/Cashouts/index.js";
import { ErrorMessage, usePageContext } from "../context/pageState.js";
+import { useAccountDetails } from "../hooks/access.js";
import {
useBusinessAccountDetails,
useBusinessAccounts,
@@ -60,7 +66,10 @@ interface Props {
export function AdminPage({ onLoadNotOk }: Props): VNode {
const [account, setAccount] = useState<string | undefined>();
const [showDetails, setShowDetails] = useState<string | undefined>();
+ const [showCashouts, setShowCashouts] = useState<string | undefined>();
const [updatePassword, setUpdatePassword] = useState<string | undefined>();
+ const [removeAccount, setRemoveAccount] = useState<string | undefined>();
+
const [createAccount, setCreateAccount] = useState(false);
const { pageStateSetter } = usePageContext();
@@ -81,6 +90,23 @@ export function AdminPage({ onLoadNotOk }: Props): VNode {
const { customers } = result.data;
+ if (showCashouts) {
+ return (
+ <div>
+ <Cashouts account={showCashouts} />
+ <input
+ class="pure-button"
+ type="submit"
+ value={i18n.str`Close`}
+ onClick={async (e) => {
+ e.preventDefault();
+ setShowCashouts(undefined);
+ }}
+ />
+ </div>
+ );
+ }
+
if (showDetails) {
return (
<ShowAccountDetails
@@ -100,6 +126,21 @@ export function AdminPage({ onLoadNotOk }: Props): VNode {
/>
);
}
+ if (removeAccount) {
+ return (
+ <RemoveAccount
+ account={removeAccount}
+ onLoadNotOk={onLoadNotOk}
+ onUpdateSuccess={() => {
+ showInfoMessage(i18n.str`Account removed`);
+ setRemoveAccount(undefined);
+ }}
+ onClear={() => {
+ setRemoveAccount(undefined);
+ }}
+ />
+ );
+ }
if (updatePassword) {
return (
<UpdateAccountPassword
@@ -164,6 +205,7 @@ export function AdminPage({ onLoadNotOk }: Props): VNode {
<th>{i18n.str`Username`}</th>
<th>{i18n.str`Name`}</th>
<th></th>
+ <th></th>
</tr>
</thead>
<tbody>
@@ -193,6 +235,28 @@ export function AdminPage({ onLoadNotOk }: Props): VNode {
change password
</a>
</td>
+ <td>
+ <a
+ href="#"
+ onClick={(e) => {
+ e.preventDefault();
+ setShowCashouts(item.username);
+ }}
+ >
+ cashouts
+ </a>
+ </td>
+ <td>
+ <a
+ href="#"
+ onClick={(e) => {
+ e.preventDefault();
+ setRemoveAccount(item.username);
+ }}
+ >
+ remove
+ </a>
+ </td>
</tr>
);
})}
@@ -536,6 +600,90 @@ export function ShowAccountDetails({
);
}
+function RemoveAccount({
+ account,
+ onClear,
+ onUpdateSuccess,
+ onLoadNotOk,
+}: {
+ onLoadNotOk: <T, E>(error: HttpResponsePaginated<T, E>) => VNode;
+ onClear: () => void;
+ onUpdateSuccess: () => void;
+ account: string;
+}): VNode {
+ const { i18n } = useTranslationContext();
+ const result = useAccountDetails(account);
+ const { deleteAccount } = useAdminAccountAPI();
+ const [error, saveError] = useState<ErrorMessage | undefined>();
+
+ if (result.clientError) {
+ if (result.isNotfound) return <div>account not found</div>;
+ }
+ if (!result.ok) {
+ return onLoadNotOk(result);
+ }
+
+ const balance = Amounts.parse(result.data.balance.amount);
+ if (!balance) {
+ return <div>there was an error reading the balance</div>;
+ }
+ const isBalanceEmpty = Amounts.isZero(balance);
+ return (
+ <div>
+ <div>
+ <h1 class="nav welcome-text">
+ <i18n.Translate>Remove account: {account}</i18n.Translate>
+ </h1>
+ </div>
+ {!isBalanceEmpty && (
+ <ErrorBanner
+ error={{
+ title: i18n.str`Can't delete the account`,
+ description: i18n.str`Balance is not empty`,
+ }}
+ />
+ )}
+ {error && (
+ <ErrorBanner error={error} onClear={() => saveError(undefined)} />
+ )}
+
+ <p>
+ <div style={{ display: "flex", justifyContent: "space-between" }}>
+ <div>
+ <input
+ class="pure-button"
+ type="submit"
+ value={i18n.str`Cancel`}
+ onClick={async (e) => {
+ e.preventDefault();
+ onClear();
+ }}
+ />
+ </div>
+ <div>
+ <input
+ id="select-exchange"
+ class="pure-button pure-button-primary content"
+ disabled={!isBalanceEmpty}
+ type="submit"
+ value={i18n.str`Confirm`}
+ onClick={async (e) => {
+ e.preventDefault();
+ try {
+ const r = await deleteAccount(account);
+ onUpdateSuccess();
+ } catch (error) {
+ handleError(error, saveError, i18n);
+ }
+ }}
+ />
+ </div>
+ </div>
+ </p>
+ </div>
+ );
+}
+
/**
* Create valid account object to update or create
* Take template as initial values for the form
diff --git a/packages/demobank-ui/src/pages/BankFrame.tsx b/packages/demobank-ui/src/pages/BankFrame.tsx
index 0fb75b87b..fe7571c38 100644
--- a/packages/demobank-ui/src/pages/BankFrame.tsx
+++ b/packages/demobank-ui/src/pages/BankFrame.tsx
@@ -128,7 +128,7 @@ export function BankFrame({
<StatusBanner />
{backend.state.status === "loggedIn" ? (
<div class="top-right">
- {goToBusinessAccount ? (
+ {goToBusinessAccount && !backend.state.isUserAdministrator ? (
<MaybeBusinessButton
account={backend.state.username}
onClick={goToBusinessAccount}
@@ -187,7 +187,7 @@ export function ErrorBanner({
onClear,
}: {
error: ErrorMessage;
- onClear: () => void;
+ onClear?: () => void;
}): VNode | null {
return (
<div class="informational informational-fail" style={{ marginTop: 8 }}>
@@ -196,15 +196,17 @@ export function ErrorBanner({
<b>{error.title}</b>
</p>
<div>
- <input
- type="button"
- class="pure-button"
- value="Clear"
- onClick={(e) => {
- e.preventDefault();
- onClear();
- }}
- />
+ {onClear && (
+ <input
+ type="button"
+ class="pure-button"
+ value="Clear"
+ onClick={(e) => {
+ e.preventDefault();
+ onClear();
+ }}
+ />
+ )}
</div>
</div>
<p>{error.description}</p>
diff --git a/packages/demobank-ui/src/pages/BusinessAccount.tsx b/packages/demobank-ui/src/pages/BusinessAccount.tsx
index d845c2fa0..6651ef0f7 100644
--- a/packages/demobank-ui/src/pages/BusinessAccount.tsx
+++ b/packages/demobank-ui/src/pages/BusinessAccount.tsx
@@ -13,18 +13,34 @@
You should have received a copy of the GNU General Public License along with
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
-import { TranslatedString } from "@gnu-taler/taler-util";
+import {
+ AmountJson,
+ Amounts,
+ HttpStatusCode,
+ TranslatedString,
+} from "@gnu-taler/taler-util";
import {
HttpResponsePaginated,
+ RequestError,
useTranslationContext,
} from "@gnu-taler/web-util/lib/index.browser";
-import { h, VNode } from "preact";
+import { Fragment, h, VNode } from "preact";
import { useState } from "preact/hooks";
import { Cashouts } from "../components/Cashouts/index.js";
import { useBackendContext } from "../context/backend.js";
-import { usePageContext } from "../context/pageState.js";
+import { ErrorMessage, usePageContext } from "../context/pageState.js";
+import { useAccountDetails } from "../hooks/access.js";
+import {
+ useCashoutDetails,
+ useCashouts,
+ useCircuitAccountAPI,
+ useRatiosAndFeeConfig,
+} from "../hooks/circuit.js";
+import { CashoutStatus, TanChannel, undefinedIfEmpty } from "../utils.js";
import { ShowAccountDetails, UpdateAccountPassword } from "./AdminPage.js";
+import { ErrorBanner } from "./BankFrame.js";
import { LoginForm } from "./LoginForm.js";
+import { ShowInputErrorLabel } from "./ShowInputErrorLabel.js";
interface Props {
onClose: () => void;
@@ -40,6 +56,8 @@ export function BusinessAccount({
const { pageStateSetter } = usePageContext();
const backend = useBackendContext();
const [updatePassword, setUpdatePassword] = useState(false);
+ const [newCashout, setNewcashout] = useState(false);
+ const [showCashout, setShowCashout] = useState<string | undefined>();
function showInfoMessage(info: TranslatedString): void {
pageStateSetter((prev) => ({
...prev,
@@ -51,6 +69,32 @@ export function BusinessAccount({
return <LoginForm onRegister={onRegister} />;
}
+ if (newCashout) {
+ return (
+ <CreateCashout
+ account={backend.state.username}
+ onLoadNotOk={onLoadNotOk}
+ onCancel={() => {
+ setNewcashout(false);
+ }}
+ onComplete={(id) => {
+ setNewcashout(false);
+ setShowCashout(id);
+ }}
+ />
+ );
+ }
+ if (showCashout) {
+ return (
+ <ShowCashout
+ id={showCashout}
+ onLoadNotOk={onLoadNotOk}
+ onCancel={() => {
+ setShowCashout(undefined);
+ }}
+ />
+ );
+ }
if (updatePassword) {
return (
<UpdateAccountPassword
@@ -82,9 +126,634 @@ export function BusinessAccount({
<section style={{ marginTop: "2em" }}>
<div class="active">
<h3>{i18n.str`Latest cashouts`}</h3>
- <Cashouts />
+ <Cashouts
+ account={backend.state.username}
+ onSelected={(id) => {
+ setShowCashout(id);
+ }}
+ />
+ </div>
+ <br />
+ <div style={{ display: "flex", justifyContent: "space-between" }}>
+ <div />
+ <input
+ class="pure-button pure-button-primary content"
+ type="submit"
+ value={i18n.str`New cashout`}
+ onClick={async (e) => {
+ e.preventDefault();
+ setNewcashout(true);
+ }}
+ />
</div>
</section>
</div>
);
}
+
+interface PropsCashout {
+ account: string;
+ onComplete: (id: string) => void;
+ onCancel: () => void;
+ onLoadNotOk: <T, E>(error: HttpResponsePaginated<T, E>) => VNode;
+}
+
+type FormType = {
+ isDebit: boolean;
+ amount: string;
+ subject: string;
+ channel: TanChannel;
+};
+type ErrorFrom<T> = {
+ [P in keyof T]+?: string;
+};
+
+function CreateCashout({
+ account,
+ onComplete,
+ onCancel,
+ onLoadNotOk,
+}: PropsCashout): VNode {
+ const { i18n } = useTranslationContext();
+ const ratiosResult = useRatiosAndFeeConfig();
+ const result = useAccountDetails(account);
+ const [error, saveError] = useState<ErrorMessage | undefined>();
+
+ const [form, setForm] = useState<Partial<FormType>>({});
+
+ const { createCashout } = useCircuitAccountAPI();
+ if (!result.ok) return onLoadNotOk(result);
+ if (!ratiosResult.ok) return onLoadNotOk(ratiosResult);
+ const config = ratiosResult.data;
+ const maybeBalance = Amounts.parse(result.data.balance.amount);
+ if (!maybeBalance) return <div>error</div>;
+ const balance = maybeBalance;
+ const zero = Amounts.zeroOfCurrency(balance.currency);
+
+ const sellRate = config.ratios_and_fees.sell_at_ratio;
+ const sellFee = !config.ratios_and_fees.sell_out_fee
+ ? zero
+ : Amounts.fromFloat(config.ratios_and_fees.sell_out_fee, balance.currency);
+
+ if (!sellRate || sellRate < 0) return <div>error rate</div>;
+
+ function truncate(a: AmountJson): AmountJson {
+ const str = Amounts.stringify(a);
+ const idx = str.indexOf(".");
+ if (idx === -1) return a;
+ const truncated = str.substring(0, idx + 3);
+ console.log(str, truncated);
+ return Amounts.parseOrThrow(truncated);
+ }
+
+ const amount = Amounts.parse(`${balance.currency}:${form.amount}`);
+ const amount_debit = !amount
+ ? zero
+ : form.isDebit
+ ? amount
+ : truncate(Amounts.divide(Amounts.add(amount, sellFee).amount, sellRate));
+ const credit_before_fee = !amount
+ ? zero
+ : form.isDebit
+ ? truncate(Amounts.divide(amount, 1 / sellRate))
+ : Amounts.add(amount, sellFee).amount;
+
+ const __amount_credit = Amounts.sub(credit_before_fee, sellFee).amount;
+ const amount_credit = Amounts.parseOrThrow(
+ `${config.currency}:${Amounts.stringifyValue(__amount_credit)}`,
+ );
+
+ const balanceAfter = Amounts.sub(balance, amount_debit).amount;
+
+ function updateForm(newForm: typeof form): void {
+ setForm(newForm);
+ }
+ const errors = undefinedIfEmpty<ErrorFrom<typeof form>>({
+ amount: !form.amount
+ ? i18n.str`required`
+ : !amount
+ ? i18n.str`could not be parsed`
+ : Amounts.cmp(balance, amount_debit) === -1
+ ? i18n.str`balance is not enough`
+ : Amounts.cmp(credit_before_fee, sellFee) === -1
+ ? i18n.str`amount is not enough`
+ : Amounts.isZero(amount_credit)
+ ? i18n.str`amount is not enough`
+ : undefined,
+ channel: !form.channel ? i18n.str`required` : undefined,
+ });
+
+ // setErrors(validationResult);
+
+ return (
+ <div>
+ {error && (
+ <ErrorBanner error={error} onClear={() => saveError(undefined)} />
+ )}
+ <h1>New cashout</h1>
+ <form class="pure-form">
+ <fieldset>
+ <label>{i18n.str`Subject`}</label>
+ <input
+ value={form.subject ?? ""}
+ onChange={(e) => {
+ form.subject = e.currentTarget.value;
+ updateForm(structuredClone(form));
+ }}
+ />
+ <ShowInputErrorLabel
+ message={errors?.subject}
+ isDirty={form.subject !== undefined}
+ />
+ </fieldset>
+ <fieldset>
+ <label>
+ {form.isDebit
+ ? i18n.str`Amount to send`
+ : i18n.str`Amount to receive`}
+ </label>
+ <div style={{ width: "max-content" }}>
+ <input
+ type="text"
+ readonly
+ class="currency-indicator"
+ size={balance.currency.length}
+ maxLength={balance.currency.length}
+ tabIndex={-1}
+ value={balance.currency}
+ />
+ &nbsp;
+ <input
+ type="number"
+ // ref={ref}
+ id="withdraw-amount"
+ name="withdraw-amount"
+ value={form.amount ?? ""}
+ onChange={(e): void => {
+ form.amount = e.currentTarget.value;
+ updateForm(structuredClone(form));
+ }}
+ />
+ &nbsp;
+ <label class="toggle">
+ <input
+ class="toggle-checkbox"
+ type="checkbox"
+ onChange={(e): void => {
+ form.isDebit = !form.isDebit;
+ updateForm(structuredClone(form));
+ }}
+ />
+ <div class="toggle-switch"></div>
+ </label>
+ </div>
+ <ShowInputErrorLabel
+ message={errors?.amount}
+ isDirty={form.amount !== undefined}
+ />
+ </fieldset>
+ <fieldset>
+ <label>{i18n.str`Conversion rate`}</label>
+ <input value={sellRate} disabled />
+ </fieldset>
+ <fieldset>
+ <label>{i18n.str`Balance now`}</label>
+ <div style={{ width: "max-content" }}>
+ <input
+ type="text"
+ readonly
+ class="currency-indicator"
+ size={balance.currency.length}
+ maxLength={balance.currency.length}
+ tabIndex={-1}
+ value={balance.currency}
+ />
+ &nbsp;
+ <input
+ type="number"
+ id="withdraw-amount"
+ disabled
+ name="withdraw-amount"
+ value={Amounts.stringifyValue(balance)}
+ />
+ </div>
+ </fieldset>
+ <fieldset>
+ <label
+ style={{ fontWeight: "bold", color: "red" }}
+ >{i18n.str`Total cost`}</label>
+ <div style={{ width: "max-content" }}>
+ <input
+ type="text"
+ readonly
+ class="currency-indicator"
+ size={balance.currency.length}
+ maxLength={balance.currency.length}
+ tabIndex={-1}
+ value={balance.currency}
+ />
+ &nbsp;
+ <input
+ type="number"
+ // ref={ref}
+ id="withdraw-amount"
+ disabled
+ name="withdraw-amount"
+ value={amount_debit ? Amounts.stringifyValue(amount_debit) : ""}
+ />
+ </div>
+ </fieldset>
+ <fieldset>
+ <label>{i18n.str`Balance after`}</label>
+ <div style={{ width: "max-content" }}>
+ <input
+ type="text"
+ readonly
+ class="currency-indicator"
+ size={balance.currency.length}
+ maxLength={balance.currency.length}
+ tabIndex={-1}
+ value={balance.currency}
+ />
+ &nbsp;
+ <input
+ type="number"
+ // ref={ref}
+ id="withdraw-amount"
+ disabled
+ name="withdraw-amount"
+ value={balanceAfter ? Amounts.stringifyValue(balanceAfter) : ""}
+ />
+ </div>
+ </fieldset>{" "}
+ {Amounts.isZero(sellFee) ? undefined : (
+ <Fragment>
+ <fieldset>
+ <label>{i18n.str`Transfer before fee`}</label>
+ <div style={{ width: "max-content" }}>
+ <input
+ type="text"
+ readonly
+ class="currency-indicator"
+ size={balance.currency.length}
+ maxLength={balance.currency.length}
+ tabIndex={-1}
+ value={balance.currency}
+ />
+ &nbsp;
+ <input
+ // type="number"
+ style={{ color: "black" }}
+ disabled
+ value={Amounts.stringifyValue(credit_before_fee)}
+ />
+ </div>
+ </fieldset>
+
+ <fieldset>
+ <label>{i18n.str`Cashout fee`}</label>
+ <div style={{ width: "max-content" }}>
+ <input
+ type="text"
+ readonly
+ class="currency-indicator"
+ size={balance.currency.length}
+ maxLength={balance.currency.length}
+ tabIndex={-1}
+ value={balance.currency}
+ />
+ &nbsp;
+ <input
+ // type="number"
+ style={{ color: "black" }}
+ disabled
+ value={Amounts.stringifyValue(sellFee)}
+ />
+ </div>
+ </fieldset>
+ </Fragment>
+ )}
+ <fieldset>
+ <label
+ style={{ fontWeight: "bold", color: "green" }}
+ >{i18n.str`Total cashout transfer`}</label>
+ <div style={{ width: "max-content" }}>
+ <input
+ type="text"
+ readonly
+ class="currency-indicator"
+ size={balance.currency.length}
+ maxLength={balance.currency.length}
+ tabIndex={-1}
+ value={balance.currency}
+ />
+ &nbsp;
+ <input
+ type="number"
+ // ref={ref}
+ id="withdraw-amount"
+ disabled
+ name="withdraw-amount"
+ value={amount_credit ? Amounts.stringifyValue(amount_credit) : ""}
+ />
+ </div>
+ </fieldset>
+ <fieldset>
+ <label>{i18n.str`Confirmation channel`}</label>
+
+ <div class="channel">
+ <input
+ class={
+ "pure-button content " +
+ (form.channel === TanChannel.EMAIL
+ ? "pure-button-primary"
+ : "pure-button-secondary")
+ }
+ type="submit"
+ value={i18n.str`Email`}
+ onClick={async (e) => {
+ e.preventDefault();
+ form.channel = TanChannel.EMAIL;
+ updateForm(structuredClone(form));
+ }}
+ />
+ <input
+ class={
+ "pure-button content " +
+ (form.channel === TanChannel.SMS
+ ? "pure-button-primary"
+ : "pure-button-secondary")
+ }
+ type="submit"
+ value={i18n.str`SMS`}
+ onClick={async (e) => {
+ e.preventDefault();
+ form.channel = TanChannel.SMS;
+ updateForm(structuredClone(form));
+ }}
+ />
+ <input
+ class={
+ "pure-button content " +
+ (form.channel === TanChannel.FILE
+ ? "pure-button-primary"
+ : "pure-button-secondary")
+ }
+ type="submit"
+ value={i18n.str`FILE`}
+ onClick={async (e) => {
+ e.preventDefault();
+ form.channel = TanChannel.FILE;
+ updateForm(structuredClone(form));
+ }}
+ />
+ </div>
+ <ShowInputErrorLabel
+ message={errors?.channel}
+ isDirty={form.channel !== undefined}
+ />
+ </fieldset>
+ <br />
+ <div style={{ display: "flex", justifyContent: "space-between" }}>
+ <button
+ class="pure-button pure-button-secondary btn-cancel"
+ onClick={(e) => {
+ e.preventDefault();
+ onCancel();
+ }}
+ >
+ {i18n.str`Cancel`}
+ </button>
+
+ <button
+ class="pure-button pure-button-primary btn-register"
+ type="submit"
+ disabled={!!errors}
+ onClick={async (e) => {
+ e.preventDefault();
+
+ if (errors) return;
+ try {
+ const res = await createCashout({
+ amount_credit: Amounts.stringify(amount_credit),
+ amount_debit: Amounts.stringify(amount_debit),
+ subject: form.subject,
+ tan_channel: form.channel,
+ });
+ onComplete(res.data.uuid);
+ } catch (error) {
+ if (error instanceof RequestError) {
+ const errorData: SandboxBackend.SandboxError =
+ error.info.error;
+ if (error.info.status === HttpStatusCode.PreconditionFailed) {
+ saveError({
+ title: i18n.str`The account does not have sufficient funds`,
+ description: errorData.error.description,
+ debug: JSON.stringify(error.info),
+ });
+ } else if (
+ error.info.status === HttpStatusCode.ServiceUnavailable
+ ) {
+ saveError({
+ title: i18n.str`The bank does not support the TAN channel for this operation`,
+ description: errorData.error.description,
+ debug: JSON.stringify(error.info),
+ });
+ } else if (error.info.status === HttpStatusCode.Conflict) {
+ saveError({
+ title: i18n.str`No contact information for this channel`,
+ description: errorData.error.description,
+ debug: JSON.stringify(error.info),
+ });
+ } else {
+ saveError({
+ title: i18n.str`New cashout gave response error`,
+ description: errorData.error.description,
+ debug: JSON.stringify(error.info),
+ });
+ }
+ } else if (error instanceof Error) {
+ saveError({
+ title: i18n.str`Cashout failed, please report`,
+ description: error.message,
+ });
+ }
+ }
+ }}
+ >
+ {i18n.str`Create`}
+ </button>
+ </div>
+ </form>
+ </div>
+ );
+}
+
+interface ShowCashoutProps {
+ id: string;
+ onCancel: () => void;
+ onLoadNotOk: <T, E>(error: HttpResponsePaginated<T, E>) => VNode;
+}
+function ShowCashout({ id, onCancel, onLoadNotOk }: ShowCashoutProps): VNode {
+ const { i18n } = useTranslationContext();
+ const result = useCashoutDetails(id);
+ const { abortCashout, confirmCashout } = useCircuitAccountAPI();
+ const [code, setCode] = useState<string | undefined>(undefined);
+ const [error, saveError] = useState<ErrorMessage | undefined>();
+ if (!result.ok) return onLoadNotOk(result);
+ const errors = undefinedIfEmpty({
+ code: !code ? i18n.str`required` : undefined,
+ });
+ const isPending = String(result.data.status).toUpperCase() === "PENDING";
+ return (
+ <div>
+ <h1>Cashout details {id}</h1>
+ {error && (
+ <ErrorBanner error={error} onClear={() => saveError(undefined)} />
+ )}
+ <form class="pure-form">
+ <fieldset>
+ <label>
+ <i18n.Translate>Subject</i18n.Translate>
+ </label>
+ <input readOnly value={result.data.subject} />
+ </fieldset>
+ <fieldset>
+ <label>
+ <i18n.Translate>Created</i18n.Translate>
+ </label>
+ <input readOnly value={result.data.creation_time ?? ""} />
+ </fieldset>
+ <fieldset>
+ <label>
+ <i18n.Translate>Confirmed</i18n.Translate>
+ </label>
+ <input readOnly value={result.data.confirmation_time ?? ""} />
+ </fieldset>
+ <fieldset>
+ <label>
+ <i18n.Translate>Debited</i18n.Translate>
+ </label>
+ <input readOnly value={result.data.amount_debit} />
+ </fieldset>
+ <fieldset>
+ <label>
+ <i18n.Translate>Credit</i18n.Translate>
+ </label>
+ <input readOnly value={result.data.amount_credit} />
+ </fieldset>
+ <fieldset>
+ <label>
+ <i18n.Translate>Status</i18n.Translate>
+ </label>
+ <input readOnly value={result.data.status} />
+ </fieldset>
+ {isPending ? (
+ <fieldset>
+ <label>
+ <i18n.Translate>Code</i18n.Translate>
+ </label>
+ <input
+ value={code ?? ""}
+ onChange={(e) => {
+ setCode(e.currentTarget.value);
+ }}
+ />
+ <ShowInputErrorLabel
+ message={errors?.code}
+ isDirty={code !== undefined}
+ />
+ </fieldset>
+ ) : undefined}
+ </form>
+ <br />
+ <div style={{ display: "flex", justifyContent: "space-between" }}>
+ <button
+ class="pure-button pure-button-secondary btn-cancel"
+ onClick={(e) => {
+ e.preventDefault();
+ onCancel();
+ }}
+ >
+ {i18n.str`Back`}
+ </button>
+ {isPending ? (
+ <div>
+ <button
+ type="submit"
+ class="pure-button pure-button-primary button-error"
+ onClick={async (e) => {
+ e.preventDefault();
+ try {
+ const rest = await abortCashout(id);
+ onCancel();
+ } catch (error) {
+ if (error instanceof RequestError) {
+ const errorData: SandboxBackend.SandboxError =
+ error.info.error;
+ if (
+ error.info.status === HttpStatusCode.PreconditionFailed
+ ) {
+ saveError({
+ title: i18n.str`Cashout was already aborted`,
+ description: errorData.error.description,
+ debug: JSON.stringify(error.info),
+ });
+ } else {
+ saveError({
+ title: i18n.str`Aborting cashout gave response error`,
+ description: errorData.error.description,
+ debug: JSON.stringify(error.info),
+ });
+ }
+ } else if (error instanceof Error) {
+ saveError({
+ title: i18n.str`Aborting failed, please report`,
+ description: error.message,
+ });
+ }
+ }
+ }}
+ >
+ {i18n.str`Abort`}
+ </button>
+ &nbsp;
+ <button
+ type="submit"
+ disabled={!code}
+ class="pure-button pure-button-primary "
+ onClick={async (e) => {
+ e.preventDefault();
+ try {
+ if (!code) return;
+ const rest = await confirmCashout(id, {
+ tan: code,
+ });
+ } catch (error) {
+ if (error instanceof RequestError) {
+ const errorData: SandboxBackend.SandboxError =
+ error.info.error;
+ saveError({
+ title: i18n.str`Confirmation of cashout gave response error`,
+ description: errorData.error.description,
+ debug: JSON.stringify(error.info),
+ });
+ } else if (error instanceof Error) {
+ saveError({
+ title: i18n.str`Confirmation failed, please report`,
+ description: error.message,
+ });
+ }
+ }
+ }}
+ >
+ {i18n.str`Confirm`}
+ </button>
+ </div>
+ ) : (
+ <div />
+ )}
+ </div>
+ </div>
+ );
+}
diff --git a/packages/demobank-ui/src/pages/HomePage.tsx b/packages/demobank-ui/src/pages/HomePage.tsx
index 76eb8d515..5af195f48 100644
--- a/packages/demobank-ui/src/pages/HomePage.tsx
+++ b/packages/demobank-ui/src/pages/HomePage.tsx
@@ -50,7 +50,6 @@ export function HomePage({ onRegister }: { onRegister: () => void }): VNode {
}
function saveErrorAndLogout(error: PageStateType["error"]): void {
- console.log("rrot", error);
saveError(error);
backend.logOut();
}
@@ -124,7 +123,6 @@ function handleNotOkResult(
return function handleNotOkResult2<T, E>(
result: HttpResponsePaginated<T, E>,
): VNode {
- console.log("qweqwe", JSON.stringify(result, undefined, 2));
if (result.clientError && result.isUnauthorized) {
onErrorHandler({
title: i18n.str`Wrong credentials for "${account}"`,
diff --git a/packages/demobank-ui/src/pages/Routing.tsx b/packages/demobank-ui/src/pages/Routing.tsx
index 55317f4ed..48f226574 100644
--- a/packages/demobank-ui/src/pages/Routing.tsx
+++ b/packages/demobank-ui/src/pages/Routing.tsx
@@ -19,7 +19,7 @@ import {
useTranslationContext,
} from "@gnu-taler/web-util/lib/index.browser";
import { createHashHistory } from "history";
-import { h, VNode, } from "preact";
+import { h, VNode } from "preact";
import { Router, route, Route } from "preact-router";
import { useEffect } from "preact/hooks";
import { Loading } from "../components/Loading.js";
diff --git a/packages/demobank-ui/src/scss/bank.scss b/packages/demobank-ui/src/scss/bank.scss
index 2bd5f317a..16370227b 100644
--- a/packages/demobank-ui/src/scss/bank.scss
+++ b/packages/demobank-ui/src/scss/bank.scss
@@ -278,3 +278,35 @@ h1.nav {
.pure-form > fieldset > input[disabled] {
color: black !important;
}
+.pure-form > fieldset > div > input[disabled] {
+ color: black !important;
+}
+
+.pure-form > fieldset > div.channel > div {
+ display: inline-block;
+ margin: 1em;
+ border: 1px black solid;
+ width: fit-content;
+ padding: 0.4em;
+ cursor: pointer;
+}
+
+.button-success {
+ background: rgb(28, 184, 65);
+ /* this is a green */
+}
+
+.button-error {
+ background: rgb(202, 60, 60);
+ /* this is a maroon */
+}
+
+.button-warning {
+ background: rgb(223, 117, 20);
+ /* this is an orange */
+}
+
+.button-secondary {
+ background: rgb(66, 184, 221);
+ /* this is a light blue */
+}
diff --git a/packages/demobank-ui/src/scss/main.scss b/packages/demobank-ui/src/scss/main.scss
index b92260af0..b9a46718f 100644
--- a/packages/demobank-ui/src/scss/main.scss
+++ b/packages/demobank-ui/src/scss/main.scss
@@ -1,4 +1,5 @@
@use "pure";
@use "bank";
@use "demo";
+@use "toggle";
@use "colors-bank";
diff --git a/packages/demobank-ui/src/scss/toggle.scss b/packages/demobank-ui/src/scss/toggle.scss
new file mode 100644
index 000000000..24636da2f
--- /dev/null
+++ b/packages/demobank-ui/src/scss/toggle.scss
@@ -0,0 +1,51 @@
+$green: #56c080;
+
+.toggle {
+ cursor: pointer;
+ display: inline-block;
+}
+.toggle-switch {
+ display: inline-block;
+ background: #ccc;
+ border-radius: 16px;
+ width: 58px;
+ height: 32px;
+ position: relative;
+ vertical-align: middle;
+ transition: background 0.25s;
+ &:before,
+ &:after {
+ content: "";
+ }
+ &:before {
+ display: block;
+ background: linear-gradient(to bottom, #fff 0%, #eee 100%);
+ border-radius: 50%;
+ box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.25);
+ width: 24px;
+ height: 24px;
+ position: absolute;
+ top: 4px;
+ left: 4px;
+ transition: left 0.25s;
+ }
+ .toggle:hover &:before {
+ background: linear-gradient(to bottom, #fff 0%, #fff 100%);
+ box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.5);
+ }
+ .toggle-checkbox:checked + & {
+ background: $green;
+ &:before {
+ left: 30px;
+ }
+ }
+}
+.toggle-checkbox {
+ position: absolute;
+ visibility: hidden;
+}
+.toggle-label {
+ margin-left: 5px;
+ position: relative;
+ top: 2px;
+}
diff --git a/packages/demobank-ui/src/utils.ts b/packages/demobank-ui/src/utils.ts
index 642b3c68d..49b9ac276 100644
--- a/packages/demobank-ui/src/utils.ts
+++ b/packages/demobank-ui/src/utils.ts
@@ -59,6 +59,21 @@ export type WithIntermediate<Type extends object> = {
: Type[prop] | undefined;
};
+export enum TanChannel {
+ SMS = "sms",
+ EMAIL = "email",
+ FILE = "file",
+}
+export enum CashoutStatus {
+ // The payment was initiated after a valid
+ // TAN was received by the bank.
+ CONFIRMED = "confirmed",
+
+ // The cashout was created and now waits
+ // for the TAN by the author.
+ PENDING = "pending",
+}
+
// export function partialWithObjects<T extends object>(obj: T | undefined, () => complete): WithIntermediate<T> {
// const root = obj === undefined ? {} : obj;
// return Object.entries(root).([key, value]) => {